diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..52001fd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: + - main + +jobs: + test: + name: Test the project + runs-on: windows-latest + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - name: Set up a specific Java version + uses: actions/setup-java@v3 + with: + distribution: "temurin" # OR adopt OR microsoft OR... + java-version: "21" + - name: Run all unit tests + run: ./gradlew test --stacktrace + - name: Upload test reports + if: always() # Run even if the previous steps failed + uses: actions/upload-artifact@v3 + with: + name: tests-report + path: build/reports/tests/ diff --git a/.github/workflows/update-prod-branch.yml b/.github/workflows/update-prod-branch.yml new file mode 100644 index 0000000..5975a2c --- /dev/null +++ b/.github/workflows/update-prod-branch.yml @@ -0,0 +1,18 @@ +name: Fast-forward the prod branch if the commit has release tag + +on: + push: + tags: + - v* + +jobs: + merge-into-prod: + name: Rebase (fast forward) the prod branch onto the main + runs-on: ubuntu-latest + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - uses: emiliopedrollo/auto-merge@v1.2.0 + with: + target_branch: 'prod' + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac4e13f --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +asset/windows/vlc +raw/vlc* +raw/upx* + +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/uiDesigner.xml +.idea/compiler.xml +.idea/kotlinc.xml +.idea/libraries/ +.idea/misc.xml +.idea/gradle.xml +.idea/workspace.xml +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +*.log diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..2d18921 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Cutcon \ No newline at end of file diff --git a/.idea/icon.svg b/.idea/icon.svg new file mode 100644 index 0000000..5cfb4f6 --- /dev/null +++ b/.idea/icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/.idea/runConfigurations/Clean.xml b/.idea/runConfigurations/Clean.xml new file mode 100644 index 0000000..ccdb4f9 --- /dev/null +++ b/.idea/runConfigurations/Clean.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Package_Release_Exe.xml b/.idea/runConfigurations/Package_Release_Exe.xml new file mode 100644 index 0000000..ac2c681 --- /dev/null +++ b/.idea/runConfigurations/Package_Release_Exe.xml @@ -0,0 +1,30 @@ + + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Package_Release_Exe_Full_VLC.xml b/.idea/runConfigurations/Package_Release_Exe_Full_VLC.xml new file mode 100644 index 0000000..386dbbe --- /dev/null +++ b/.idea/runConfigurations/Package_Release_Exe_Full_VLC.xml @@ -0,0 +1,30 @@ + + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Run_Release.xml b/.idea/runConfigurations/Run_Release.xml new file mode 100644 index 0000000..ff28b94 --- /dev/null +++ b/.idea/runConfigurations/Run_Release.xml @@ -0,0 +1,31 @@ + + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Run_Release_Distributable.xml b/.idea/runConfigurations/Run_Release_Distributable.xml new file mode 100644 index 0000000..9e17401 --- /dev/null +++ b/.idea/runConfigurations/Run_Release_Distributable.xml @@ -0,0 +1,31 @@ + + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Run_Release_Full_VLC.xml b/.idea/runConfigurations/Run_Release_Full_VLC.xml new file mode 100644 index 0000000..de8d30e --- /dev/null +++ b/.idea/runConfigurations/Run_Release_Full_VLC.xml @@ -0,0 +1,31 @@ + + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Tests__All_.xml b/.idea/runConfigurations/Tests__All_.xml new file mode 100644 index 0000000..93f7fcd --- /dev/null +++ b/.idea/runConfigurations/Tests__All_.xml @@ -0,0 +1,32 @@ + + + + + + + + true + true + false + false + + + diff --git a/.idea/runConfigurations/Tests__UI_.xml b/.idea/runConfigurations/Tests__UI_.xml new file mode 100644 index 0000000..9913c82 --- /dev/null +++ b/.idea/runConfigurations/Tests__UI_.xml @@ -0,0 +1,31 @@ + + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Tests__Unit_.xml b/.idea/runConfigurations/Tests__Unit_.xml new file mode 100644 index 0000000..6506a1a --- /dev/null +++ b/.idea/runConfigurations/Tests__Unit_.xml @@ -0,0 +1,31 @@ + + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..886fb40 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c5e0c30 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# History of notable changes introduced in each version + +## v1 (2024-02-29) +#### Announcement + - (En) First release of the app + - (Fa) نخستین انتشار برنامه diff --git a/README.md b/README.md new file mode 100644 index 0000000..535352e --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +
+ + + + Cutcon demo screenshot + +
+ +# Logo Cutcon +Cut, convert, and view media files (video, audio, image). Free and open source. + +With ability to generate lossless (raw) and lossy output. +Supports adding watermark (overlay) and start image (intro) to MP4 output and artwork (album art) to MP3 output. + +Currently, only Windows is supported. +Support for Linux will come soon. + +## Download +See the bottom section of the [release page](https://github.com/mahozad/cutcon/releases). + +## The name +In addition to being a merge of the words **cut** and **con**vert, +it is also a Persian/Farsi name, meaning literally, *do the cut*. + +## The logo +Represents the capitalized name of the app, **CUTCON**. \ No newline at end of file diff --git a/asset/common/cover-little-padding.svg b/asset/common/cover-little-padding.svg new file mode 100644 index 0000000..54d5214 --- /dev/null +++ b/asset/common/cover-little-padding.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/asset/common/cover.svg b/asset/common/cover.svg new file mode 100644 index 0000000..c37b453 --- /dev/null +++ b/asset/common/cover.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/asset/common/notification.wav b/asset/common/notification.wav new file mode 100644 index 0000000..ec6fde4 Binary files /dev/null and b/asset/common/notification.wav differ diff --git a/asset/common/shutter.wav b/asset/common/shutter.wav new file mode 100644 index 0000000..9ba6b89 Binary files /dev/null and b/asset/common/shutter.wav differ diff --git a/asset/common/splash-screen.gif b/asset/common/splash-screen.gif new file mode 100644 index 0000000..2e7fe86 Binary files /dev/null and b/asset/common/splash-screen.gif differ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..750cf9d --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,332 @@ +import de.undercouch.gradle.tasks.download.Download +import org.gradle.api.tasks.testing.logging.TestLogEvent +import org.gradle.api.tasks.wrapper.Wrapper.DistributionType +import org.jetbrains.compose.desktop.application.dsl.TargetFormat.* +import org.jetbrains.kotlin.cli.common.toBooleanLenient +import java.nio.file.Path +import java.time.LocalDate +import kotlin.io.path.* + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.buildDownload) + // Alternative: https://github.com/yshrsmz/BuildKonfig + alias(libs.plugins.buildConfig) +} + +val appRawFilesPath = rootDir.toPath() / "raw" +val appResourcesPath = rootDir.toPath() / "asset" +val vlcDirectoryName = "vlc" +val isVlcFull = System.getenv("fullVlc").toBooleanLenient() ?: false +val shouldMinifyVlc = System.getenv("minifyVlc").toBooleanLenient() ?: true +val releaseDate: LocalDate = LocalDate.of(2024, 2, 29) + +group = "ir.mahozad" +version = "1" + +sourceSets { + create("uiTest") { + // Adds files from the main source set to the compile classpath and runtime classpath of this new source set. + // sourceSets.main.output is a collection of all the directories containing compiled main classes and resources + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output + } +} +// Makes the uiTestImplementation configuration extend from testImplementation, +// which means that all the declared dependencies of the test code (and transitively the main as well) +// also become dependencies of this new configuration +val uiTestImplementation by configurations.getting { + extendsFrom(configurations.testImplementation.get()) +} +val uiTest = task("uiTest") { + description = "Runs UI tests." + group = "verification" + + testClassesDirs = sourceSets["uiTest"].output.classesDirs + classpath = sourceSets["uiTest"].runtimeClasspath + shouldRunAfter("test") + + testLogging { + events(TestLogEvent.PASSED) + } +} +tasks.check { dependsOn(uiTest) } + +tasks.withType { + useJUnitPlatform() + // See https://github.com/JetBrains/compose-multiplatform/issues/3244 + dependsOn("prepareAppResources") + afterEvaluate { + systemProperty( + "compose.application.resources.dir", + tasks.getByName("prepareAppResources").destinationDir + ) + } +} + +@OptIn(ExperimentalPathApi::class) +tasks.clean { + delete += appResourcesPath + .walk(PathWalkOption.INCLUDE_DIRECTORIES) + .filter(Path::isDirectory) + .filter { it.name == vlcDirectoryName } +} + +/** + * See https://docs.gradle.org/current/userguide/working_with_files.html + */ +afterEvaluate { + tasks.named("prepareAppResources") { + dependsOn("prepareVlcPlugins") + } +} + +val downloadVlc by tasks.register( + name = "downloadVlc", + type = Download::class +) { + val baseUrl = "https://get.videolan.org" + val version = libs.versions.vlc.get() + // Make sure to download the 64-bit version of VLC + src("$baseUrl/vlc/$version/win64/vlc-$version-win64.zip") + dest((appRawFilesPath / "vlc-$version.zip").toFile()) + overwrite(false) // Prevents re-download every time +} + +val unzipVlc by tasks.register( + name = "unzipVlc", + type = Copy::class +) { + dependsOn(downloadVlc) + dependsOn(unzipUpx) + from(zipTree(downloadVlc.dest)) { + // All the below is to copy only the contents of the root directory + // in the archive and not the root directory itself + // See https://docs.gradle.org/current/userguide/working_with_files.html#ex-unpacking-a-subset-of-a-zip-file + eachFile { relativePath = RelativePath(true, *relativePath.segments.drop(1).toTypedArray()) } + includeEmptyDirs = false // Deletes empty remainder directories + } + into(appRawFilesPath / "vlc") +} + +val downloadUpx by tasks.register( + name = "downloadUpx", + type = Download::class +) { + val version = libs.versions.upx.get() + src("https://github.com/upx/upx/releases/download/v$version/upx-$version-win64.zip") + dest((appRawFilesPath / "upx-$version.zip").toFile()) + overwrite(false) // Prevents re-download every time +} + +val unzipUpx by tasks.register( + name = "unzipUpx", + type = Copy::class +) { + dependsOn(downloadUpx) + from(zipTree(downloadUpx.dest)) { + // All the below is to copy only the contents of the root directory + // in the archive and not the root directory itself + // See https://docs.gradle.org/current/userguide/working_with_files.html#ex-unpacking-a-subset-of-a-zip-file + eachFile { relativePath = RelativePath(true, *relativePath.segments.drop(1).toTypedArray()) } + includeEmptyDirs = false // Deletes empty remainder directories + } + include("**/upx.exe") + into(appRawFilesPath.toFile()) +} + +tasks.register( + name = "prepareVlcPlugins", + type = Copy::class +) { + dependsOn(unzipVlc) + dependsOn(unzipUpx) + from(unzipVlc.outputs) + // If isVlcFull == true and then in a later execution isVlcFull == false (i.e. includes become more restrictive) + // or if we directly remove include(s) or modify their patterns in a way that removes some files, then + // the task will not remove those now-not-needed files. For these scenarios, clean the project first + if (isVlcFull) { + include("*.dll") + include("plugins/**/*.dll") + } else { + include( + "libvlc.dll", + "libvlccore.dll", + "plugins/access/libfilesystem_plugin.dll", + // Along with audio_output/libmmdevice_plugin.dll normalizes audio loudness + "plugins/audio_filter/libnormvol_plugin.dll", + "plugins/audio_filter/libscaletempo_pitch_plugin.dll", + "plugins/audio_filter/libscaletempo_plugin.dll", + "plugins/audio_output/libdirectsound_plugin.dll", + // Multimedia device output (along with audio_filter/libnormvol_plugin.dll normalizes audio loudness) + "plugins/audio_output/libmmdevice_plugin.dll", + // Various audio and video decoders/encoders delivered by the FFmpeg library. + // When almost all other DLLs were available (not deleted) I deleted this file and the player still worked but the video flickered (when pausing and sometimes during playback) + "plugins/codec/libavcodec_plugin.dll", + "plugins/demux/libts_plugin.dll", + "plugins/packetizer/libpacketizer_mpeg4audio_plugin.dll", + "plugins/packetizer/libpacketizer_mpeg4video_plugin.dll", + // For speedup of live-stream video to work correctly and smoothly; speedup of finished videos works without this + "plugins/stream_filter/libcache_read_plugin.dll", + // Can include this to show the text overlay (save path) when taking a screenshot + // "plugins/text_renderer/libfreetype_plugin.dll", + "plugins/video_chroma/libswscale_plugin.dll", + // To de-interlace the video playback; otherwise, not needed + "plugins/video_filter/libdeinterlace_plugin.dll", + // Recommended video output for Windows Vista and later + "plugins/video_output/libdirect3d9_plugin.dll", + // Recommended video output for Windows 8 and later + "plugins/video_output/libdirect3d11_plugin.dll", + // Recommended video output for Windows XP + "plugins/video_output/libdirectdraw_plugin.dll", + "plugins/video_output/libdrawable_plugin.dll", + // This is needed when drawing to a skia bitmap surface in the app code + "plugins/video_output/libvmem_plugin.dll" + ) + } + if (shouldMinifyVlc) { + eachFile { + ProcessBuilder() + .command("${appRawFilesPath / "upx.exe"}", "vlc/$path") + .directory(appRawFilesPath.toFile()) + .start() + .inputReader() + .forEachLine(::println) + } + } + into(appResourcesPath / "windows" / vlcDirectoryName) +} + +/** + * See the buildconfig plugin above + * and https://github.com/gmazzo/gradle-buildconfig-plugin + * Could also just create a file and read it in the class. + */ +buildConfig { + packageName("${project.group}.${project.name.lowercase()}") + buildConfigField("String", "APP_NAME", """"${project.name}"""") + buildConfigField("String", "APP_VERSION", """"${project.version}"""") + buildConfigField("String", "VLC_DIRECTORY_NAME", """"$vlcDirectoryName"""") + buildConfigField( + "java.time.LocalDate", + "APP_RELEASE_DATE", + """LocalDate.of(${releaseDate.year}, ${releaseDate.monthValue}, ${releaseDate.dayOfMonth})""" + ) +} + +kotlin { + // With toolchain, we say, no matter what JDK is used for running Gradle itself, + // we want this specific JDK for building/compiling our code (aka tasks of our library/app). + // So, if Gradle finds a local JDK matching the specified toolchain properties, + // it will use it to compile our library/app code. Otherwise, it will download a proper JDK. + // Additionally, if we haven't set source and target compatibility of our code explicitly, + // Gradle configures source and target compatibility to be equal to the toolchain ones. + // Note that setting a toolchain via the kotlin extension + // updates the toolchain for Java compile tasks as well. + // See https://kotlinlang.org/docs/gradle-configure-project.html + // and https://blog.gradle.org/java-toolchains + // and https://blog.jetbrains.com/kotlin/2021/11/gradle-jvm-toolchain-support-in-the-kotlin-plugin/ + // and https://kotlinlang.org/docs/gradle-compiler-options.html#all-compiler-options + jvmToolchain(libs.versions.jvm.get().toInt()) +} + +dependencies { + implementation(compose.desktop.currentOs) + // Alternative icon packs: + // https://github.com/DevSrSouza/compose-icons: FontAwesome and so on + // https://github.com/microsoft/fluentui-system-icons: Has Android drawable files + // which now can be used in Compose Multiplatform with the new resources library + implementation(compose.materialIconsExtended) + implementation(libs.kotlin.coroutines.core) + implementation(libs.kotlin.coroutines.swing) + implementation(libs.mahozad.wavySlider) + // Version 1.4.5 crashed the app when running app exe. + // Requires modules("java.naming") in compose { nativeDistribution block below. + // See https://github.com/JetBrains/compose-multiplatform/issues/1358 + // and https://github.com/JetBrains/compose-multiplatform/issues/1977 + implementation(libs.logback.classic) + implementation(libs.kotlinLogging) + // Fixes libraries that use log4j for logging (for example, apache poi) + // Maybe could use log4j-over-slf4j library instead + // See https://www.slf4j.org/legacy.html#log4j-over-slf4j + implementation(libs.log4jToSlf4j) + implementation(libs.persianDateTime) + implementation(libs.ffmpeg) // The main artifact + // Cannot use version catalog containing artifact classifier + // See https://github.com/gradle/gradle/issues/17169 + // and https://stackoverflow.com/q/71485996 + implementation(variantOf(libs.ffmpeg) { classifier("windows-x86_64-gpl") }) + implementation(libs.vlcj) + implementation(libs.apache.tika) + // Alternative: implementation("com.mpatric:mp3agic:0.9.1") but it does not support extracting FLAC cover art etc. + implementation(libs.jAudioTagger) + implementation(libs.jna.jpms) + implementation(libs.jna.platform.jpms) + + testImplementation(compose.desktop.uiTestJUnit4) + testImplementation(libs.kotlin.coroutines.test) + testImplementation(libs.junit5) + // Includes the Vintage engine to be able to run JUnit 4 tests as well + testImplementation(libs.junit5.vintageEngine) + testImplementation(libs.assertj) + testImplementation(libs.mockk) + testImplementation(libs.imageComparison) +} + +compose.desktop { + application { + mainClass = "ir.mahozad.cutcon.MainKt" + /** + * Java supports showing native OS splash screen with `-splash` JVM argument. + * So, here an image is set to be shown as native OS splash screen. + * It works when the app is launched with its installed exe or one of run*Distributable tasks. + * To also set the splash for application uber jar (created with one of package*UberJar* tasks), + * copy the splash image into classpath (like src/main/resources directory) + * and update all the Jar tasks in the build script like below: + * + * ```kotlin + * tasks.withType { + * manifest { + * attributes["SplashScreen-Image"] = "image.png" + * } + * } + * ``` + * + * See https://github.com/JetBrains/compose-multiplatform/issues/3233 + * and https://docs.oracle.com/javase/tutorial/uiswing/misc/splashscreen.html + * and https://learn.microsoft.com/en-us/windows/uwp/launch-resume/add-a-splash-screen + * + * The splash image can have transparency (PNG and GIF are supported). + * It seems that the Windows OS does not respect the GIF speed and no-loop settings and + * plays the animation at a low frame rate. See https://stackoverflow.com/q/25382400 + */ + jvmArgs += "-splash:app/resources/splash-screen.gif" + nativeDistributions { + // java.naming is to prevent app crash when running the app exe + // (introduced with ch.qos.logback:logback-classic version 1.4.5) + // jdk.unsupported is for showing the display image when running the app exe + modules("java.naming", "jdk.unsupported") + targetFormats(Dmg, Msi, Exe, Deb) + packageVersion = "${project.version}.0.0" + packageName = project.name + vendor = "Mahdi Hosseinzadeh" + windows { + iconFile = (appRawFilesPath / "logo.ico").toFile() + menuGroup = project.name // Start menu shortcut + } + appResourcesRootDir = appResourcesPath.toFile() + buildTypes.release.proguard { + version = libs.versions.proguard.get() + configurationFiles.from("rules.pro") + } + } + } +} + +tasks.wrapper { + gradleVersion = libs.versions.gradle.get() + networkTimeout = 60_000 // milliseconds + distributionType = DistributionType.ALL + validateDistributionUrl = false +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..937a711 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,52 @@ +[versions] +gradle = "8.6" +jvm = "21" +kotlin = "1.9.22" +proguard = "7.4.2" +compose-multiplatform = "1.5.12" +buildConfig = "4.1.2" +buildDownload = "5.5.0" +kotlin-coroutines = "1.8.0" +logback = "1.4.11" +log4jToSlf4j = "2.21.1" +kotlinLogging = "5.1.0" +ffmpeg = "5.1.2-1.5.8" +vlc = "3.0.20" +vlcj = "4.8.2" +upx = "4.2.1" +wavySlider="1.0.0" +persianDateTime = "4.2.1" +junit5 = "5.10.0" +assertj = "3.24.2" +mockk = "1.13.8" +tika = "2.9.1" +jna = "5.13.0" +jaudioTagger = "3.0.1" +image-comparison = "4.4.0" + +[libraries] +kotlin-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlin-coroutines" } +kotlin-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlin-coroutines" } +kotlin-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlin-coroutines" } +kotlinLogging = { group = "io.github.oshai", name = "kotlin-logging-jvm", version.ref = "kotlinLogging" } +logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } +log4jToSlf4j = { group = "org.apache.logging.log4j", name = "log4j-to-slf4j", version.ref = "log4jToSlf4j" } +ffmpeg = { group = "org.bytedeco", name = "ffmpeg", version.ref = "ffmpeg" } +vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" } +mahozad-wavySlider = { group = "ir.mahozad.multiplatform", name = "wavy-slider", version.ref = "wavySlider" } +persianDateTime = { group = "com.github.mfathi91", name = "persian-date-time", version.ref = "persianDateTime" } +apache-tika = { group = "org.apache.tika", name = "tika-core", version.ref = "tika" } +jAudioTagger = { group = "net.jthink", name = "jaudiotagger", version.ref = "jaudioTagger" } +jna-jpms = { group = "net.java.dev.jna", name = "jna-jpms", version.ref = "jna" } +jna-platform-jpms = { group = "net.java.dev.jna", name = "jna-platform-jpms", version.ref = "jna" } +junit5 = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit5" } +junit5-vintageEngine = { group = "org.junit.vintage", name = "junit-vintage-engine", version.ref = "junit5" } +imageComparison = { group = "com.github.romankh3", name = "image-comparison", version.ref = "image-comparison" } +assertj = { group = "org.assertj", name = "assertj-core", version.ref = "assertj" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +buildConfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildConfig" } +buildDownload = { id = "de.undercouch.download", version.ref = "buildDownload" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4981abd --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip +networkTimeout=60000 +validateDistributionUrl=false +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/raw/README.md b/raw/README.md new file mode 100644 index 0000000..d504669 --- /dev/null +++ b/raw/README.md @@ -0,0 +1,87 @@ +## Splash screen +See the test in UiTest source set to generate the splash screen GIF. + +## Demo images +Run the app and play the file available in git branch demo-video downloaded from pexels.com. + +With FFmpeg 5.1 gpl: +ffmpeg.exe -f gdigrab -framerate 1 -i title="Cutcon" out.apng +ffmpeg.exe -i out.apng -r 1/1 "$file%03d".png + +Select one of the extracted frames +Open it in Inkscape +Add a stroke with width = hairline around the picture +Export the whole document to PNG +Optimize with https://tinypng.com/ + +## Notification sounds +To play mp3 format audio in Java, see https://stackoverflow.com/tags/javasound/info + +camera sound effects downloaded from https://freesound.org + +The free *[pristine](notification-pristine.mp3)* sound is licensed under the Creative Commons Attribution license. +- https://notificationsounds.com/message-tones/pristine-609 + +Some other notification sounds: +1- https://notificationsounds.com/notification-sounds/definite-555 +2- https://notificationsounds.com/free-jingles-and-logos/playful-notification +3- https://notificationsounds.com/message-tones/relax-message-tone +4- https://notificationsounds.com/notification-sounds/ringtone-you-would-be-glad-to-know + +To convert the sounds to WAV format (supported by Java), use FFmpeg: + +```shell +./ffmpeg -i sound.mp3 result.wav +``` + +## Ico files +.ico sizes for Windows: 16, 24, 32, 48, 256 +See https://learn.microsoft.com/en-us/windows/apps/design/style/iconography/app-icon-construction#:~:text=Apps%20should%20have%2C%20at%20the%20bare%20minimum%3A + +If you encounter error with imagemagick, set the result file path to a non-protected directory like *Desktop* +(directories like *Program Files* and its subdirectories etc. are protected). + +Create a high-res PNG (for example, 2048x2048) from the SVG and then convert the PNG to .ico with imagemagick +(it automatically creates the embedded 256 size in PNG format and the smaller ones in ICO format): + +```shell +./magick logo.png -background transparent -define icon:auto-resize="16,20,24,32,40,48,64,256" C:/Users/Mahdi/Desktop/result.ico +``` + +OR: + +```shell +./magick logo.png -background none -resize 256x256 -density 256x256 C:/Users/Mahdi/Desktop/result.ico +``` + +To inspect icon sizes: + +```shell +./magick identify "C:/Users/Mahdi/Desktop/image.ico" +``` + +Here are icon sizes of exe files of a few programs +(icons extracted with https://www.nirsoft.net/utils/iconsext.html) +and inspected with imagemagick as described above: + +| Application | 16 | 20 | 24 | 32 | 40 | 48 | 60 | 64 | 72 | 80 | 96 | 256 | +|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----| +| [Google Chrome 106](https://www.google.com/chrome/) | ✓ | | | ✓ | | ✓ | | | | | | ✓ | +| [IntelliJ IDEA 2022.2.3](https://www.jetbrains.com/idea/) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ | | | | ✓ | +| [MS PowerToys 0.63.0](https://github.com/microsoft/PowerToys) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ | | | | ✓ | +| [MS Paint 11.2208.6.0](https://en.wikipedia.org/wiki/Microsoft_Paint) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ | | | | ✓ | +| [MS Task Manager 10.0](https://en.wikipedia.org/wiki/Task_Manager_(Windows)) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ | | | | ✓ | +| [MS Word 2021](https://www.microsoft.com/en-ww/microsoft-365/word) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| [MS Visual Studio 2022](https://visualstudio.microsoft.com/) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | + +Notes: + 1. No app has included the `128` size + 2. The `256` size is `PNG` format; others are `ICO` + 3. `MS` is short for *Microsoft* + +Also, +see https://stackoverflow.com/a/74392449 +and https://stackoverflow.com/q/3236115 +and https://stackoverflow.com/q/11423711 +and https://developer.apple.com/design/human-interface-guidelines/foundations/app-icons/ +and https://gist.github.com/azam/3b6995a29b9f079282f3 diff --git a/raw/demo-dark.png b/raw/demo-dark.png new file mode 100644 index 0000000..bb6043b Binary files /dev/null and b/raw/demo-dark.png differ diff --git a/raw/demo-light.png b/raw/demo-light.png new file mode 100644 index 0000000..beb3b9c Binary files /dev/null and b/raw/demo-light.png differ diff --git a/raw/icons.svg b/raw/icons.svg new file mode 100644 index 0000000..4402c8a --- /dev/null +++ b/raw/icons.svg @@ -0,0 +1,1503 @@ + + + + diff --git a/raw/logo.ico b/raw/logo.ico new file mode 100644 index 0000000..3cd0c93 Binary files /dev/null and b/raw/logo.ico differ diff --git a/raw/logo.svg b/raw/logo.svg new file mode 100644 index 0000000..db2d2dc --- /dev/null +++ b/raw/logo.svg @@ -0,0 +1,1335 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/raw/notification-pristine.mp3 b/raw/notification-pristine.mp3 new file mode 100644 index 0000000..ed3e308 Binary files /dev/null and b/raw/notification-pristine.mp3 differ diff --git a/raw/progress-seek.svg b/raw/progress-seek.svg new file mode 100644 index 0000000..7e0a8e3 --- /dev/null +++ b/raw/progress-seek.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/raw/test-image-1.svg b/raw/test-image-1.svg new file mode 100644 index 0000000..0601821 --- /dev/null +++ b/raw/test-image-1.svg @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/raw/test-image-2.svg b/raw/test-image-2.svg new file mode 100644 index 0000000..72d3b27 --- /dev/null +++ b/raw/test-image-2.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + diff --git a/rules.pro b/rules.pro new file mode 100644 index 0000000..9e71d0b --- /dev/null +++ b/rules.pro @@ -0,0 +1,23 @@ +-keep class kotlinx.coroutines.** { *; } + +# Our own code is less than 1 MB; keep it all +-keep class ir.mahozad.cutcon.** { *; } +-keep class org.jaudiotagger.tag.** { *; } +-keep class ch.qos.logback.** { *; } +-keep class uk.co.caprica.** { *; } +-keep class org.bytedeco.** { *; } +-keep class com.sun.jna.** { *; } + +# See https://github.com/bytedeco/javacv/wiki/Configuring-Proguard-for-JavaCV +-dontwarn org.bytedeco.** +-dontwarn org.apache.** +-dontwarn ch.qos.logback.** +-dontwarn kotlinx.datetime.** +-dontwarn io.github.oshai.kotlinlogging.coroutines.** + +# Obfuscation breaks coroutines/ktor for some reason +-dontobfuscate +# Optimization breaks Compose classes for some reason +-dontoptimize +#-dontshrink +#-dontpreverify diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..b8a816d --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven(url = "https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} + +rootProject.name = "Cutcon" diff --git a/src/main/kotlin/ir/mahozad/cutcon/Defaults.kt b/src/main/kotlin/ir/mahozad/cutcon/Defaults.kt new file mode 100644 index 0000000..0fd84d1 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/Defaults.kt @@ -0,0 +1,98 @@ +@file:Suppress("MayBeConstant") + +package ir.mahozad.cutcon + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ir.mahozad.cutcon.component.DefaultDurationConverter +import ir.mahozad.cutcon.component.SystemDateTime +import ir.mahozad.cutcon.localization.Language +import ir.mahozad.cutcon.localization.LanguageEn +import ir.mahozad.cutcon.model.* +import java.net.URL +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.div +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +const val DISPLAY_WIDTH = 600 // 16:9 aspect ratio +const val DISPLAY_HEIGHT = 338 // 16:9 aspect ratio +const val DISPLAY_WIDTH_MINI = 356 // 16:9 aspect ratio +const val DISPLAY_HEIGHT_MINI = 200 // 16:9 aspect ratio +const val WINDOW_WIDTH_WITH_PANEL = DISPLAY_WIDTH + 384 +const val WINDOW_WIDTH_NO_PANEL = DISPLAY_WIDTH + 16 +const val WINDOW_HEIGHT_REGULAR = DISPLAY_HEIGHT + (4 /* Rows of components */ * 48) + 34 +const val WINDOW_WIDTH_MINI = 372 +const val WINDOW_HEIGHT_MINI = 314 + +val defaultAudioImage by lazy { + decodeImage(path = assetsPath / "cover.svg", mimeType = "image/svg+xml") +} + +/** + * Because VLC has a problem that finishes the media when seeking to 1.0f, + * the seek fraction is a few seconds (aka [liveSeekSafeMargin]) before the media end. + */ +val liveSeekFraction: Float get() { + val now = SystemDateTime.nowTime() + val nowDuration = now.minute.minutes + now.second.seconds + val safeMarginFraction = liveSeekSafeMargin / nowDuration + return 1 - safeMarginFraction.toFloat().coerceIn(0f..1f) +} +val liveSeekSafeMargin = 5.seconds +val defaultSource by lazy { Source.Local(assetsPath / "cover.svg") } +val defaultTimeStamp = Duration.ZERO +val defaultClip = Clip(defaultTimeStamp, defaultTimeStamp) +val defaultFormat = Format.MP4 +val defaultQuality = Quality.MEDIUM +val defaultLanguage: Language = LanguageEn +val defaultCalendar = Calendar.GREGORIAN +val defaultTheme = Theme.LIGHT +val defaultTooltipDelay = 1.seconds +val defaultDateTimeCheckingPeriod = 3.seconds +val defaultFontSize = 13.sp +val defaultIconSize = 24.dp +val defaultChipHeight = 34.dp +val defaultInputHeight = 36.dp +val defaultIconColor = Color.Black +val defaultIsFullscreen = false +val defaultIsMiniScreen = false +val defaultIsAlwaysOnTop = false +val defaultIsSidePanelDisplayed = true +val defaultIsFinishSoundEnabled = true +val defaultIsScreenshotSoundEnabled = true +val defaultIsInterlacedFixEnabled = true +val defaultSidePanelDisplayedTab = 0 +val defaultSaveFilePath: Path? = null +val defaultOutputFrameRate = 25 +val defaultDurationConverter = DefaultDurationConverter +val defaultTimeStampString get() = defaultDurationConverter.format(defaultTimeStamp, numberOfParts = 1) +val defaultScreenshotSaveDirectory = (System.getProperty("user.home") ?: error("Could not get user home directory")) + .let(::Path) / "Pictures" / "Screenshots" / BuildConfig.APP_NAME +val defaultAspectRatio = AspectRatio.W16H9 +val defaultCoverOptions = CoverOptions( + path = null, + scale = 1f, + opacity = 1f, + position = WatermarkPosition.CENTER +) +val defaultIntroOptions = IntroOptions( + path = null, + duration = 1.seconds, + backgroundColor = Color.Black +) +val defaultIsResumed = true +val defaultAudioVolume = 1f +val defaultIsAudioMuted = false +val defaultMediaUrl = URL("file://") +val defaultSeek = 0f +val defaultSpeed = Speed.NORMAL +val defaultFastSpeed = Speed.FAST2_0 +val defaultClipToLoop: Clip? = null + +val LocalLanguage = compositionLocalOf { defaultLanguage } +val LocalCalendar = compositionLocalOf { defaultCalendar } diff --git a/src/main/kotlin/ir/mahozad/cutcon/Main.kt b/src/main/kotlin/ir/mahozad/cutcon/Main.kt new file mode 100644 index 0000000..377cfbc --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/Main.kt @@ -0,0 +1,81 @@ +package ir.mahozad.cutcon + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.* +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import ir.mahozad.cutcon.component.* +import ir.mahozad.cutcon.converter.DefaultConverterFactory +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.ui.MainWindow +import ir.mahozad.cutcon.ui.errorWindow +import kotlinx.coroutines.Dispatchers +import java.util.prefs.Preferences +import kotlin.io.path.Path + +// See logback.xml file in classpath for logback configs +private val logger = logger(name = "Main") +private var error: Throwable? = null + +/** + * Note that it does not catch exceptions thrown outside a [Window]. + * See https://github.com/JetBrains/compose-multiplatform/issues/663 + * and https://github.com/JetBrains/compose-multiplatform/issues/1764 + * and https://github.com/JetBrains/compose-multiplatform/issues/4233 + */ +@OptIn(ExperimentalComposeUiApi::class) +private val ApplicationScope.exceptionHandler + get() = WindowExceptionHandlerFactory { + WindowExceptionHandler { + logger.error(it) { "An error occurred in the application" } + // The app should be terminated so the code will continue after + // application {} because of its exitProcessOnExit = false + terminate() + error = it + } + } + +val assetsPath = System + .getProperty("compose.application.resources.dir") + ?.let(::Path) + ?.also { logger.debug { "Custom assets path: $it" } } + ?: error(Messages.ERR_COMPOSE_RES_DIR_NOT_SET) + +val viewModel = MainViewModel( + urlMaker = DefaultUrlMaker, + settings = Preferences.userRoot(), + dispatcher = Dispatchers.IO, + mediaPlayer = DefaultMediaPlayer(), + dateTimeChecker = DefaultDateTimeChecker(Dispatchers.IO), + converterFactory = DefaultConverterFactory(Dispatchers.IO), + saveFileNameGenerator = DefaultSaveFileNameGenerator +) + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + logger.info { "Application started" } + initialize() + application(exitProcessOnExit = false /* See exceptionHandler */) { + CompositionLocalProvider( + LocalWindowExceptionHandlerFactory provides exceptionHandler + ) { + MainWindow(onExitRequest = ::terminate) + } + } + error?.let(::errorWindow) + logger.info { "Application finished" } +} + +private fun initialize() { + viewModel.startUrlMaker() + viewModel.startDateTimeChecker() + viewModel.startMediaProgressListener() +} + +// This is also called when force closing/halting/killing the app process +private fun ApplicationScope.terminate() { + logger.info { "Cancelling everything..." } + viewModel.cancelEverything() + logger.info { "Exiting the application..." } + exitApplication() +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/MainViewModel.kt b/src/main/kotlin/ir/mahozad/cutcon/MainViewModel.kt new file mode 100644 index 0000000..621d7a2 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/MainViewModel.kt @@ -0,0 +1,888 @@ +package ir.mahozad.cutcon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.input.key.* +import androidx.compose.ui.res.loadImageBitmap +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import ir.mahozad.cutcon.component.* +import ir.mahozad.cutcon.converter.ConverterFactory +import ir.mahozad.cutcon.localization.Language +import ir.mahozad.cutcon.model.* +import ir.mahozad.cutcon.model.PreferenceKeys.PREF_ASPECT_RATIO +import ir.mahozad.cutcon.model.PreferenceKeys.PREF_CALENDAR +import ir.mahozad.cutcon.model.PreferenceKeys.PREF_FINISH_SOUND +import ir.mahozad.cutcon.model.PreferenceKeys.PREF_INTERLACED_FIX +import ir.mahozad.cutcon.model.PreferenceKeys.PREF_LANGUAGE +import ir.mahozad.cutcon.model.PreferenceKeys.PREF_LAST_OPEN_DIRECTORY +import ir.mahozad.cutcon.model.PreferenceKeys.PREF_LAST_SAVE_DIRECTORY +import ir.mahozad.cutcon.model.PreferenceKeys.PREF_LAST_SHOWN_CHANGELOG_VERSION +import ir.mahozad.cutcon.model.PreferenceKeys.PREF_SCREENSHOT_SOUND +import ir.mahozad.cutcon.model.PreferenceKeys.PREF_THEME +import ir.mahozad.cutcon.ui.widget.COVER_PREVIEW_SIZE +import ir.mahozad.cutcon.ui.widget.INTRO_PREVIEW_SIZE +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.jaudiotagger.audio.AudioFileIO +import java.awt.GraphicsEnvironment +import java.awt.Rectangle +import java.nio.file.Path +import java.time.LocalDate +import java.time.LocalTime +import java.util.prefs.Preferences +import javax.sound.sampled.AudioSystem +import kotlin.coroutines.CoroutineContext +import kotlin.io.path.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class MainViewModel( + dispatcher: CoroutineContext, + private val urlMaker: UrlMaker, + private val mediaPlayer: MediaPlayer, + private val dateTimeChecker: DateTimeChecker, + private val converterFactory: ConverterFactory, + private val saveFileNameGenerator: SaveFileNameGenerator, + private val settings: Preferences +) { + + private sealed interface ConversionStatus { + data object None : ConversionStatus + data object Initializing : ConversionStatus + data class InProgress(val progress: Float) : ConversionStatus + data class Success(val totalTime: Duration) : ConversionStatus + data class Failure(val throwable: Throwable) : ConversionStatus + } + + private val logger = logger(name = MainViewModel::class.simpleName ?: "") + private val coroutineScope = CoroutineScope(dispatcher) + private var conversionJob: Job? = null + private val _isAppExitConfirmDialogDisplayed = MutableStateFlow(false) + private val _isChangelogDialogDisplayed = MutableStateFlow( + settings[PREF_LAST_SHOWN_CHANGELOG_VERSION, null].let { + compareVersionStrings(BuildConfig.APP_VERSION, it) == VersionComparisonResult.NEWER + } + ) + private val _language = MutableStateFlow( + settings[PREF_LANGUAGE, null] + ?.let(Language::fromTag) + ?: defaultLanguage + ) + private val _calendar = MutableStateFlow( + settings[PREF_CALENDAR, null] + ?.let(Calendar::valueOf) + ?: defaultCalendar + ) + private val _theme = MutableStateFlow( + settings[PREF_THEME, null] + ?.let(Theme::valueOf) + ?: defaultTheme + ) + private val _isFinishSoundEnabled = MutableStateFlow( + settings[PREF_FINISH_SOUND, null] + ?.let(String::toBoolean) + ?: defaultIsFinishSoundEnabled + ) + private val _aspectRatio = MutableStateFlow( + settings[PREF_ASPECT_RATIO, null] + ?.let(AspectRatio::valueOf) + ?: defaultAspectRatio + ) + private val _isScreenshotSoundEnabled = MutableStateFlow( + settings[PREF_SCREENSHOT_SOUND, null] + ?.let(String::toBoolean) + ?: defaultIsScreenshotSoundEnabled + ) + private val _isInterlacedFixEnabled = MutableStateFlow( + settings[PREF_INTERLACED_FIX, null] + ?.let(String::toBoolean) + ?: defaultIsInterlacedFixEnabled + ) + private val _mediaInfo = MutableStateFlow( + MediaInfo( + url = defaultMediaUrl, + speed = defaultSpeed, + progress = Progress.ZERO, + isResumed = defaultIsResumed, + clipToLoop = defaultClipToLoop, + audioVolume = defaultAudioVolume, + isAudioMuted = defaultIsAudioMuted + ) + ) + private val clipSource = MutableStateFlow(defaultSource) + private val _isLoopToggleable = MutableStateFlow(false) + private val _source = MutableStateFlow(defaultSource) + private val _quality = MutableStateFlow(defaultQuality) + private val _clip = MutableStateFlow(defaultClip) + private val _isFullscreen = MutableStateFlow(defaultIsFullscreen) + private val _isMiniScreen = MutableStateFlow(defaultIsMiniScreen) + private val _isSidePanelDisplayed = MutableStateFlow(defaultIsSidePanelDisplayed) + private val _sidePanelSelectedTabIndex = MutableStateFlow(defaultSidePanelDisplayedTab) + private var _format = MutableStateFlow(defaultFormat) + private val _isAlwaysOnTop = MutableStateFlow(defaultIsAlwaysOnTop) + private val _saveFile = MutableStateFlow(defaultSaveFilePath) + private var _lastOpenDirectory = MutableStateFlow( + settings[PREF_LAST_OPEN_DIRECTORY, null] + ?.let(::Path) + ?.takeIf(Path::exists) + ) + private var _lastSaveDirectory = MutableStateFlow( + settings[PREF_LAST_SAVE_DIRECTORY, null] + ?.let(::Path) + ?.takeIf(Path::exists) + ) + private var lastAspectRatio = _aspectRatio.value + private var lastNonDefaultSpeed = defaultFastSpeed + private val _coverOptions = MutableStateFlow(defaultCoverOptions) + private val _introOptions = MutableStateFlow(defaultIntroOptions) + private val _coverBitmap = MutableStateFlow(null) + private val _introBitmap = MutableStateFlow(null) + private var wasSidePanelDisplayedBeforeChangingScreen = _isSidePanelDisplayed.value + private var wasMiniScreen = _isMiniScreen.value + /** + * See https://stackoverflow.com/q/10123735 + * + * Another way which did not take into account the taskbar size: + * ```kotlin + * val screenSize = Toolkit.getDefaultToolkit().screenSize + * ``` + */ + private val screenSize = GraphicsEnvironment + .getLocalGraphicsEnvironment() + .runCatching { maximumWindowBounds } // Necessary to avoid HeadlessException in @Previews + .onFailure { logger.error(it) { "Could not get screen size. Maybe the environment is headless..." } } + .getOrElse { + logger.error { "Returning 800x600 as screen size" } + Rectangle(800, 600) + } + private val windowUserDraggedPosition: MutableStateFlow = MutableStateFlow( + WindowPosition( + x = ((screenSize.width - WINDOW_WIDTH_WITH_PANEL) / 2).dp, + y = ((screenSize.height - WINDOW_HEIGHT_REGULAR) / 2).dp + ) + ) + private val _conversion = MutableStateFlow(ConversionStatus.None) + private var _clipStartMinuteInput = MutableStateFlow(TextFieldValue(text = defaultTimeStampString)) + private var _clipStartSecondInput = MutableStateFlow(TextFieldValue(text = defaultTimeStampString)) + private var _clipEndMinuteInput = MutableStateFlow(TextFieldValue(text = defaultTimeStampString)) + private var _clipEndSecondInput = MutableStateFlow(TextFieldValue(text = defaultTimeStampString)) + // NOTE: Do not use .flowOn(dispatcher); It degrades UI performance + val displayImage = _source + .transform { + emit(null) + // To give the transition animation of Display widget **at least** this much time + delay(500.milliseconds) + emit(it) + } + .combine(mediaPlayer.video) { source, video -> + if (source == null) { + null + } else if (source.mediaType == Source.MediaType.VIDEO) { + video + } else if (source.mimeType == "image/gif") { + video + } else { + generateStaticDisplayImage(source) + } + } + val language = _language.asStateFlow() + val calendar = _calendar.asStateFlow() + val theme = _theme.asStateFlow() + val aspectRatio = _aspectRatio.asStateFlow() + val clipStartMinuteInput = combine(_clipStartMinuteInput, _language) { input, language -> + input.copy(text = language.localizeDigits(input.text)) + }.stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = TextFieldValue(text = language.value.localizeDigits(defaultTimeStampString)) + ) + val clipStartSecondInput = combine(_clipStartSecondInput, _language) { input, language -> + input.copy(text = language.localizeDigits(input.text)) + }.stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = TextFieldValue(text = language.value.localizeDigits(defaultTimeStampString)) + ) + val clipEndMinuteInput = combine(_clipEndMinuteInput, _language) { input, language -> + input.copy(text = language.localizeDigits(input.text)) + }.stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = TextFieldValue(text = language.value.localizeDigits(defaultTimeStampString)) + ) + val clipEndSecondInput = combine(_clipEndSecondInput, _language) { input, language -> + input.copy(text = language.localizeDigits(input.text)) + }.stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = TextFieldValue(text = language.value.localizeDigits(defaultTimeStampString)) + ) + val source = _source.asStateFlow() + val isScreenshotInputEnabled = _source + .map { it.mediaType == Source.MediaType.VIDEO } + .stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = _source.value.mediaType == Source.MediaType.VIDEO + ) + val isQualityInputApplicable = _format + .map { it != Format.RAW } + .stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = _format.value != Format.RAW + ) + // NOTE: The following two properties should be reacted upon only in a single place for displaying the dialogs. + // See MainWindow file -> Dialogs function for more information. + val isAppExitConfirmDialogDisplayed = _isAppExitConfirmDialogDisplayed.asStateFlow() + val isChangelogDialogDisplayed = _isChangelogDialogDisplayed.asStateFlow() + val isScreenshotSoundEnabled = _isScreenshotSoundEnabled.asStateFlow() + val isFinishSoundEnabled = _isFinishSoundEnabled.asStateFlow() + val isInterlacedFixEnabled = _isInterlacedFixEnabled.asStateFlow() + val quality = _quality.asStateFlow() + val clip = _clip.asStateFlow() + val format = _format.asStateFlow() + val isFullscreen = _isFullscreen.asStateFlow() + val isMiniScreen = _isMiniScreen.asStateFlow() + val isSidePanelDisplayed = _isSidePanelDisplayed.asStateFlow() + val saveFile = _saveFile.asStateFlow() + val isAlwaysOnTop = _isAlwaysOnTop.asStateFlow() + val sidePanelSelectedTabIndex = _sidePanelSelectedTabIndex.asStateFlow() + val lastOpenDirectory = _lastOpenDirectory.asStateFlow() + val lastSaveDirectory = _lastSaveDirectory.asStateFlow() + val coverOptions = _coverOptions.asStateFlow() + val introOptions = _introOptions.asStateFlow() + val coverBitmap = _coverBitmap.asStateFlow() + val introBitmap = _introBitmap.asStateFlow() + val mediaInfo = _mediaInfo.asStateFlow() + val isLoopToggleable = _isLoopToggleable.asStateFlow() + + /* + * For adaptive sizing of window (wrap content or fit content) + * see https://github.com/JetBrains/compose-multiplatform/issues/986 + */ + val windowPosition = combine(_isSidePanelDisplayed, _isMiniScreen) { _, isMiniScreen -> + if (isMiniScreen && !wasMiniScreen) { + wasMiniScreen = true + WindowPosition( + x = (screenSize.width - WINDOW_WIDTH_MINI).dp, + y = (screenSize.height - WINDOW_HEIGHT_MINI).dp + ) + } else if (!isMiniScreen && wasMiniScreen) { + wasMiniScreen = false + val appWidth = if (wasSidePanelDisplayedBeforeChangingScreen) { + WINDOW_WIDTH_WITH_PANEL + } else { + WINDOW_WIDTH_NO_PANEL + } + WindowPosition( + x = ((screenSize.width - appWidth) / 2).dp, + y = ((screenSize.height - WINDOW_HEIGHT_REGULAR) / 2).dp + ).also { windowUserDraggedPosition.value = it } + } else { + windowUserDraggedPosition.value + } + }.stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = windowUserDraggedPosition.value + ) + val windowWidth = combine(_isSidePanelDisplayed, _isMiniScreen) { isSidePanelDisplayed, isMiniScreen -> + if (isMiniScreen) { + WINDOW_WIDTH_MINI + } else if (isSidePanelDisplayed) { + WINDOW_WIDTH_WITH_PANEL + } else { + WINDOW_WIDTH_NO_PANEL + } + }.stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = WINDOW_WIDTH_WITH_PANEL + ) + val windowHeight = _isMiniScreen + .map { if (it) WINDOW_HEIGHT_MINI else WINDOW_HEIGHT_REGULAR } + .stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = WINDOW_HEIGHT_REGULAR + ) + + val status = combine( + _source, + _mediaInfo, + _clip, + _saveFile, + _conversion + ) { source, mediaInfo, clip, saveFile, conversion -> + if (conversion is ConversionStatus.Initializing) { + Status.Initializing + } else if (conversion is ConversionStatus.InProgress) { + Status.InProgress(clipSource.value, conversion.progress) + } else if (conversion is ConversionStatus.Failure) { + Status.Finished.Failure(conversion.throwable) + } else if (conversion is ConversionStatus.Success) { + Status.Finished.Success(conversion.totalTime) + } else if (source.mediaType == Source.MediaType.IMAGE) { + Status.Error.ClipFromImageNotSupported + } else if (source.mediaType !in setOf(Source.MediaType.VIDEO, Source.MediaType.AUDIO)) { + Status.Error.ClipFromFormatNotSupported + } else if (clip.start == ZERO && clip.end == ZERO) { + Status.Error.ClipNotSet + } else if (clip.start >= mediaInfo.progress.length && mediaInfo.progress.length > ZERO) { + Status.Error.ClipStartAfterMediaEnd + } else if (clip.duration == ZERO) { + Status.Error.ClipLengthZero + } else if (clip.duration < ZERO) { + Status.Error.ClipLengthNegative + } else if (saveFile == null) { + Status.Error.FileNotSet + } else { + Status.Ready + } + }.stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = Status.Error.ClipNotSet + ) + + init { + _lastSaveDirectory + .filterNotNull() + .conflate() + .onEach { settings.put(PREF_LAST_SAVE_DIRECTORY, it.absolutePathString()) } + .launchIn(coroutineScope) + _lastOpenDirectory + .filterNotNull() + .conflate() + .onEach { settings.put(PREF_LAST_OPEN_DIRECTORY, it.absolutePathString()) } + .launchIn(coroutineScope) + } + + fun startUrlMaker() { + _source + .map(urlMaker::makeUrl) + .distinctUntilChanged() + .onEach(mediaPlayer::play) + .onEach { mediaPlayer.resume() } + .onEach { url -> _mediaInfo.update { it.copy(url = url, isResumed = true) } } + .launchIn(coroutineScope) + // Sets the initial seek to default seek + coroutineScope.launch { + // The delay is required to ensure the url has been started playing + delay(100.milliseconds) + mediaPlayer.seek(defaultSeek) + } + } + + fun startMediaProgressListener() { + mediaPlayer + .progress + .onEach { p -> _mediaInfo.update { it.copy(progress = p) } } + .onEach { + _clip.update { + val input = generateNewTimestamp(_clipEndMinuteInput.value.text, _clipEndSecondInput.value.text) + val end = input?.coerceAtMost(_mediaInfo.value.progress.length) ?: it.end + it.copy(end = end) + } + } + .launchIn(coroutineScope) + } + + fun startDateTimeChecker() { + dateTimeChecker + .dateTimeFlow() + // .onEach { (date, _) -> currentDate.value = date } + // .onEach { (_, time) -> currentTime.value = time } + .launchIn(coroutineScope) + } + + fun setLanguage(language: Language) { + _language.value = language + settings.put(PREF_LANGUAGE, language.tag) + } + + fun onClipStartMinuteChanged(string: String, selection: TextRange) { + _clipStartMinuteInput.value = handleInputForTimeMinute(_clipStartMinuteInput.value.text, string, selection) + val newValue = generateNewTimestamp(_clipStartMinuteInput.value.text, _clipStartSecondInput.value.text) + setClipStart(newValue) + } + + fun onClipStartSecondChanged(string: String, selection: TextRange) { + _clipStartSecondInput.value = handleInputForTimeSecond(_clipStartSecondInput.value.text, string, selection) + val newValue = generateNewTimestamp(_clipStartMinuteInput.value.text, _clipStartSecondInput.value.text) + setClipStart(newValue) + } + + fun onClipEndMinuteChanged(string: String, selection: TextRange) { + _clipEndMinuteInput.value = handleInputForTimeMinute(_clipEndMinuteInput.value.text, string, selection) + val newValue = generateNewTimestamp(_clipEndMinuteInput.value.text, _clipEndSecondInput.value.text) + setClipEnd(newValue) + } + + fun onClipEndSecondChanged(string: String, selection: TextRange) { + _clipEndSecondInput.value = handleInputForTimeSecond(_clipEndSecondInput.value.text, string, selection) + val newValue = generateNewTimestamp(_clipEndMinuteInput.value.text, _clipEndSecondInput.value.text) + setClipEnd(newValue) + } + + fun onSetClipStartToNow() = setClipStartTo(_mediaInfo.value.progress.time) + + fun onSetClipEndToNow() = setClipEndTo(_mediaInfo.value.progress.time) + + private fun setClipStartTo(time: Duration) { + val (minute, second) = defaultDurationConverter + .format(duration = time, numberOfParts = 2) + .split(':') + _clipStartMinuteInput.value = TextFieldValue(text = minute) + _clipStartSecondInput.value = TextFieldValue(text = second) + setClipStart(time) + } + + private fun setClipEndTo(time: Duration) { + val (minute, second) = defaultDurationConverter + .format(duration = time, numberOfParts = 2) + .split(':') + _clipEndMinuteInput.value = TextFieldValue(text = minute) + _clipEndSecondInput.value = TextFieldValue(text = second) + setClipEnd(time) + } + + private fun generateNewTimestamp(minute: String, second: String): Duration? { + val minuteString = minute.takeIf(String::isNotEmpty) ?: defaultTimeStampString + val secondString = second.takeIf(String::isNotEmpty) ?: defaultTimeStampString + return defaultDurationConverter.parse("$minuteString:$secondString") + } + + fun toggleIsAlwaysOnTop() { + _isAlwaysOnTop.value = !_isAlwaysOnTop.value + } + + fun onKeyboardEvent(event: KeyEvent) { + if (event.type == KeyEventType.KeyDown) { + when (event.key) { + in Shortcut.SEEK_SHORT_BACKWARD.keys -> + setSeek(((_mediaInfo.value.progress.time - 5.seconds) / _mediaInfo.value.progress.length).toFloat()) + in Shortcut.SEEK_SHORT_FORWARD.keys -> + setSeek(((_mediaInfo.value.progress.time + 5.seconds) / _mediaInfo.value.progress.length).toFloat()) + in Shortcut.SEEK_LONG_BACKWARD.keys -> + setSeek(((_mediaInfo.value.progress.time - 30.seconds) / _mediaInfo.value.progress.length).toFloat()) + in Shortcut.SEEK_LONG_FORWARD.keys -> + setSeek(((_mediaInfo.value.progress.time + 30.seconds) / _mediaInfo.value.progress.length).toFloat()) + in Shortcut.AUDIO_DECREASE.keys -> setVolume((_mediaInfo.value.audioVolume - 0.1f).coerceAtLeast(0f)) + in Shortcut.AUDIO_INCREASE.keys -> setVolume((_mediaInfo.value.audioVolume + 0.1f).coerceAtMost(1f)) + in Shortcut.PLAY_PAUSE.keys -> toggleResume() + in Shortcut.FULLSCREEN_EXIT.keys -> exitFullscreen() + in Shortcut.SPEED_DECREASE.keys -> setSpeed(_mediaInfo.value.speed.dec()) + in Shortcut.SPEED_INCREASE.keys -> setSpeed(_mediaInfo.value.speed.inc()) + in Shortcut.SPEED_RESET.keys -> resetSpeed() + in Shortcut.CLIP_START_NOW.keys -> onSetClipStartToNow() + in Shortcut.CLIP_END_NOW.keys -> onSetClipEndToNow() + in Shortcut.CLIP_START_BEGINNING.keys -> setClipStartTo(ZERO) + in Shortcut.CLIP_END_FINISH.keys -> setClipEndTo(_mediaInfo.value.progress.length) + in Shortcut.SIDE_PANEL_TOGGLE.keys -> toggleSidePanel() + in Shortcut.MINI_MODE_TOGGLE.keys -> toggleMiniScreen() + in Shortcut.FULLSCREEN_TOGGLE.keys -> if (_isFullscreen.value) exitFullscreen() else enterFullscreen() + in Shortcut.CLIP_LOOP_TOGGLE.keys -> if (_isLoopToggleable.value) toggleClipLoop() + in Shortcut.PIN_TOGGLE.keys -> toggleIsAlwaysOnTop() + in Shortcut.AUDIO_MUTE_TOGGLE.keys -> toggleAudioMute() + in Shortcut.SCREENSHOT_TAKE.keys -> takeScreenshot() + } + } + } + + fun setSaveFile(file: Path) { + val extension = _format.value.extension(_source.value) + if (file.name.endsWith(".$extension", ignoreCase = true)) { + _saveFile.value = file.parent / "${file.nameWithoutExtension}.$extension" + } else { + _saveFile.value = file.parent / "${file.name}.$extension" + } + _lastSaveDirectory.value = file.parent + } + + fun setQuality(newQuality: Float) { + _quality.value = newQuality.toQuality() + } + + fun setFormat(newFormat: Format) { + _format.value = newFormat + _saveFile.update { + it + ?.toString() + ?.substringBeforeLast('.') + ?.plus(".${_format.value.extension(_source.value)}") + ?.let(::Path) + } + } + + fun setIntroFile(path: Path?) { + coroutineScope.launch { + _lastOpenDirectory.update { path?.parent ?: it } + val newPath = convertImageToSupportedFormatIfNeeded(path) + _introOptions.update { it.copy(path = newPath) } + _introBitmap.value = getImageBitmap(path, INTRO_PREVIEW_SIZE) { + logger.warn { "Could not create intro preview for $path" } + } + } + } + + fun setCoverFile(path: Path?) { + coroutineScope.launch { + _lastOpenDirectory.update { path?.parent ?: it } + val newPath = convertImageToSupportedFormatIfNeeded(path) + _coverOptions.update { it.copy(path = newPath) } + _coverBitmap.value = getImageBitmap(path, COVER_PREVIEW_SIZE) { + logger.warn { "Could not create watermark/album art preview for $path" } + } + } + } + + private fun convertImageToSupportedFormatIfNeeded(path: Path?): Path? { + val mimeType = path?.detectMimeType() + return if (mimeType == "image/svg+xml") { + // FFmpeg does not support SVG format, so here a temp PNG file with the default size + // of the SVG (its default width and height) is created + path.convertSvgToPng() + } else if (mimeType != null && mimeType.startsWith("image/")) { + path + } else { + null + } + } + + private fun getImageBitmap( + path: Path?, + desiredSizeIfVector: Float, + onFailure: () -> Unit + ) = path + ?.let { decodeImage(path = it, desiredSizeIfVector = desiredSizeIfVector) } + ?: run { + if (path != null) onFailure() + null + } + + fun setIntroBackgroundColor(color: Color) { + _introOptions.update { it.copy(backgroundColor = color) } + } + + fun setIntroDuration(duration: Duration) { + _introOptions.update { it.copy(duration = duration) } + } + + fun setWaterMarkScale(scale: Float) { + _coverOptions.update { it.copy(scale = scale) } + } + + fun setWaterMarkOpacity(opacity: Float) { + _coverOptions.update { it.copy(opacity = opacity) } + } + + fun setWatermarkPosition(position: WatermarkPosition) { + _coverOptions.update { it.copy(position = position) } + } + + private fun setClipStart(value: Duration?) { + _clip.update { it.copy(start = value ?: defaultTimeStamp) } + _isLoopToggleable.value = _clip.value.duration > ZERO + _mediaInfo.update { it.copy(clipToLoop = null) } + mediaPlayer.setClipToLoop(null) + logger.info { "Clip start was set to ${_clip.value.start}" } + } + + private fun setClipEnd(value: Duration?) { + _clip.update { it.copy(end = value?.coerceAtMost(_mediaInfo.value.progress.length) ?: defaultTimeStamp) } + _isLoopToggleable.value = _clip.value.duration > ZERO + _mediaInfo.update { it.copy(clipToLoop = null) } + mediaPlayer.setClipToLoop(null) + logger.info { "Clip end was set to ${_clip.value.end}" } + } + + fun startProcess() { + conversionJob = coroutineScope.launch { + val conversionStartTime = SystemDateTime.nowMillis() + clipSource.value = _source.value + logger.info { "Started creating ${clip.value} from ${_source.value}..." } + _conversion.value = ConversionStatus.Initializing + val converter = converterFactory.createFor(_format.value) + _conversion.value = ConversionStatus.InProgress(progress = 0f) + converter.runCatching { + convert( + input = urlMaker.makeUrl(_source.value), + clip = _clip.value, + intro = _introOptions.value, + cover = _coverOptions.value, + quality = _quality.value, + flags = ConverterFlags( + isInterlacingFixEnabled = _isInterlacedFixEnabled.value, + isVideoAvailableInInput = _source.value.mediaType == Source.MediaType.VIDEO + ), + output = _saveFile.value!!, + listener = { _conversion.value = ConversionStatus.InProgress(progress = it) } + ) + (SystemDateTime.nowMillis() - conversionStartTime).milliseconds + } + .onSuccess(::onConversionSuccess) + .onFailure(::onConversionException) + } + } + + private fun onConversionSuccess(conversionDuration: Duration) { + logger.info { "Conversion finished successfully in $conversionDuration" } + _conversion.value = ConversionStatus.Success(totalTime = conversionDuration) + resetState() + } + + /** + * Including CancellationException (conversion job cancel) + */ + private fun onConversionException(throwable: Throwable) { + if (throwable is CancellationException) { + logger.info { "Conversion canceled successfully" } + _conversion.value = ConversionStatus.None + } else { + logger.error(throwable) { "Conversion failed" } + _conversion.value = ConversionStatus.Failure(throwable = throwable) + } + resetState() + } + + fun cancelProcess() { + logger.info { "Cancelling the conversion..." } + conversionJob?.cancel() + } + + private fun resetState() { + _saveFile.value = null + } + + fun cancelEverything() { + // Cancels everything launched in this scope + // (including the conversion job) + coroutineScope.cancel() + mediaPlayer.terminate() + } + + fun toggleSidePanel() { + if (!_isFullscreen.value && !_isMiniScreen.value) { + _isSidePanelDisplayed.value = !_isSidePanelDisplayed.value + } + } + + fun enterFullscreen() { + wasSidePanelDisplayedBeforeChangingScreen = _isSidePanelDisplayed.value + _isSidePanelDisplayed.value = false + _isFullscreen.value = true + } + + fun exitFullscreen() { + _isFullscreen.value = false + _isSidePanelDisplayed.value = wasSidePanelDisplayedBeforeChangingScreen + } + + fun toggleMiniScreen() { + if (_isFullscreen.value) { + return + } + if (!_isMiniScreen.value) { + wasSidePanelDisplayedBeforeChangingScreen = _isSidePanelDisplayed.value + _isSidePanelDisplayed.value = false + } + _isMiniScreen.update { !it } + if (!_isMiniScreen.value) { + _isSidePanelDisplayed.value = wasSidePanelDisplayedBeforeChangingScreen + } + } + + fun takeScreenshot() { + // Does it asynchronously because creating directories takes a little time + coroutineScope.launch { + // Makes sure the directories exist. + // Calling this method where the variable is defined is not enough + // because, although it creates the directories on app start, + // user may have deleted the directories while the app is running + defaultScreenshotSaveDirectory.createDirectories() + val wasSuccessful = mediaPlayer.takeScreenshot(defaultScreenshotSaveDirectory.toFile()) + logger.info { "Saving screenshot ${if (wasSuccessful) "succeeded" else "failed"}" } + } + coroutineScope.launch { + if (_isScreenshotSoundEnabled.value) { + val soundPath = assetsPath / "shutter.wav" + val audioStream = AudioSystem.getAudioInputStream(soundPath.toFile()) + val audioClip = AudioSystem.getClip() + audioClip.open(audioStream) + audioClip.start() + } + } + } + + fun toggleResume() { + mediaPlayer.toggleResume() + _mediaInfo.update { it.copy(isResumed = !it.isResumed) } + } + + fun setSeek(seek: Float) { + val fraction = if (_mediaInfo.value.clipToLoop != null) { + val startFraction = (_clip.value.start / _mediaInfo.value.progress.length).toFloat() + val endFraction = (_clip.value.end / _mediaInfo.value.progress.length).toFloat() + seek.coerceIn(startFraction, endFraction) + } else { + seek + } + mediaPlayer.seek(fraction) + } + + fun setVolume(volume: Float) { + mediaPlayer.setAudioVolume(volume.coerceIn(0f..1f)) + _mediaInfo.update { it.copy(audioVolume = volume.coerceIn(0f..1f)) } + } + + fun setSpeed(speed: Speed) { + mediaPlayer.setSpeed(speed.value) + _mediaInfo.update { it.copy(speed = speed) } + lastNonDefaultSpeed = speed.takeIf { it != defaultSpeed } ?: lastNonDefaultSpeed + } + + fun resetSpeed() { + if (_mediaInfo.value.speed != defaultSpeed) { + _mediaInfo.update { it.copy(speed = defaultSpeed) } + } else { + _mediaInfo.update { it.copy(speed = lastNonDefaultSpeed) } + } + mediaPlayer.setSpeed(_mediaInfo.value.speed.value) + } + + fun setSidePanelSelectedTabIndex(tabIndex: Int) { + _sidePanelSelectedTabIndex.value = tabIndex + } + + fun generateSaveFileDefaultName(path: Path): String { + return saveFileNameGenerator.generate(path, LocalDate.now(), LocalTime.now()) + } + + fun toggleAudioMute() { + _mediaInfo.update { it.copy(isAudioMuted = !it.isAudioMuted) } + if (_mediaInfo.value.isAudioMuted) mediaPlayer.mute() else mediaPlayer.unMute() + } + + fun toggleClipLoop() { + val fraction = (_clip.value.start / _mediaInfo.value.progress.length).toFloat() + if (_mediaInfo.value.clipToLoop == null) mediaPlayer.seek(fraction) + _mediaInfo.update { it.copy(clipToLoop = if (it.clipToLoop == null) _clip.value else null) } + mediaPlayer.setClipToLoop(_mediaInfo.value.clipToLoop) + } + + fun onWindowPositionChanged(position: WindowPosition) { + windowUserDraggedPosition.value = position + } + + fun showChangelogDialog() { + _isChangelogDialogDisplayed.value = true + } + + fun onFinishDialogDismissRequest() { + _conversion.value = ConversionStatus.None + } + + fun onChangelogDialogDismissRequest() { + settings.put(PREF_LAST_SHOWN_CHANGELOG_VERSION, BuildConfig.APP_VERSION) + _isChangelogDialogDisplayed.value = false + } + + fun onAppExitConfirmDialogDismissRequest() { + _isAppExitConfirmDialogDisplayed.value = false + } + + fun setAspectRatio(aspectRatio: AspectRatio) { + lastAspectRatio = aspectRatio + _aspectRatio.value = aspectRatio + settings.put(PREF_ASPECT_RATIO, aspectRatio.name) + } + + fun setCalendar(calendar: Calendar) { + _calendar.value = calendar + settings.put(PREF_CALENDAR, calendar.name) + } + + fun setTheme(theme: Theme) { + _theme.value = theme + settings.put(PREF_THEME, theme.name) + } + + fun setIsFinishSoundEnabled(isEnabled: Boolean) { + _isFinishSoundEnabled.value = isEnabled + settings.put(PREF_FINISH_SOUND, isEnabled.toString()) + } + + fun setIsScreenshotSoundEnabled(isEnabled: Boolean) { + _isScreenshotSoundEnabled.value = isEnabled + settings.put(PREF_SCREENSHOT_SOUND, isEnabled.toString()) + } + + fun setIsInterlacedFixEnabled(isEnabled: Boolean) { + _isInterlacedFixEnabled.value = isEnabled + settings.put(PREF_INTERLACED_FIX, isEnabled.toString()) + } + + fun setSourceToLocal(path: Path) { + _source.value = Source.Local(path) + _lastOpenDirectory.value = path.parent + onSourceChanged() + } + + private fun onSourceChanged() { + if (_source.value.mediaType == Source.MediaType.VIDEO) { + _aspectRatio.value = lastAspectRatio + } else { + _aspectRatio.value = AspectRatio.SOURCE + } + _saveFile.update { + it + ?.toString() + ?.substringBeforeLast('.') + ?.plus(".${_format.value.extension(_source.value)}") + ?.let(::Path) + } + } + + fun onAppExitRequest(forceExit: Boolean, exit: () -> Unit) { + val isIdle = + _conversion.value !is ConversionStatus.Initializing && + _conversion.value !is ConversionStatus.InProgress + if (isIdle || forceExit) { + exit() + } else { + _isAppExitConfirmDialogDisplayed.value = true + } + } + + private fun generateStaticDisplayImage(source: Source): ImageBitmap? { + val path = (source as? Source.Local)?.path ?: Path("") + return path + .toFile() + .runCatching(AudioFileIO::read) + .getOrNull() + ?.tag + ?.firstArtwork + ?.binaryData + ?.inputStream() + ?.use(::loadImageBitmap) + ?: run { + when (source.mediaType) { + Source.MediaType.IMAGE -> decodeImage( + path = path, + mimeType = source.mimeType, + desiredSizeIfVector = 512f + ) + Source.MediaType.AUDIO -> defaultAudioImage + else -> null + } + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/Utilities.kt b/src/main/kotlin/ir/mahozad/cutcon/Utilities.kt new file mode 100644 index 0000000..8a1a3f2 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/Utilities.kt @@ -0,0 +1,397 @@ +package ir.mahozad.cutcon + +import androidx.compose.material.Colors +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toAwtImage +import androidx.compose.ui.res.loadImageBitmap +import androidx.compose.ui.res.loadSvgPainter +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import com.github.mfathi91.time.PersianDate +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import ir.mahozad.cutcon.model.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.transformLatest +import org.apache.tika.Tika +import java.awt.Desktop +import java.awt.image.BufferedImage +import java.io.InputStream +import java.nio.file.Path +import java.time.LocalDate +import java.util.* +import javax.imageio.ImageIO +import kotlin.io.path.* +import kotlin.math.roundToInt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStdlibApi::class) +private val hexFormat = HexFormat { + upperCase = false + number.prefix = "" + number.removeLeadingZeros = false +} +private val digitRegex = Regex("[۰-۹0-9٠-٩]") // Farsi | Latin | Arabic +private val numberRegex = Regex("${digitRegex.pattern}+") +private val ipRegex = Regex("""(${digitRegex.pattern}{1,3}\.){3}${digitRegex.pattern}{1,3}""") +private val dateDelimiterRegex = Regex("""[-و \t_.,،:;؛|/\\]""") +private val redundantDelimiterRegex = Regex("${dateDelimiterRegex.pattern}+") +private val tika = Tika() +private val logger = logger(name = "Utilities") + +fun parseMarkdownAsChangelog( + inputStream: InputStream, + languageTag: String +): Changelog = inputStream + .use { it.reader().readLines() } + .dropWhile { !it.startsWith("## ") } + .filterNot(String::isBlank) + .fold(Changelog(versions = listOf())) { changelog, line -> + if (line.startsWith("## ")) { + val (name, date) = line + .substringAfter("## v") + .replace(Regex("[()]"), "") + .split(' ') + changelog + ChangelogVersion(name = name, date = LocalDate.parse(date), categories = listOf()) + } else if (line.startsWith("#### ")) { + val type = line.substringAfter("#### ").lowercase().let { + if ("feature" in it) { + CategoryType.FEATURE + } else if ("bug" in it) { + CategoryType.BUGFIX + } else if ("update" in it || "improve" in it) { + CategoryType.UPDATE + } else if (Regex("(drop)|(clean)|(remov)|(delet)|(deprecat)") in it) { + CategoryType.REMOVAL + } else { + CategoryType.INTERNAL + } + } + val newVersion = changelog.versions.last() + ChangelogCategory(type = type, entries = listOf()) + changelog.replaceLast(newVersion) + } else { + val tag = line.substringAfter('(').substringBefore(')') + if (tag.lowercase() !in setOf("@", languageTag.lowercase())) return@fold changelog + val newItem = line + .replaceFirst("($tag) ", "") + .replace(Regex("""^\s*[-+*]?\s*"""), "") // Bullets + .replace(Regex(""" [(](\[|various).+[)]"""), "") // Commits + val newEntries = if (line matches Regex("""^\s*-.+""")) { // If starts with - + changelog.versions.last().categories.last().entries + ChangelogEntry(listOf(newItem)) + } else { + val newEntry = changelog.versions.last().categories.last().entries.last() + newItem + changelog.versions.last().categories.last().entries.dropLast(1) + newEntry + } + val newCategory = changelog.versions.last().categories.last().copy(entries = newEntries) + val newCategories = changelog.versions.last().categories.dropLast(1) + newCategory + changelog.replaceLast(changelog.versions.last().copy(categories = newCategories)) + } + }.also { + logger.info { "Parsed changelog" } + } + +fun Path.convertSvgToPng(desiredPngSize: Float? = null): Path? { + return runCatching { + val imageData = decodeImage( + path = this, + mimeType = "image/svg+xml", + desiredSizeIfVector = desiredPngSize + )?.toAwtImage() + val imagePath = createTempFile(suffix = ".png") + imagePath.outputStream().use { ImageIO.write(imageData, "PNG", it) } + imagePath + } + .onSuccess { logger.info { "Converted $this to PNG in $it" } } + .onFailure { logger.warn(it) { "Could not convert $this to PNG" } } + .getOrNull() +} + +/** + * If changed the parameter from [Path] to [InputStream], pay attention to the following notes. + * + * The stream should be read twice by [Tika] and the load*bitmap() methods. + * But, a stream cannot be read multiple times. Also, input stream does not support mark and reset. + * So, we should read all the stream bytes into memory (like `val bytes = stream.readBytes()`) but, + * unlike what is said in https://stackoverflow.com/q/9501237, it may not be a bad idea because, + * at the end, we want a decoded image and a decoded image has all its bytes in memory. + */ +fun decodeImage( + path: Path, + mimeType: String? = null, + desiredSizeIfVector: Float? = null +): ImageBitmap? { + val mimeTypeDetected = mimeType ?: path.detectMimeType() + return if (mimeTypeDetected == "image/svg+xml") { + // Catches exception thrown from stream or if it is .svgz as it is NOT supported by skia (yet) + runCatching { path.inputStream().use { loadSvgAsBitmap(it, desiredSizeIfVector) } }.getOrNull() + } else if (mimeTypeDetected?.startsWith("image/") == true) { + // Catches exceptions thrown from stream or if image format is unsupported + runCatching { path.inputStream().use { loadImageBitmap(it) } }.getOrNull() + } else { + null + }.also { + logger.info { "Decoded image $path" } + } +} + +/** + * Passing null to [desiredSize] makes size default to the intrinsic dimensions of the SVG. + * + * See https://stackoverflow.com/q/13605248 + * and https://stackoverflow.com/q/4251383 + */ +private fun loadSvgAsBitmap( + stream: InputStream, + desiredSize: Float? = null +): ImageBitmap { + val density = Density(1f) + val image = loadSvgPainter(stream, density).run { + val aspectRatio = intrinsicSize.width / intrinsicSize.height + val size = if (desiredSize == null) { + intrinsicSize + } else if (aspectRatio > 1f) { + Size(desiredSize, desiredSize / aspectRatio) + } else { + Size(desiredSize * aspectRatio, desiredSize) + } + toAwtImage(density, LayoutDirection.Ltr, size) + } + val bufferedImage = BufferedImage( + image.getWidth(null), + image.getHeight(null), + BufferedImage.TYPE_INT_ARGB + ) + bufferedImage.createGraphics().apply { + drawImage(image, 0, 0, null) + dispose() + } + val file = createTempFile("temp.png") + ImageIO.write(bufferedImage, "PNG", file.toFile()) + return file.inputStream().use(::loadImageBitmap) +} + +/** + * NOTE: Using Dispatchers.Main Requires the dependency + * org.jetbrains.kotlinx:kotlinx-coroutines-swing + * See https://github.com/JetBrains/compose-multiplatform/blob/master/CHANGELOG.md#111-mar-2022 + */ +suspend fun onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) + +val TextRange.Companion.One get() = TextRange(1) +val TextRange.Companion.Two get() = TextRange(2) +val TextRange.Companion.Three get() = TextRange(3) + +@OptIn(ExperimentalStdlibApi::class) +fun Color.toHex() = "#${toArgb().toHexString(hexFormat).takeLast(6)}" + +@Suppress("UnusedReceiverParameter") +val Colors.success: Color get() = Color(red = 88, green = 150, blue = 0) + +fun Int.toTwoDigit() = "%02d".format(this) + +fun String.isValidIp() = matches(ipRegex) + +fun String.normalizeDigits() = replace(numberRegex) { it.value.toLong().toString() } + +fun String.substringBetween(l: String, r: String) = substringAfter(l).substringBefore(r) + +fun String.toDuration(): Duration? { + if (isEmpty()) return null + val (s, m, h) = split(":").map(String::toInt).reversed() + 0 // Ensure contains hour + return h.hours + m.minutes + s.seconds +} + +fun Float.toQuality() = Quality + .entries + .single { it.value == roundToInt().coerceIn(1..5) } + .also { logger.info { "Converted $this to quality $it" } } + +fun calculateMaxSizeInFrame( + frameWidth: Dp, + frameHeight: Dp, + desiredAspectRatio: Float +): DpSize { + val frameAspectRatio = (frameWidth / frameHeight) + val (width, height) = if (frameAspectRatio > desiredAspectRatio) { + frameHeight * desiredAspectRatio to frameHeight + } else { + frameWidth to frameWidth / desiredAspectRatio + } + return DpSize(width, height) +} + +fun CoroutineScope.openAppLogFolder() { + launch(Dispatchers.IO) { + runCatching { System.getenv("AppData") } + .onFailure { logger.warn(it) { "Could not get system AppData path" } } + .getOrNull() + ?.let(::Path) + ?.resolve(BuildConfig.APP_NAME) + ?.let(Path::toFile) + ?.let(Desktop.getDesktop()::open) + ?.also { logger.info { "Opened App log folder" } } + } +} + +/** + * Continuously emit the latest value of the flow. + * + * See https://stackoverflow.com/q/67325125 + * and https://stackoverflow.com/q/54827455 + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun Flow.emitLatestEvery( + duration: Duration, + onChange: suspend FlowCollector.() -> Unit = {} +) = transformLatest { + onChange() + while (true) { + emit(it) + delay(duration) + } +} + +/** + * Note that Apache tika returns `application/octet-stream` + * if it does not know what the type of file is. + * For example, for .ts video files, instead of video/mp2t, it returns that. + * + * See https://stackoverflow.com/q/5507565 + */ +fun Path.detectMimeType(): String? { + if (extension == "ts") return "video/mp2t" + return runCatching(tika::detect) + .onSuccess { logger.info { "Detected mime type $it for $this" } } + .onFailure { logger.warn(it) { "Could not detect mime type of $this" } } + .getOrNull() + .takeIf { it != "application/octet-stream" } +} + +fun Path.trim(maxLength: Int): Path { + fun String.prune(): String { + return if (length <= maxLength) { + this + } else if ('/' !in this) { + "...${takeLast(maxLength - 3)}" + } else if ('/' !in removePrefix(".../")) { + removePrefix(".../").prune() + } else { + (".../${removePrefix(".../").substringAfter('/')}").prune() + } + } + return invariantSeparatorsPathString + .prune() + .let(::Path) + .also { logger.info { "Trimmed '$this' to '$it'" } } +} + +/** + * u202A (Left-To-Right Embedding) has two purposes: + * - If the path starts with ellipsis, makes the ellipsis stay on the left side in RTL layouts + * - Makes the RTL directory names (like Persian names) in the path not break the left-to-right flow of the path + */ +fun Path.toLtrString(): String { + return "\u202A$invariantSeparatorsPathString" + .split('/') + .joinToString(separator = "/\u202A") + .also { logger.info { "Converted path $this to LTR" } } +} + +fun String.parseAsDate(): LocalDate? = runCatching { + val (year, month, day) = this + .trim() + .replace(dateDelimiterRegex, "-") + .replace(redundantDelimiterRegex, "-") + .split("-") + .takeIf { it.size == 3 } + ?.map(String::toInt) + ?: return@runCatching null + if (day !in 1..31 || month !in 1..12) { + return@runCatching null + } else if (year < 1800) { + return@runCatching PersianDate.of(year, month, day).toGregorian() + } else { + return@runCatching LocalDate.of(year, month, day) + } +} + .onSuccess { logger.info { "Parsed $this as date $it" } } + .onFailure { logger.debug(it) { "Could not parse $this as date" } } + .getOrNull() + +@Suppress("LiftReturnOrAssignment") +fun handleInputForTimeSecond( + old: String, + input: String, + newCursor: TextRange +): TextFieldValue { + if (input.any { !it.isDigit() }) { + return TextFieldValue(old, TextRange.One) + } else if (input.length < 2) { + return TextFieldValue(input, TextRange(input.length)) + } else if (input.length < 3 && input.toInt() < 60) { + return TextFieldValue(input, TextRange(newCursor.end)) + } else if (input[0] == input[1] && newCursor.end == 1) { + return TextFieldValue(old, TextRange.One) + } else if ("${input.first()}${input.last()}" == old) { + return TextFieldValue(input.take(2), TextRange.Two) + } else if (input.endsWith(old) && input.first().digitToInt() < 6) { + return TextFieldValue("${input.first()}${input.last()}", TextRange.One) + } else { + return TextFieldValue(old, TextRange(newCursor.end - 1)) + } +} + +@Suppress("LiftReturnOrAssignment") +fun handleInputForTimeMinute( + old: String, + input: String, + newCursor: TextRange +): TextFieldValue { + if (input.any { !it.isDigit() }) { + return TextFieldValue(old, TextRange.One) + } else if (input.length < 3) { + return TextFieldValue(input, TextRange(newCursor.end)) + } else if (input[0] == input[1] && newCursor.end == 1) { + return TextFieldValue(old, TextRange.One) + } else if ("${input.first()}${input.last()}" == old) { + return TextFieldValue(input.take(2), TextRange.Two) + } else if (input.endsWith(old)) { + return TextFieldValue("${input.first()}${input.last()}", TextRange(newCursor.end)) + } else { + return TextFieldValue(old, TextRange.Two) + } +} + +@Suppress("LiftReturnOrAssignment") +fun compareVersionStrings( + thisVersion: String, + thatVersion: String? +): VersionComparisonResult { + if (thisVersion.trim() == thatVersion?.trim()) return VersionComparisonResult.SAME + val thisSuffix = thisVersion.substringAfter(delimiter = '-', missingDelimiterValue = "") + val thatSuffix = thatVersion?.substringAfter(delimiter = '-', missingDelimiterValue = "") ?: "" + val (thisMajor, thisMinor, thisPatch) = "$thisVersion.0.0".split(".").mapNotNull(String::toIntOrNull) + val (thatMajor, thatMinor, thatPatch) = "$thatVersion.0.0.0".split(".").mapNotNull(String::toIntOrNull) + if (thisMajor > thatMajor) { + return VersionComparisonResult.NEWER + } else if (thisMajor == thatMajor && thisMinor > thatMinor) { + return VersionComparisonResult.NEWER + } else if (thisMinor == thatMinor && thisPatch > thatPatch) { + return VersionComparisonResult.NEWER + } else if (thisSuffix > thatSuffix && thatSuffix.isNotEmpty()) { + return VersionComparisonResult.NEWER + } else { + return VersionComparisonResult.OLDER + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/component/DateTimeChecker.kt b/src/main/kotlin/ir/mahozad/cutcon/component/DateTimeChecker.kt new file mode 100644 index 0000000..596b967 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/component/DateTimeChecker.kt @@ -0,0 +1,33 @@ +package ir.mahozad.cutcon.component + +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import ir.mahozad.cutcon.defaultDateTimeCheckingPeriod +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import java.time.LocalDate +import java.time.LocalTime +import kotlin.coroutines.CoroutineContext + +private val logger = logger(name = DateTimeChecker::class.simpleName ?: "") + +fun interface DateTimeChecker { + fun dateTimeFlow(): Flow> +} + +class DefaultDateTimeChecker(private val dispatcher: CoroutineContext) : DateTimeChecker { + + override fun dateTimeFlow() = flow { + while (true) { + val date = SystemDateTime.nowDate() + val time = SystemDateTime + .nowTime() + .withSecond(0) + .withNano(0) + emit(date to time) + delay(defaultDateTimeCheckingPeriod) + } + } + .distinctUntilChanged() + .onEach { logger.debug { "Detected date or time change: $it" } } + .flowOn(dispatcher) +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/component/DurationConverter.kt b/src/main/kotlin/ir/mahozad/cutcon/component/DurationConverter.kt new file mode 100644 index 0000000..80645da --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/component/DurationConverter.kt @@ -0,0 +1,49 @@ +package ir.mahozad.cutcon.component + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +interface DurationConverter { + fun format(duration: Duration, numberOfParts: Int): String + fun parse(string: String): Duration? +} + +object DefaultDurationConverter : DurationConverter { + override fun format(duration: Duration, numberOfParts: Int): String { + val durationValue = duration.absoluteValue + if (durationValue >= 100.hours) { + return "--:".repeat(numberOfParts).dropLast(1) + } else if (durationValue < 1.seconds) { + return "00:".repeat(numberOfParts).dropLast(1) + } + val sign = if (duration.isNegative()) "-" else "" + val hours = durationValue.inWholeHours.format(false) + val minutes = durationValue.inWholeMinutes.format(numberOfParts > 2) + val seconds = durationValue.inWholeSeconds.format(numberOfParts > 1) + return when (numberOfParts) { + 3 -> "$sign$hours:$minutes:$seconds" + 2 -> "$sign$minutes:$seconds" + else -> "$sign$seconds" + } + } + + private fun Long.format(shouldCap: Boolean) = + (if (shouldCap) (this % 60) else this) + .toString().padStart(2, '0') + + /** + * See https://stackoverflow.com/q/54970799 + */ + override fun parse(string: String): Duration? { + val (s, m, h) = string + .split(":") + .runCatching { map(String::toInt) } + .getOrNull() + ?.reversed() + ?.plus(0) /* Ensures contains hour */ + ?: return null + return h.hours + m.minutes + s.seconds + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/component/MediaPlayer.kt b/src/main/kotlin/ir/mahozad/cutcon/component/MediaPlayer.kt new file mode 100644 index 0000000..b63466e --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/component/MediaPlayer.kt @@ -0,0 +1,335 @@ +package ir.mahozad.cutcon.component + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.assetsPath +import ir.mahozad.cutcon.defaultAudioVolume +import ir.mahozad.cutcon.defaultIsAudioMuted +import ir.mahozad.cutcon.model.Clip +import ir.mahozad.cutcon.model.Progress +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flow +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.ImageInfo +import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery +import uk.co.caprica.vlcj.factory.discovery.strategy.NativeDiscoveryStrategy +import uk.co.caprica.vlcj.player.embedded.videosurface.CallbackVideoSurface +import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface +import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurfaceAdapters +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat +import java.io.File +import java.net.URL +import java.nio.ByteBuffer +import javax.swing.SwingUtilities +import kotlin.io.path.absolutePathString +import kotlin.io.path.div +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds +import uk.co.caprica.vlcj.factory.MediaPlayerFactory as VlcMediaPlayerFactory +import uk.co.caprica.vlcj.player.base.MediaPlayer as VlcMediaPlayer +import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter as VlcMediaPlayerEventAdapter + +interface MediaPlayer { + val video: Flow + val progress: Flow + + fun play(url: URL) + fun seek(value: Float) + fun pause() + fun resume() + fun toggleResume() + fun mute() + fun unMute() + fun setAudioVolume(value: Float) + fun setSpeed(value: Float) + fun setClipToLoop(clip: Clip?) + fun takeScreenshot(saveDirectory: File): Boolean + fun setFinishListener(listener: () -> Unit) + fun terminate() +} + +// See https://github.com/JetBrains/compose-multiplatform/pull/3336 +// and https://github.com/caprica/vlcj/issues/1098 +class DefaultMediaPlayer : MediaPlayer { + + private val logger = logger(MediaPlayer::class.simpleName ?: "") + private val vlcPath = (assetsPath / BuildConfig.VLC_DIRECTORY_NAME).absolutePathString() + private val vlcOptions = listOf( + // Does not have any effect; just in case + "--video-title=${BuildConfig.APP_NAME} video output", + // Name of screenshot files (plus date and time) + "--snapshot-prefix=${BuildConfig.APP_NAME.lowercase()}-", + // Format of screenshot {png, jpg, tiff} + "--snapshot-format=png", + // Makes the process priority high + "--high-priority", + // Disables collection of statistics + "--no-stats", + // Shows verbose output {0 error and info, 1 warning, 2 debug} + "--verbose", "1", + // Sets user interface to none + "--intf=dummy", + // Avoids showing a dialog box when user input is required + "--no-interact", + // Allows hardware decoding when available {any, d3d11va, dxva2, none} + "--avcodec-hw=any", + // Greatly improves the startup time of VLC + "--plugins-cache", + // Drops frames instead of showing visual (gray) artifacts + "--no-avcodec-corrupted" + ) + + private var vlcMediaPlayerFactory = initializeVlcMediaPlayerFactory() + private var clipToLoop: Clip? = null + private var isResumed = true + private var finishListener: (() -> Unit)? = null + private val videoSurface = SkiaBitmapVideoSurface() + private val eventListener = EventListener() + private var vlcMediaPlayer = vlcMediaPlayerFactory + .mediaPlayers() + .newEmbeddedMediaPlayer() + .apply { videoSurface().set(videoSurface) } + .apply { events().addMediaPlayerEventListener(eventListener) } + + override val video = videoSurface.bitmap + override val progress = vlcMediaPlayer.progressFlow() + + init { + /** + * TODO: Use --no-volume-save option in the factory below + * if newer versions of VLC reset the audio muteness as well. + * VLC has a feature that starts with the last audio volume/muteness set in its previous launch + * (can explicitly control this by passing --volume-save and --no-volume-save options). + * The --no-volume-save option does not reset the muteness of audio + * (meaning, if we mute the audio and restart the app, the app will start with muted sound) + * so, instead of passing this option, here we set the initial audio volume/muteness manually. + */ + vlcMediaPlayer.audio().isMute = defaultIsAudioMuted + vlcMediaPlayer.audio().setVolume(defaultAudioVolume.toPercentage()) + } + + private fun initializeVlcMediaPlayerFactory(): VlcMediaPlayerFactory { + // See main README -> Embedding VLC DLL files section for more information + val discovery = NativeDiscovery(object : NativeDiscoveryStrategy { + override fun discover() = vlcPath + override fun supported() = true + override fun onFound(path: String) = true + override fun onSetPluginPath(path: String) = true + }) + // The default args are MediaPlayerComponentDefaults.EMBEDDED_MEDIA_PLAYER_ARGS + // Run vlc -H or vlc --help --advanced or add -H or --help and --advanced options + // separately to vlcOptions below to see all vlc configurations and capabilities + return VlcMediaPlayerFactory(discovery, vlcOptions) + } + + /** + * Polls and emits media progress every `n` milliseconds. + * Note that it seems vlcj updates the progress only every 250 milliseconds or so. + * + * Instead of polling, could also have used event listener like below, + * but it reintroduced bugs when seeking the paused media: + * ```kotlin + * DisposableEffect(listener) { + * val playerListener = object : MediaPlayerEventAdapter() { + * override fun timeChanged(mediaPlayer: MediaPlayer?, newTime: Long) { + * val fraction = mediaPlayer.status().position() + * val length = mediaPlayer.status().length().milliseconds + * onProgress(Progress(fraction, newTime.milliseconds, length)) + * } + * } + * events().addMediaPlayerEventListener(playerListener) + * onDispose { events().removeMediaPlayerEventListener(playerListener) } + * } + * ``` + * + * See https://stackoverflow.com/a/43426974 + */ + private fun VlcMediaPlayer.progressFlow() = flow { + while (true) { + val fraction = status().position() + val length = status().length().milliseconds + emit(Progress(fraction, length)) + // Higher delay is better which also fixes problem with progress bar; + // See the progress bar widget code for more information + delay(250.milliseconds) + } + } + + private fun Float.toPercentage() = (this * 100).roundToInt() + + override fun play(url: URL) { + // Sets null to clear the last frame of previous media + videoSurface.bitmap.value = null + isResumed = true + vlcMediaPlayer.media().play /* OR .start */(url.toString()) + } + + override fun pause() { + isResumed = false + vlcMediaPlayer.controls().setPause(true) + } + + override fun resume() { + isResumed = true + vlcMediaPlayer.controls().setPause(false) + } + + override fun toggleResume() { + isResumed = !isResumed + if (isResumed) resume() else pause() + } + + override fun setAudioVolume(value: Float) { + vlcMediaPlayer.audio().setVolume(value.toPercentage()) + } + + override fun setSpeed(value: Float) { + vlcMediaPlayer.controls().setRate(value) + } + + override fun mute() { + vlcMediaPlayer.audio().isMute = true + } + + override fun unMute() { + vlcMediaPlayer.audio().isMute = false + } + + override fun seek(value: Float) { + vlcMediaPlayer.controls().setPosition(value) + } + + override fun setClipToLoop(clip: Clip?) { + clipToLoop = clip + } + + override fun takeScreenshot(saveDirectory: File): Boolean { + return vlcMediaPlayer.snapshots().save(saveDirectory) + } + + override fun terminate() { + vlcMediaPlayer.events().removeMediaPlayerEventListener(eventListener) + vlcMediaPlayer.release() + vlcMediaPlayerFactory.release() + } + + override fun setFinishListener(listener: () -> Unit) { + finishListener = listener + } + + private inner class EventListener : VlcMediaPlayerEventAdapter() { + // Using vlcMediaPlayer.status().length() didn't work + var mediaLength = 0L + + override fun lengthChanged(vlcMediaPlayer: VlcMediaPlayer, newLength: Long) { + mediaLength = newLength + } + + /** + * Handles media finish. + * + * We play the media on finish (so the player is kind of idempotent), + * unless the [finishListener] callback stops the playback. + * Using `vlcMediaPlayer.controls().repeat = true` did not work as expected. + */ + override fun stopped(vlcMediaPlayer: VlcMediaPlayer) { + // finishListener?.invoke() + // Restarts the media only if it is longer than 1 seconds; + // This is mostly for when the file is an image to prevent + // this callback which is called very often to consume CPU + // and to prevent logging too many statements in the logger + if (mediaLength > 1_000) { + logger.info { "Media finished; starting it over" } + vlcMediaPlayer.controls().play() + } + } + + /** + * Handles looping the clip if it is set. + * Note that it seems vlcj updates the progress only every 250 milliseconds or so. + * + * For looping the clip, instead of setting start and stop time options in the play method above + * we set them manually here in a listener because when the loop is set to null + * the play is called again and thus the media starts over (instead of continuing). + * + * For previous implementation of looping the clip that used stop time option in the play method above, + * checkout the v1.4.0 git tag. + */ + override fun timeChanged(vlcMediaPlayer: VlcMediaPlayer, newTime: Long) { + val (start, end) = clipToLoop.takeIf { it != null } ?: return + val startPosition = start.inWholeMilliseconds / vlcMediaPlayer.status().length().toFloat() + if (newTime < (start - 500.milliseconds).inWholeMilliseconds) { + vlcMediaPlayer.controls().setPosition(startPosition) + } else if (newTime >= end.inWholeMilliseconds) { + vlcMediaPlayer.controls().setPosition(startPosition) + } + } + } +} + +private class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVideoSurfaceAdapter()) { + + private lateinit var imageInfo: ImageInfo + private lateinit var frameBytes: ByteArray + private val skiaBitmap = Bitmap() + private val videoSurface = SkiaBitmapVideoSurface() + val bitmap = MutableStateFlow(null) + + override fun attach(mediaPlayer: VlcMediaPlayer) { + videoSurface.attach(mediaPlayer) + } + + private inner class SkiaBitmapBufferFormatCallback : BufferFormatCallback { + private var sourceWidth: Int = 0 + private var sourceHeight: Int = 0 + + override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat { + this.sourceWidth = sourceWidth + this.sourceHeight = sourceHeight + return RV32BufferFormat(sourceWidth, sourceHeight) + } + + override fun allocatedBuffers(buffers: Array) { + frameBytes = buffers[0].run { ByteArray(remaining()).also(::get) } + imageInfo = ImageInfo( + sourceWidth, + sourceHeight, + ColorType.BGRA_8888, + ColorAlphaType.PREMUL + ) + } + } + + private inner class SkiaBitmapRenderCallback : RenderCallback { + override fun display( + mediaPlayer: VlcMediaPlayer, + nativeBuffers: Array, + bufferFormat: BufferFormat, + ) { + SwingUtilities.invokeLater { + nativeBuffers[0].rewind() + nativeBuffers[0].get(frameBytes) + skiaBitmap.installPixels(imageInfo, frameBytes, bufferFormat.width * 4) + // Takes less than 1 millisecond + bitmap.value = skiaBitmap.asComposeImageBitmap() + } + } + } + + private inner class SkiaBitmapVideoSurface : CallbackVideoSurface( + SkiaBitmapBufferFormatCallback(), + SkiaBitmapRenderCallback(), + true, + videoSurfaceAdapter + ) +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/component/SaveFileNameGenerator.kt b/src/main/kotlin/ir/mahozad/cutcon/component/SaveFileNameGenerator.kt new file mode 100644 index 0000000..5afdfa2 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/component/SaveFileNameGenerator.kt @@ -0,0 +1,46 @@ +package ir.mahozad.cutcon.component + +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import ir.mahozad.cutcon.toTwoDigit +import java.nio.file.Path +import java.time.LocalTime +import java.time.chrono.ChronoLocalDate +import kotlin.io.path.exists +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.nameWithoutExtension + +private val logger = logger(name = SaveFileNameGenerator::class.simpleName ?: "") + +fun interface SaveFileNameGenerator { + fun generate( + directory: Path, + date: ChronoLocalDate, + time: LocalTime + ): String +} + +object DefaultSaveFileNameGenerator : SaveFileNameGenerator { + override fun generate( + directory: Path, + date: ChronoLocalDate, + time: LocalTime + ): String { + val name = "${date}_${time.hour.toTwoDigit()}-00" + val maxNumber = maxExistingFileNumber(directory, name) + val result = "${name}_${maxNumber + 1}" + logger.info { "Generated save file name $result" } + return result + } + + private fun maxExistingFileNumber( + directory: Path, + name: String + ) = directory + .takeIf(Path::exists) + ?.listDirectoryEntries(glob = "$name*") + ?.map(Path::nameWithoutExtension) + ?.map { it.substringAfter("${name}_") } + ?.mapNotNull(String::toIntOrNull) + ?.maxOrNull() + ?: 0 +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/component/SystemDateTime.kt b/src/main/kotlin/ir/mahozad/cutcon/component/SystemDateTime.kt new file mode 100644 index 0000000..e699365 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/component/SystemDateTime.kt @@ -0,0 +1,10 @@ +package ir.mahozad.cutcon.component + +import java.time.LocalDate +import java.time.LocalTime + +object SystemDateTime { + fun nowTime(): LocalTime = LocalTime.now() + fun nowDate(): LocalDate = LocalDate.now() + fun nowMillis(): Long = System.currentTimeMillis() +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/component/UrlMaker.kt b/src/main/kotlin/ir/mahozad/cutcon/component/UrlMaker.kt new file mode 100644 index 0000000..b9e303a --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/component/UrlMaker.kt @@ -0,0 +1,26 @@ +package ir.mahozad.cutcon.component + +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import ir.mahozad.cutcon.model.Source +import java.net.URL +import kotlin.io.path.absolutePathString + +private val logger = logger(name = UrlMaker::class.simpleName ?: "") + +fun interface UrlMaker { + fun makeUrl(source: Source): URL +} + +object DefaultUrlMaker : UrlMaker { + override fun makeUrl(source: Source) = when (source) { + is Source.Local -> { + // For playing local files use a URL with "localhost" like this: + // file://localhost/C:/Users/Name/Downloads/video.mp4 + // Otherwise, the media playback stutters when using a URL like this: + // file://C:/Users/Name/Downloads/video.mp4 + URL("file://localhost/${source.path.absolutePathString()}") + } + }.also { + logger.info { "Generated url $it" } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/converter/Converter.kt b/src/main/kotlin/ir/mahozad/cutcon/converter/Converter.kt new file mode 100644 index 0000000..541ed76 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/converter/Converter.kt @@ -0,0 +1,131 @@ +package ir.mahozad.cutcon.converter + +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.model.* +import ir.mahozad.cutcon.substringBetween +import ir.mahozad.cutcon.toDuration +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext +import org.bytedeco.ffmpeg.ffmpeg +import org.bytedeco.javacpp.Loader +import java.net.URL +import java.nio.file.Path +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +typealias FFmpegOption = Pair + +fun interface ProgressListener { + fun onProgressUpdate(progress: Float) +} + +/** + * See https://stackoverflow.com/q/52182827 + */ +abstract class Converter(private val dispatcher: CoroutineDispatcher) { + // Loads the FFmpeg executable appropriate for the current OS/architecture. + // See the docs in README -> FFmpeg section and its subsections for more information. + private val ffmpegPath = Loader.load(ffmpeg::class.java) + private val millisecondRegex = Regex("""\.\d+""") + private val logger = logger(name = Converter::class.simpleName ?: "") + + protected abstract fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ): List + + /** + * The more functional and better way would be to return a result value + * (such as [Result] or our own custom sealed class) but then the clients who call this function, + * would have to handle two kinds of errors in two separate places: + * - Handle the exceptions in their catch clause or runCatching.onFailure + * - Handle the results denoting errors after calling this or in their runCatching.onSuccess + * (which mixes logic with results denoting success) + * + * For a quicker cancellation, increase the frequency of FFmpeg progress updates in [createCommand]. + * + * @throws [FFmpegProcessStartFailureException] if FFmpeg process failed to start + * @throws [FFmpegNonZeroExitCodeException] if FFmpeg return value was non-zero (abnormal) + * @throws [CancellationException] if the coroutine executing this function is canceled + */ + suspend fun convert( + input: URL, + clip: Clip, + intro: IntroOptions, + cover: CoverOptions, + quality: Quality, + flags: ConverterFlags, + output: Path, + listener: ProgressListener + ) = withContext(dispatcher) { + val ffmpegOptions = ffmpegOptions(quality, intro, cover, flags) + val ffmpegProcess = ProcessBuilder() + .createCommand(input, clip, ffmpegOptions, output) + .also { logger.info { "FFmpeg command:\n ${it.command().joinToString(separator = "\n ")}" } } + .runCatching { start() } + .onFailure { logger.error(it) { "Starting the FFmpeg process failed" } } + .onSuccess { logger.info { "Started FFmpeg process with pid=${it.pid()}" } } + .getOrElse { throw FFmpegProcessStartFailureException(it) } + ffmpegProcess + // By default, FFmpeg logs to stderr, so its errorStream is used + .errorStream + .reader() + .useLines { lines -> + lines + .asFlow() + .onEach { logger.info { it } } + .mapNotNull { parseLineAsProgress(it, clip.duration) } + .cancellable() // Could instead use .while { isActive OR ensureActive() } and remove asFlow() + .onCompletion { + logger.info { "Destroying FFmpeg process (pid=${ffmpegProcess?.pid()}) (alive=${ffmpegProcess?.isAlive})..." } + ffmpegProcess?.destroy() // This is also required to prevent FFmpeg leak on cancellation + ffmpegProcess?.onExit()?.join() + logger.info { "FFmpeg process destroyed successfully" } + } + .collect(listener::onProgressUpdate) + } + if (ffmpegProcess?.exitValue() != 0) { + throw FFmpegNonZeroExitCodeException(ffmpegProcess.exitValue()) + } + } + + private fun ProcessBuilder.createCommand( + input: URL, + clip: Clip, + options: List, + output: Path + ) = command( + ffmpegPath, + "-y", // Overwrites the output file if it exists + "-nostdin", // Disables FFmpeg input (just in case) + "-loglevel", "info", // -loglevel option is the same as -v + "-stats_period", "0.1s", // The period between progress updates + "-accurate_seek", // Enables accurate seeking in input files when using -ss + // NOTE: The timestamps may not be exact due to VLC and vlcj progress emission inaccuracy + // Refer to MediaPlayer progress emitter function for more information. + // NOTE: Use timestamps (-ss, -to, -t) before inputs (-i) to avoid unnecessary decoding + // For timestamp formats see https://ffmpeg.org/ffmpeg-utils.html#Time-duration + "-ss", "${(clip.start - /* Makes end result more accurate */ 500.milliseconds).inWholeMilliseconds}ms", + "-to", "${(clip.end - /* Makes end result more accurate */ 500.milliseconds).inWholeMilliseconds}ms", // Could use -t for duration + "-i", input.toString().replace("file://localhost/", "file:"), // The main input (could be a file, stream, etc.) + *options.flatMap(FFmpegOption::toList).toTypedArray(), + // See https://wiki.multimedia.cx/index.php/FFmpeg_Metadata + "-metadata", "encoding_tool=${BuildConfig.APP_NAME} v${BuildConfig.APP_VERSION}", + "$output" + ) + + private fun parseLineAsProgress(line: String, totalDuration: Duration) = line + .takeIf { "time=" in it } + ?.substringBetween("time=", " bitrate=") + ?.replace(millisecondRegex, "") + ?.toDuration() + ?.div(totalDuration) + ?.toFloat() + ?.coerceIn(0f..1f) + ?.also { logger.debug { "Parsed FFmpeg progress $it" } } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/converter/ConverterFactory.kt b/src/main/kotlin/ir/mahozad/cutcon/converter/ConverterFactory.kt new file mode 100644 index 0000000..aa736bf --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/converter/ConverterFactory.kt @@ -0,0 +1,21 @@ +package ir.mahozad.cutcon.converter + +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import ir.mahozad.cutcon.model.Format +import kotlinx.coroutines.CoroutineDispatcher + +private val logger = logger(name = ConverterFactory::class.simpleName ?: "") + +fun interface ConverterFactory { + fun createFor(format: Format): Converter +} + +class DefaultConverterFactory(private val dispatcher: CoroutineDispatcher) : ConverterFactory { + override fun createFor(format: Format) = when (format) { + Format.MP3 -> Mp3Converter(dispatcher) + Format.MP4 -> Mp4Converter(dispatcher) + Format.RAW -> RawConverter(dispatcher) + }.also { + logger.info { "Selected ${it::class.simpleName} for format $format" } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/converter/Mp3Converter.kt b/src/main/kotlin/ir/mahozad/cutcon/converter/Mp3Converter.kt new file mode 100644 index 0000000..4b41fc4 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/converter/Mp3Converter.kt @@ -0,0 +1,62 @@ +package ir.mahozad.cutcon.converter + +import ir.mahozad.cutcon.assetsPath +import ir.mahozad.cutcon.convertSvgToPng +import ir.mahozad.cutcon.model.ConverterFlags +import ir.mahozad.cutcon.model.CoverOptions +import ir.mahozad.cutcon.model.IntroOptions +import ir.mahozad.cutcon.model.Quality +import kotlinx.coroutines.CoroutineDispatcher +import kotlin.io.path.div + +/** + * See https://trac.ffmpeg.org/wiki/Encode/MP3 + * + * Could also first convert the input to mp3 and then add metadata to it via another command: + * - `ffmpeg -nostdin -ss 5 -to 12 -i input.ts -map a temp.mp3` + * - `ffmpeg -i temp.mp3 -i cover.png -map 0:0 -map 1:0 -c copy + * id3v2_version 3 -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" output.mp3` + * + * For adding an album art see: + * - https://stackoverflow.com/a/73706680 + * - https://stackoverflow.com/a/24840831 + * - https://stackoverflow.com/q/23581866 + */ +class Mp3Converter(dispatcher: CoroutineDispatcher) : Converter(dispatcher) { + + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = listOf( + "-i" to (coverOptions.path ?: defaultAlbumArtPath).toString(), // Adds an image to be used as album art + "-map" to "0:a", // Selects the audio from the first input + "-map" to "1:0", // Selects first stream from the second input + "-c:1" to "copy", // Sets the encoding for the second input to "copy" (otherwise, FFmpeg may convert the image) + "-b:a" to qualityOptionValue(quality), + // Sets ID3 version; version 4 doesn't seem to work on Windows + // Also, additional options (might need to add them as well): + // "-metadata:s:v" to """title="Album cover"""", + // "-metadata:s:v" to """comment="Cover (front)"""", + // "-f" to "mp3", // Specify output format explicitly + "-id3v2_version" to "3" + ) + + /** + * Allowed bit rates are 8|16|24|32|40|48|64|80|96|112|128|160|192|224|256|320 + */ + private fun qualityOptionValue(quality: Quality) = when (quality) { + Quality.LOWEST -> "96k" + Quality.LOW -> "128k" + Quality.MEDIUM -> "192k" + Quality.HIGH -> "256k" + Quality.HIGHEST -> "320k" + } + + companion object { + private const val DEFAULT_ALBUM_ART_SIZE = 512f + val defaultAlbumArtPath = (assetsPath / "cover-little-padding.svg") + .convertSvgToPng(DEFAULT_ALBUM_ART_SIZE) + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/converter/Mp4Converter.kt b/src/main/kotlin/ir/mahozad/cutcon/converter/Mp4Converter.kt new file mode 100644 index 0000000..60b9402 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/converter/Mp4Converter.kt @@ -0,0 +1,197 @@ +package ir.mahozad.cutcon.converter + +import ir.mahozad.cutcon.defaultOutputFrameRate +import ir.mahozad.cutcon.model.* +import ir.mahozad.cutcon.toHex +import kotlinx.coroutines.CoroutineDispatcher +import kotlin.time.Duration + +class Mp4Converter(dispatcher: CoroutineDispatcher) : Converter(dispatcher) { + + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = inputOptions(introOptions, coverOptions, flags) + outputOptions(quality) + + private fun inputOptions( + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ): List { + // To check if the input has video or not, + // could have probably used org.bytedeco.*** classes or + // could have used ffprobe (see https://stackoverflow.com/q/32278277) + if (!flags.isVideoAvailableInInput) return emptyList() + + return buildList { + if (introOptions.path != null && introOptions.duration > Duration.ZERO) { + // The 4 options below create a video, n seconds long, from the image + add("-loop" to "1") + add("-framerate" to "$defaultOutputFrameRate") + add("-t" to "${introOptions.duration.inWholeSeconds}s") + add("-i" to introOptions.path.toString()) + // The 3 options below create a silent audio, n seconds long, to be used for the intro. + // It is needed because if some part of final output has audio then all the output must have audio. + // Could also have used anullsrc instead of aevalsrc=0 but the video size would differ a little bit. + add("-t" to "${introOptions.duration.inWholeSeconds}s") + add("-f" to "lavfi") + add("-i" to "aevalsrc=0") + } + if (coverOptions.path != null) { + add("-i" to coverOptions.path.toString()) + } + add("-filter_complex" to filterChains(flags, introOptions, coverOptions)) + } + } + + /** + * Note: Every output of filter chains should be used somewhere. + * otherwise, FFmpeg conversion will fail. + * So, to ignore an output of a filter chain, use `[outputName] nullsink` + * (`nullsink` does not produce any output, in contrast to `null` that outputs the input). + */ + private fun filterChains( + flags: ConverterFlags, + introOptions: IntroOptions, + coverOptions: CoverOptions + ): String { + val hasIntro = introOptions.path != null + val coverIndex = if (hasIntro) 3 else 1 + return """ + ${videoFilterChains(flags)} + ${introFilterChains(introOptions)} + ${coverFilterChains(coverOptions, coverIndex)} + ${finalFilterChains(hasIntro)} + """ + .lines() + .map(String::trim) + .map { " $it" } // Indentation to prettify + .filter(String::isNotBlank) + .joinToString(separator = "\n ") + } + + /** + * Description of each filter chain: + * - For the 0 (first) input (aka the video), apply de-interlacing (yet-another-de-interlacing-filter) + * and name the output `media-de-interlaced`. + * To just copy the stream unmodified, use `copy` or `null` instead of `yadif=1` + * - Most regular videos have sar (source/storage aspect ratio) of `1`. + * But some videos are anamorphic, meaning, they have a different sar (`16/11`). + * So, apply the sar to fix the anamorphic problem (to get the final width displayed on TVs) + * - Multiplying width or height by a fraction in previous step may result in an odd number of pixels + * for width or height (for example, `843` pixels for width) which is not acceptable for a video. + * So, round the width and height to have an even number of pixels + * (for example, instead of `843` pixels, it becomes `844` pixels) + * - We have applied the video sar to its dimensions in previous steps, + * so now we set the sar to `1` so all inputs will have sar of `1`. + * This is to prevent the `concat` filter chain in next steps from complaining + * - Name the final output `media` for subsequent steps to use + */ + private fun videoFilterChains(flags: ConverterFlags) = """ + [0] ${if (flags.isInterlacingFixEnabled) "yadif=1" else "null"} [media-de-interlaced]; + [media-de-interlaced] scale=iw*sar:ih [media-anamorphic-fixed]; + [media-anamorphic-fixed] pad=ceil(iw/2)*2:ceil(ih/2)*2 [media-odd-pixel-number-fixed]; + [media-odd-pixel-number-fixed] setsar=1/1 [media-source-aspect-ratio-normalized]; + [media-source-aspect-ratio-normalized] null [media]; + """ + + /** + * Description of each filter chain: + * - Coerce maximum width and height of the intro image to be not larger than + * the video width and height, correspondingly, preserving the intro image aspect ratio. + * (Note that, `force_original_aspect_ratio` refers to aspect ratio of the second argument + * of filter chain (the media here) and that's the reason we could not use it for simplification) + * - Use the video (media) and also use the intro image as a dummy input because `scale2ref` requires 2 inputs, + * to create an output in this step that has the same size as the video + * - Use the output from the last step which has the same size as the main video + * to create a background with desired color for the intro in case + * the intro is smaller than the video or its aspect ratio is different from the video + * (because intro and video must have the same size i.e. one part of video cannot have different size than another part) + * - Use the intro background from previous step and the possibly resized intro image and place the image on the background + * - Like what was done for the main video, make sure that the intro has sar of `1` (just in case) + * - Name the final output `intro` for subsequent steps to use + */ + private fun introFilterChains(options: IntroOptions) = """ + [1][media] scale2ref='if(gt(main_a,a),min(main_w,iw),min(main_h,ih)*main_a)':'if(gt(main_a,a),min(main_w,iw)/main_a,min(main_h,ih))' [intro-image-max-size-coerced][media]; + [1][media] scale2ref=iw:ih [size-template][media]; + [size-template] drawbox=w=iw:h=ih:t=fill:color=${options.backgroundColor.toHex()} [intro-background]; + [intro-background][intro-image-max-size-coerced] overlay=x='(W-w)/2':y='(H-h)/2' [intro-with-background]; + [intro-with-background] setsar=1/1 [intro-source-aspect-ratio-normalized]; + [intro-source-aspect-ratio-normalized] null [intro]; + """.takeIf { options.path != null } ?: "" + + /** + * Description of each filter chain: + * - Apply desired scale and alpha to the watermark input. + * Note that because we have normalized the sar of the main video to `1` there is no more need to + * stretch or squeeze the watermark by multiplying its width by the input asr ratio (for exampel, `0.6875`) + * - This step is like what was done for the intro image. + * Note: size coercion should be applied after the watermark scale is applied + * to coerce the dimensions of the **final scaled** watermark + * - Place the watermark on the video in the desired position. + * Make sure to include the `format=yuv420p` so, Windows OS can show the video thumbnail and, + * more importantly, the output is playable in all players (specifically, Eitaa mobile player). + * See https://www.canva.dev/blog/engineering/a-journey-through-colour-space-with-ffmpeg/ + * - Name the final output `media` for subsequent steps to use + */ + private fun coverFilterChains(options: CoverOptions, watermarkIndex: Int) = """ + [$watermarkIndex] format=rgba, colorchannelmixer=aa=${options.opacity}, scale=iw*${options.scale}:ih*${options.scale} [watermark]; + [watermark][media] scale2ref='if(gt(main_a,a),min(main_w,iw),min(main_h,ih)*main_a)':'if(gt(main_a,a),min(main_w,iw)/main_a,min(main_h,ih))' [watermark-max-size-coerced][media]; + [media][watermark-max-size-coerced] overlay=${options.position.ffmpegNotation}:format=auto, format=yuv420p [media-with-watermark]; + [media-with-watermark] null [media]; + """.takeIf { options.path != null } ?: "" + + /** + * Description of the filter chain: + * - Concatenate the intro (along with its silent audio) and the video (possibly watermarked). + * `n=2:v=1:a=1` means there are two parts to join and + * the result will have one video stream and one audio stream. + * Could add `:unsafe=1` to the end of `concat`, just in case, + * to prevent errors if dimensions/aspect ratios of intro and video did not match. + */ + private fun finalFilterChains(hasIntro: Boolean) = when { + hasIntro -> "[intro][2][media] concat=n=2:v=1:a=1" + else -> "[media] null" + } + + /** + * Description of each option: + * - The encoder for the video stream. Run `./ffmpeg -encoders` for more + * - The encoder for the audio stream. Run `./ffmpeg -encoders` for more + * - The output constant quality (accepted values are `0..51`): + * + `0`: lossless + * + `23`: default + * + `51`: worst quality + * - The output frame rate. + * Needed because the intro image and the rest of the video should have the same frame rate. + * Instead of `-r ` could use `-fps_mode vfr`. + */ + private fun outputOptions(quality: Quality) = listOf( + "-c:v" to "libx264", + "-c:a" to "aac", + "-crf" to qualityString(quality), + "-r" to "$defaultOutputFrameRate" + ) + + private val WatermarkPosition.ffmpegNotation get() = when (this) { + WatermarkPosition.TOP_LEFT -> "0:0" + WatermarkPosition.TOP_MIDDLE -> "(W-w)/2:0" + WatermarkPosition.TOP_RIGHT -> "W-w:0" + WatermarkPosition.CENTER_LEFT -> "0:(H-h)/2" + WatermarkPosition.CENTER -> "(W-w)/2:(H-h)/2" + WatermarkPosition.CENTER_RIGHT -> "W-w:(H-h)/2" + WatermarkPosition.BOTTOM_LEFT -> "0:H-h" + WatermarkPosition.BOTTOM_MIDDLE -> "(W-w)/2:H-h" + WatermarkPosition.BOTTOM_RIGHT -> "W-w:H-h" + } + + private fun qualityString(quality: Quality) = when (quality) { + Quality.LOWEST -> "35" + Quality.LOW -> "29" + Quality.MEDIUM -> "23" + Quality.HIGH -> "17" + Quality.HIGHEST -> "11" + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/converter/RawConverter.kt b/src/main/kotlin/ir/mahozad/cutcon/converter/RawConverter.kt new file mode 100644 index 0000000..c173b81 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/converter/RawConverter.kt @@ -0,0 +1,18 @@ +package ir.mahozad.cutcon.converter + +import ir.mahozad.cutcon.model.ConverterFlags +import ir.mahozad.cutcon.model.CoverOptions +import ir.mahozad.cutcon.model.IntroOptions +import ir.mahozad.cutcon.model.Quality +import kotlinx.coroutines.CoroutineDispatcher + +class RawConverter(dispatcher: CoroutineDispatcher) : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ): List = listOf( + "-c" to "copy" // Preserves the encoding (== original/source encoding) + ) +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/localization/Language.kt b/src/main/kotlin/ir/mahozad/cutcon/localization/Language.kt new file mode 100644 index 0000000..00e9d15 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/localization/Language.kt @@ -0,0 +1,49 @@ +package ir.mahozad.cutcon.localization + +import androidx.compose.ui.platform.PlatformLocalization +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.LayoutDirection +import java.time.DayOfWeek +import java.util.* + +/** + * See https://github.com/JetBrains/compose-multiplatform/issues/425 + * and https://stackoverflow.com/q/2469435 + * and https://www.baeldung.com/java-resourcebundle + */ +interface Language { + val tag: String + val locale: Locale + val messages: Messages + val fontFamily: FontFamily + // See https://m2.material.io/design/usability/bidirectionality.html + val layoutDirection: LayoutDirection + val contextMenuLocalization: PlatformLocalization + get() = object : PlatformLocalization { + override val cut = messages.cut + override val copy = messages.copy + override val paste = messages.paste + override val selectAll = messages.selectAll + } + + fun getWeekdayName(day: DayOfWeek) = when (day) { + DayOfWeek.SATURDAY -> messages.weekdaySaturday + DayOfWeek.SUNDAY -> messages.weekdaySunday + DayOfWeek.MONDAY -> messages.weekdayMonday + DayOfWeek.TUESDAY -> messages.weekdayTuesday + DayOfWeek.WEDNESDAY -> messages.weekdayWednesday + DayOfWeek.THURSDAY -> messages.weekdayThursday + DayOfWeek.FRIDAY -> messages.weekdayFriday + } + + fun localizeDigits(string: String): String + fun localizeNumber(number: Float): String + + companion object { + fun fromTag(tag: String) = if (tag.lowercase() == LanguageFa.tag.lowercase()) { + LanguageFa + } else { + LanguageEn + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/localization/LanguageEn.kt b/src/main/kotlin/ir/mahozad/cutcon/localization/LanguageEn.kt new file mode 100644 index 0000000..9d4663a --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/localization/LanguageEn.kt @@ -0,0 +1,27 @@ +package ir.mahozad.cutcon.localization + +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.Font +import androidx.compose.ui.unit.LayoutDirection +import java.text.DecimalFormat +import java.util.* + +object LanguageEn : Language { + override val tag = "En" + override val locale = Locale.ENGLISH!! + override val messages = MessagesEn + override val fontFamily = FontFamily(Font("font/roboto.ttf")) // Or, for example, FontFamily.Default + override val layoutDirection = LayoutDirection.Ltr + + private val decimalFormat = DecimalFormat.getInstance(locale).apply { + minimumFractionDigits = 1 + maximumFractionDigits = 2 + } + + override fun localizeDigits(string: String) = + string.map { it.digitToIntOrNull()?.let('0'::plus) ?: it }.joinToString(separator = "") + + override fun localizeNumber(number: Float): String { + return decimalFormat.format(number) + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/localization/LanguageFa.kt b/src/main/kotlin/ir/mahozad/cutcon/localization/LanguageFa.kt new file mode 100644 index 0000000..17044ab --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/localization/LanguageFa.kt @@ -0,0 +1,29 @@ +package ir.mahozad.cutcon.localization + +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.Font +import androidx.compose.ui.unit.LayoutDirection +import java.text.DecimalFormat +import java.util.* + +object LanguageFa : Language { + override val tag = "Fa" + override val locale = Locale.forLanguageTag(tag)!! + override val messages = MessagesFa + override val fontFamily = FontFamily(Font("font/vazirmatn-ui-v33.003.ttf")) + override val layoutDirection = LayoutDirection.Rtl + + private val decimalFormat = DecimalFormat.getInstance(locale).apply { + minimumFractionDigits = 1 + maximumFractionDigits = 2 + } + + override fun localizeDigits(string: String) = + string.map { it.digitToIntOrNull()?.let('۰'::plus) ?: it }.joinToString(separator = "") + + override fun localizeNumber(number: Float): String { + val string = decimalFormat.format(number) + // Fixes the bug with number not being localized when running the app distributable + return localizeDigits(string).replace(',', '٬').replace('.', '٫') + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/localization/Messages.kt b/src/main/kotlin/ir/mahozad/cutcon/localization/Messages.kt new file mode 100644 index 0000000..38c91b6 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/localization/Messages.kt @@ -0,0 +1,266 @@ +package ir.mahozad.cutcon.localization + +import ir.mahozad.cutcon.model.Changelog +import kotlin.time.Duration + +/** + * Legend: + * ICO = ICON + * LBL = LABEL + * TIT = TITLE + * VAL = VALUE + * DLG = DIALOG + * BTN = BUTTON + * TLP = TOOLTIP + * DRP = DROPDOWN + * DSC = DESCRIPTION + * PLC = PLACEHOLDER + */ +sealed interface Messages { + val changelog: Changelog + val appName: String + val versionPrefix: String + val appVersion: String + val error: String + val openLogFolder: String + val from: String + val fromFile: String + val clipCreationIsAbandonedIfExitTheApp: String + val areYouSureToExitTheApp: String + val yes: String + val no: String + val qualityNotApplicableToRawFormat: String + val setClipStart: String + val setClipEnd: String + val seek5SecondsBackward: String + val seek5SecondsForward: String + val seek30SecondsBackward: String + val seek30SecondsForward: String + val livePlayback: String + val takeScreenshotAndSaveIn: String + val resumeMediaPlayback: String + val pauseMediaPlayback: String + val pinAppWindow: String + val unPinAppWindow: String + val muteMediaAudio: String + val unMuteMediaAudio: String + val restoreLastPlaybackSpeed: String + val resetPlaybackSpeedToNormal: String + val decreasePlaybackSpeed: String + val increasePlaybackSpeed: String + val switchToFullscreen: String + val switchToMiniMode: String + val switchToNormalMode: String + val switchToManualDateInput: String + val openSaveFolder: String + val openSourceFolder: String + val switchToSelectionDateInput: String + val hideSidePanel: String + val showSidePanel: String + val turnOnClipLoop: String + val turnOffClipLoop: String + val shortcut: String + val and: String + val weekdaySaturday: String + val weekdaySunday: String + val weekdayMonday: String + val weekdayTuesday: String + val weekdayWednesday: String + val weekdayThursday: String + val weekdayFriday: String + val cut: String + val copy: String + val paste: String + val selectAll: String + val dlgTitSpecifySaveFile: String + val dlgTitSelectLocalFile: String + val dlgTitSelectIntroImage: String + val dlgTitSelectWatermark: String + val dlgTitSelectAlbumArt: String + val dlgTitExistingFile: String + val dlgTitMissingFile: String + val dlgTitChangelog: String + val second: String + val btnLblOk: String + val btnLblOpen: String + val btnLblCancel: String + val btnLblSelectSaveFolder: String + val btnLblApproveSaveFile: String + val btnLblShowChangelog: String + val btnLblApproveSelectedFile: String + val btnLblStartConversion: String + val btnLblCancelConversion: String + val btnLblOpenAppLogFolder: String + val btnTlpOpenFileChooser: String + val btnTlpCancelFileChooser: String + val btnTlpCancelFileSave: String + val btnTlpApproveSaveFolder: String + val btnTlpApproveSelectedFile: String + val btnTlpUpFolder: String + val btnTlpViewMenu: String + val btnTlpNewFolder: String + val txtLblSourceLocal: String + val txtLblToday: String + val txtLblYesterday: String + val txtLblPercentSign: String + val txtLblSaveIn: String + val txtLblLookIn: String + val txtLblClipLength: String + val txtLblInput: String + val txtLblOutput: String + val txtLblQuality: String + val txtLblQuality1: String + val txtLblQuality2: String + val txtLblQuality3: String + val txtLblQuality4: String + val txtLblQuality5: String + val txtLblSelectWatermark: String + val txtLblSelectIntroImage: String + val txtLblSelectAlbumArt: String + val txtLblDragFileHere: String + val txtLblLanguage: String + val txtLblCalendar: String + val txtLblTheme: String + val txtLblAspectRatio: String + val txtLblFinishSound: String + val txtLblScreenshotSound: String + val txtLblInterlacedFix: String + val txtLblEnabled: String + val txtLblDisabled: String + val txtLblChangelogFeature: String + val txtLblChangelogBugFix: String + val txtLblChangelogUpdate: String + val txtLblChangelogRemoval: String + val txtLblChangelogInternal: String + val txtLblExistingFile: String + val txtLblMissingFile: String + val txtLblFileName: String + val txtLblFileType: String + val txtLblFileTypeAll: String + val txtLblFileFormat: String + val txtLblAspectRatioSource: String + val txtLblAspectRatio16To9: String + val txtLblCalendarGregorian: String + val txtLblCalendarSolarHijri: String + val txtLblThemeLight: String + val txtLblThemeDark: String + val radLblFormatMp4: String + val radLblFormatMp3: String + val radLblFormatRaw: String + val radLblLanguagePersian: String + val radLblLanguageEnglish: String + val txtLblClipCreationSuccess: String + val txtLblClipCreationFailure: String + val txtDscScreenshotHelp: String + val txtLblLocalFileSupportedTypesDescription: String + val txtLblIntroSupportedTypesDescription: String + val txtLblCoverSupportedTypesDescription: String + val txtLblHasNoDefault: String + val txtLblHasDefault: String + val txtLblAlpha: String + val txtLblScale: String + val txtLblMp3IntroNotSupported: String + val txtLblRawIntroNotSupported: String + val txtLblRawCoverNotSupported: String + val txtLblAudioFileIntroNotSupported: String + val txtLblImageFileIntroNotSupported: String + val txtLblMiscFileIntroNotSupported: String + val txtLblAudioFileWatermarkNotSupported: String + val txtLblImageFileWatermarkNotSupported: String + val txtLblMiscFileWatermarkNotSupported: String + val txtLblErrorClipNotSet: String + val txtLblErrorClipLengthZero: String + val txtLblErrorClipLengthNegative: String + val txtLblErrorClipStartAfterMediaEnd: String + val txtLblErrorClipFromImageNotSupported: String + val txtLblErrorClipFromFormatNotSupported: String + val txtLblErrorClipFileNotSet: String + val txtLblReady: String + val txtLblProgressInitializing: String + val txtLblProgressCreating: String + val txtLblAboutDeveloper: String + val txtLblAboutPoweredBy: String + val txtLblAboutKotlinLabel: String + val txtLblAboutKotlinText: String + val txtLblAboutGradleLabel: String + val txtLblAboutGradleText: String + val txtLblAboutJetpackComposeLabel: String + val txtLblAboutJetpackComposeText: String + val txtLblAboutComposeMultiplatformLabel: String + val txtLblAboutComposeMultiplatformText: String + val txtLblAboutVlcLabel: String + val txtLblAboutVlcText: String + val txtLblAboutFfmpegLabel: String + val txtLblAboutFfmpegText: String + val txtLblAboutMaterialDesignLabel: String + val txtLblAboutMaterialDesignText: String + val txtLblAboutInkscapeLabel: String + val txtLblAboutInkscapeText: String + val txtLblAboutIntellijLabel: String + val txtLblAboutIntellijText: String + val txtLblAboutGitLabel: String + val txtLblAboutGitText: String + val txtLblAboutGitHubLabel: String + val txtLblAboutGitHubText: String + val txtLblAboutVazirmatnLabel: String + val txtLblAboutVazirmatnText: String + + fun totalClipCreationTime(duration: Duration): String + + companion object { + const val ERR_COMPOSE_RES_DIR_NOT_SET = """ + JVM property 'compose.application.resources.dir' + which specifies the directory containing application custom assets hasn't been set. + See https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials/Native_distributions_and_local_execution#packaging-resources + """ + const val ICO_DSC_SUCCESS = "Success" + const val ICO_DSC_FAILURE = "Failure" + const val ICO_DSC_SOFTWARE_LOGO = "Software logo" + const val ICO_DSC_MAHOZAD_LOGO = "Mahdi Hosseinzadeh (Mahozad) logo" + const val ICO_DSC_OPEN_FOLDER = "Open the folder" + const val ICO_DSC_ENTER_FULLSCREEN = "Enter fullscreen" + const val ICO_DSC_ENTER_MINI_SCREEN = "Enter mini screen" + const val ICO_DSC_ENTER_REGULAR_SCREEN = "Enter regular screen" + const val ICO_DSC_EXIT_FULLSCREEN = "Exit fullscreen" + const val ICO_DSC_PLAY_FILE = "Start playing the file" + const val ICO_DSC_PLAY_PAUSE = "Play/Pause" + const val ICO_DSC_AUDIO_VOLUME = "Audio volume" + const val ICO_DSC_TOGGLE_ALWAYS_ON_TOP = "Toggle always on top" + const val ICO_DSC_TOGGLE_CLIP_LOOP = "Toggle clip loop" + const val ICO_DSC_TOGGLE_SIDE_PANEL = "Toggle side panel" + const val ICO_DSC_CHANGELOG_CATEGORY = "Changelog category" + const val ICO_DSC_CHANGELOG_ENTRY = "Changelog entry" + const val ICO_DSC_TITLE_BAR_ICON = "Title bar icon" + const val ICO_DSC_MINIMIZE = "Minimize" + const val ICO_DSC_CLOSE = "Close" + const val ICO_DSC_PANEL_ABOUT = "About panel" + const val ICO_DSC_PANEL_CONFIG = "Config panel" + const val ICO_DSC_PANEL_SETTINGS = "Settings panel" + const val IMG_DSC_DISPLAY_IMAGE = "Display image" + const val ICO_DSC_SETTINGS_LANGUAGE = "Language settings" + const val ICO_DSC_SETTINGS_THEME = "Theme settings" + const val ICO_DSC_SETTINGS_CALENDAR = "Calendar settings" + const val ICO_DSC_SETTINGS_ASPECT_RATIO = "Display image aspect ratio settings" + const val ICO_DSC_SETTINGS_FINISH_SOUND = "Success sound settings" + const val ICO_DSC_SETTINGS_SCREENSHOT_SOUND = "Screenshot sound settings" + const val ICO_DSC_SETTINGS_INTERLACED_FIX = "Interlaced settings" + const val ICO_DSC_OPEN_DROPDOWN = "Open dropdown" + const val ICO_DSC_RESET_SPEED = "Reset playback speed" + const val ICO_DSC_DECREASE_SPEED = "Decrease playback speed" + const val ICO_DSC_INCREASE_SPEED = "Increase playback speed" + const val ICO_DSC_SET_CLIP_START_NOW = "Set clip start to now" + const val ICO_DSC_SET_CLIP_END_NOW = "Set clip end to now" + const val ICO_DSC_REMOVE_INTRO = "Remove intro" + const val ICO_DSC_REMOVE_COVER = "Remove cover" + const val ICO_DSC_INTRO_PREVIEW = "Intro preview" + const val ICO_DSC_COVER_PREVIEW = "Cover preview" + const val ICO_DSC_ADD_INTRO = "Add intro" + const val ICO_DSC_ADD_COVER = "Add cover" + const val ICO_DSC_TAKE_SCREENSHOT = "Take screenshot" + const val ICO_DSC_LOGO = "App logo" + const val ICO_DSC_REWIND_5_SECONDS = "Rewind 5 seconds" + const val ICO_DSC_REWIND_30_SECONDS = "Rewind 30 seconds" + const val ICO_DSC_FORWARD_5_SECONDS = "Forward 5 seconds" + const val ICO_DSC_FORWARD_30_SECONDS = "Forward 30 seconds" + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/localization/MessagesEn.kt b/src/main/kotlin/ir/mahozad/cutcon/localization/MessagesEn.kt new file mode 100644 index 0000000..b343dcf --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/localization/MessagesEn.kt @@ -0,0 +1,237 @@ +package ir.mahozad.cutcon.localization + +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.model.LocalSourceSupportedFileType +import ir.mahozad.cutcon.model.SupportedImageFormat +import ir.mahozad.cutcon.parseMarkdownAsChangelog +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +data object MessagesEn : Messages { + override val changelog by lazy { + // See the build script where the changelog file was added as an app resource + val stream = javaClass.getResourceAsStream("/CHANGELOG.md")!! + parseMarkdownAsChangelog(stream, LanguageEn.tag) + } + + // See the build script the BuildConfig is generated automatically + override val appName = BuildConfig.APP_NAME + override val versionPrefix = "v" + override val appVersion = "$versionPrefix${BuildConfig.APP_VERSION}" + override val error = "An error occurred in the application.\nRefer to logs for more details." + override val openLogFolder = "Open log folder" + override val from = "from" + override val fromFile = "from file" + override val clipCreationIsAbandonedIfExitTheApp = "If you exit the app, clip creation will be abandoned." + override val areYouSureToExitTheApp = "Do you want to exit the app?" + override val yes = "Yes" + override val no = "No" + override val qualityNotApplicableToRawFormat = "Source quality (cannot change quality for Raw output)" + override val setClipStart = "Set clip start" + override val setClipEnd = "Set clip end" + override val seek5SecondsBackward = "Seek 5 seconds backward" + override val seek5SecondsForward = "Seek 5 seconds forward" + override val seek30SecondsBackward = "Seek 30 seconds backward" + override val seek30SecondsForward = "Seek 30 seconds forward" + override val livePlayback = "Live playback" + override val takeScreenshotAndSaveIn = "Take screenshot (S) and save in:" + override val resumeMediaPlayback = "Resume playback" + override val pauseMediaPlayback = "Pause playback" + override val pinAppWindow = "Show always on top (pin)" + override val unPinAppWindow = "Unpin the app" + override val muteMediaAudio = "Mute audio" + override val unMuteMediaAudio = "Unmute audio" + override val restoreLastPlaybackSpeed = "Restore last playback speed" + override val resetPlaybackSpeedToNormal = "Reset playback speed to normal" + override val decreasePlaybackSpeed = "Decrease playback speed" + override val increasePlaybackSpeed = "Increase playback speed" + override val switchToFullscreen = "Switch to fullscreen" + override val switchToMiniMode = "Switch to mini mode" + override val switchToNormalMode = "Switch to normal mode" + override val openSourceFolder = "Open source folder" + override val openSaveFolder = "Open save folder" + override val switchToManualDateInput = "Switch to manual mode" + override val switchToSelectionDateInput = "Switch to selection mode" + override val hideSidePanel = "Hide side panel" + override val showSidePanel = "Show side panel" + override val turnOnClipLoop = "Turn on clip loop" + override val turnOffClipLoop = "Turn off clip loop" + override val shortcut = "Shortcut:" + override val and = "and" + override val weekdaySaturday = "Saturday" + override val weekdaySunday = "Sunday" + override val weekdayMonday = "Monday" + override val weekdayTuesday = "Tuesday" + override val weekdayWednesday = "Wednesday" + override val weekdayThursday = "Thursday" + override val weekdayFriday = "Friday" + override val cut = "Cut" + override val copy = "Copy" + override val paste = "Paste" + override val selectAll = "Select All" + override val dlgTitSpecifySaveFile = "Save clip as..." + override val dlgTitSelectLocalFile = "Select file" + override val dlgTitSelectIntroImage = "Select intro image" + override val dlgTitSelectWatermark = "Select watermark" + override val dlgTitSelectAlbumArt = "Select album art" + override val dlgTitExistingFile = "Existing file" + override val dlgTitMissingFile = "Missing file" + override val dlgTitChangelog = "Changelog" + override val second = "s" + override val btnLblOk = "OK" + override val btnLblOpen = "Open" + override val btnLblCancel = "Cancel" + override val btnLblSelectSaveFolder = "Save as..." + override val btnLblApproveSaveFile = "OK" + override val btnLblShowChangelog = "Changelog" + override val btnLblApproveSelectedFile = "Select" + override val btnLblStartConversion = "START" + override val btnLblCancelConversion = "CANCEL" + override val btnLblOpenAppLogFolder = "Open app log folder" + override val btnTlpOpenFileChooser = "Open the folder" + override val btnTlpCancelFileChooser = "Discard choosing a file" + override val btnTlpCancelFileSave = "Discard setting the file" + override val btnTlpApproveSaveFolder = "Select this as clip save file" + override val btnTlpApproveSelectedFile = "Select the file" + override val btnTlpUpFolder = "Up One Level" + override val btnTlpViewMenu = "View Menu" + override val btnTlpNewFolder = "Create new folder" + override val txtLblSourceLocal = "Source" + override val txtLblToday = "(Today)" + override val txtLblYesterday = "(Yesterday)" + override val txtLblPercentSign = "%" + override val txtLblSaveIn = "Save in:" + override val txtLblLookIn = "Look in:" + override val txtLblClipLength = "Clip length:" + override val txtLblInput = "Input" + override val txtLblOutput = "Output" + override val txtLblQuality = "Quality" + override val txtLblQuality1 = "1" + override val txtLblQuality2 = "2" + override val txtLblQuality3 = "3" + override val txtLblQuality4 = "4" + override val txtLblQuality5 = "5" + override val txtLblSelectWatermark = "Watermark" + override val txtLblSelectIntroImage = "Intro image" + override val txtLblSelectAlbumArt = "Album art" + override val txtLblDragFileHere = "can drag it here" + override val txtLblLanguage = "Language (زبان):" + override val txtLblCalendar = "Calendar:" + override val txtLblTheme = "Theme:" + override val txtLblAspectRatio = "Image ratio:" + override val txtLblFinishSound = "Success sound:" + override val txtLblScreenshotSound = "Camera sound:" + override val txtLblInterlacedFix = "De-interlacing:" + override val txtLblEnabled = "Enabled" + override val txtLblDisabled = "Disabled" + override val txtLblChangelogFeature = "New features" + override val txtLblChangelogBugFix = "Bug fixes" + override val txtLblChangelogUpdate = "Updates" + override val txtLblChangelogRemoval = "Removals" + override val txtLblChangelogInternal = "Internal changes" + override val txtLblExistingFile = "The file already exists, overwrite?" + override val txtLblMissingFile = "The file does not exist" + override val txtLblFileName = "File name:" + override val txtLblFileType = "File type:" + override val txtLblFileTypeAll = "ALL FILES" + override val txtLblFileFormat = "File format:" + override val txtLblAspectRatioSource = "Source" + override val txtLblAspectRatio16To9 = "16:9" + override val txtLblCalendarGregorian = "Gregorian" + override val txtLblCalendarSolarHijri = "Solar Hijri" + override val txtLblThemeLight = "Light" + override val txtLblThemeDark = "Dark" + override val radLblFormatMp4 = "MP4" + override val radLblFormatMp3 = "MP3" + override val radLblFormatRaw = "Raw/Source" + override val radLblLanguagePersian = "فارسی" + override val radLblLanguageEnglish = "English" + override val txtLblClipCreationSuccess = "Clip created successfully" + override val txtLblClipCreationFailure = "Failed to create the clip" + override val txtDscScreenshotHelp = "Press and hold to open the folder" + override val txtLblLocalFileSupportedTypesDescription = "Media (${LocalSourceSupportedFileType.entries.joinToString { it.name }})" + override val txtLblIntroSupportedTypesDescription = "Images (${SupportedImageFormat.entries.joinToString { it.name }})" + override val txtLblCoverSupportedTypesDescription = "Images (${SupportedImageFormat.entries.joinToString { it.name }})" + override val txtLblHasNoDefault = "(no default)" + override val txtLblHasDefault = "(has default)" + override val txtLblAlpha = "Alpha:" + override val txtLblScale = "Scale:" + override val txtLblMp3IntroNotSupported = "MP3 output does not support intro" + override val txtLblRawIntroNotSupported = "Raw output does not support intro" + override val txtLblRawCoverNotSupported = "Raw output does not support watermark or album art" + override val txtLblAudioFileIntroNotSupported = "Audio input does not support intro" + override val txtLblImageFileIntroNotSupported = "Image input does not support intro" + override val txtLblMiscFileIntroNotSupported = "Input format does not support intro" + override val txtLblAudioFileWatermarkNotSupported = "Audio input does not support watermark" + override val txtLblImageFileWatermarkNotSupported = "Image input does not support watermark" + override val txtLblMiscFileWatermarkNotSupported = "Input format does not support watermark" + override val txtLblErrorClipNotSet = "Clip has not been set" + override val txtLblErrorClipLengthZero = "Clip length is zero" + override val txtLblErrorClipLengthNegative = "Clip length is negative" + override val txtLblErrorClipStartAfterMediaEnd = "Clip start is larger than media length" + override val txtLblErrorClipFromImageNotSupported = "Image input does not support creating clip" + override val txtLblErrorClipFromFormatNotSupported = "Input format does not support creating clip" + override val txtLblErrorClipFileNotSet = "Save file has not been set" + override val txtLblReady = "Ready" + override val txtLblProgressInitializing = "Initializing..." + override val txtLblProgressCreating = "Creating clip" + override val txtLblAboutDeveloper = "Developer: Mahdi Hosseinzadeh" + override val txtLblAboutPoweredBy = "Proudly powered by open-source and free software" + override val txtLblAboutKotlinLabel = "Kotlin" + override val txtLblAboutKotlinText = " programming language by JetBrains" + override val txtLblAboutGradleLabel = "Gradle" + override val txtLblAboutGradleText = " build tool for building the project" + override val txtLblAboutJetpackComposeLabel = "Jetpack Compose" + override val txtLblAboutJetpackComposeText = " by Google for user interface" + override val txtLblAboutComposeMultiplatformLabel = "Compose Multiplatform" + override val txtLblAboutComposeMultiplatformText = " by JetBrains for user interface" + override val txtLblAboutVlcLabel = "libVLC and vlcj" + override val txtLblAboutVlcText = " for playing and interacting with media" + override val txtLblAboutFfmpegLabel = "FFmpeg and JavaCV" + override val txtLblAboutFfmpegText = " for clipping and converting media" + override val txtLblAboutMaterialDesignLabel = "Material design" + override val txtLblAboutMaterialDesignText = " and Material icons by Google" + override val txtLblAboutInkscapeLabel = "Inkscape" + override val txtLblAboutInkscapeText = " for creating icons and vector graphics" + override val txtLblAboutIntellijLabel = "IntelliJ IDEA" + override val txtLblAboutIntellijText = " Community Edition as the main IDE" + override val txtLblAboutGitLabel = "Git" + override val txtLblAboutGitText = " for version control and tracking revision history" + override val txtLblAboutGitHubLabel = "GitHub" + override val txtLblAboutGitHubText = " by Microsoft for hosting the source code" + override val txtLblAboutVazirmatnLabel = "Vazirmatn" + override val txtLblAboutVazirmatnText = " as the font family for Persian (Farsi) text" + + override fun totalClipCreationTime(duration: Duration) = "Took ${timeString(duration)}" + + private fun timeString(duration: Duration): String { + fun seconds(value: Long) = when (value) { + 0L -> "" + 1L -> "1 second" + else -> "$value seconds" + } + + fun minutes(value: Long) = when (value) { + 0L -> "" + 1L -> "1 minute" + else -> "$value minutes" + } + + fun hours(value: Long) = when (value) { + 0L -> "" + 1L -> "1 hour" + else -> "$value hours" + } + return if (duration < 1.seconds) { + "less than one second" + } else if (duration < 1.minutes) { + seconds(duration.inWholeSeconds % 60) + } else if (duration < 1.hours) { + "${minutes(duration.inWholeMinutes % 60)} ${seconds(duration.inWholeSeconds % 60)}".trim() + } else { + "${hours(duration.inWholeHours % 60)} ${minutes(duration.inWholeMinutes % 60)}".trim() + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/localization/MessagesFa.kt b/src/main/kotlin/ir/mahozad/cutcon/localization/MessagesFa.kt new file mode 100644 index 0000000..44699b2 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/localization/MessagesFa.kt @@ -0,0 +1,238 @@ +package ir.mahozad.cutcon.localization + +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.model.LocalSourceSupportedFileType +import ir.mahozad.cutcon.model.SupportedImageFormat +import ir.mahozad.cutcon.parseMarkdownAsChangelog +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +data object MessagesFa : Messages { + override val changelog by lazy { + // See the build script where the changelog file was added as an app resource + val stream = javaClass.getResourceAsStream("/CHANGELOG.md")!! + parseMarkdownAsChangelog(stream, LanguageFa.tag) + } + + override val appName = "کلیپر" + override val versionPrefix = "ویرایش" + override val appVersion = "$versionPrefix ${LanguageFa.localizeDigits(BuildConfig.APP_VERSION)}" + override val error = "خطایی در برنامه رخ داد.\nبرای جزئیات بیشتر، لاگ برنامه را مشاهده کنید." + override val openLogFolder = "باز کردن پوشه لاگ" + override val from = "از" + override val fromFile = "از پرونده" + override val clipCreationIsAbandonedIfExitTheApp = "در صورت خروج از برنامه، ایجاد کلیپ نیمه‌کاره لغو می‌شود." + override val areYouSureToExitTheApp = "از برنامه خارج می‌شوید؟" + override val yes = "بله" + override val no = "خیر" + override val qualityNotApplicableToRawFormat = "کیفیت اصلی (کیفیت خروجی خام را نمی‌توان تغییر داد)" + override val setClipStart = "تعیین شروع کلیپ" + override val setClipEnd = "تعیین پایان کلیپ" + override val seek5SecondsBackward = "رفتن به ۵ ثانیه قبل" + override val seek5SecondsForward = "رفتن به ۵ ثانیه بعد" + override val seek30SecondsBackward = "رفتن به ۳۰ ثانیه قبل" + override val seek30SecondsForward = "رفتن به ۳۰ ثانیه بعد" + override val livePlayback = "پخش زنده" + override val takeScreenshotAndSaveIn = "گرفتن عکس (S) و ذخیره در:" + override val resumeMediaPlayback = "ادامه پخش" + override val pauseMediaPlayback = "توقف پخش" + override val pinAppWindow = "نمایش روی تمام پنجره‌ها (سنجاق کردن)" + override val unPinAppWindow = "از سنجاق در آوردن برنامه" + override val muteMediaAudio = "قطع صدا" + override val unMuteMediaAudio = "پخش صدا" + override val restoreLastPlaybackSpeed = "تنظیم سرعت پخش به حالت قبل" + override val resetPlaybackSpeedToNormal = "بازنشانی سرعت پخش به عادی" + override val decreasePlaybackSpeed = "کاهش سرعت پخش" + override val increasePlaybackSpeed = "افزایش سرعت پخش" + override val switchToFullscreen = "نمایش در حالت تمام‌صفحه" + override val switchToMiniMode = "نمایش در حالت کوچک" + override val switchToNormalMode = "نمایش در حالت عادی" + override val switchToManualDateInput = "تغییر به حالت دستی" + override val switchToSelectionDateInput = "تغییر به حالت انتخابی" + override val openSourceFolder = "باز کردن پوشه منبع" + override val openSaveFolder = "باز کردن بوشه ذخیره" + override val hideSidePanel = "مخفی کردن پنل کناری" + override val showSidePanel = "نمایش پنل کناری" + override val turnOnClipLoop = "روشن کردن تکرار کلیپ" + override val turnOffClipLoop = "خاموش کردن تکرار کلیپ" + override val shortcut = "میانبر:" + override val and = "و" + override val weekdaySaturday = "شنبه" + override val weekdaySunday = "یکشنبه" + override val weekdayMonday = "دوشنبه" + override val weekdayTuesday ="سه‌شنبه" + override val weekdayWednesday = "چهارشنبه" + override val weekdayThursday = "پنجشنبه" + override val weekdayFriday = "جمعه" + override val cut = "برش" + override val copy = "رونوشت" + override val paste = "چسباندن" + override val selectAll = "انتخاب همه" + override val dlgTitSpecifySaveFile = "ذخیره کلیپ به عنوان..." + override val dlgTitSelectLocalFile = "انتخاب پرونده" + override val dlgTitSelectIntroImage = "انتخاب عکس شروع" + override val dlgTitSelectWatermark = "انتخاب واترمارک" + override val dlgTitSelectAlbumArt = "انتخاب آلبوم‌آرت" + override val dlgTitExistingFile = "پرونده موجود" + override val dlgTitMissingFile = "پرونده ناموجود" + override val dlgTitChangelog = "تغییرات" + override val second = "ث" + override val btnLblOk = "تایید" + override val btnLblOpen = "باز کردن" + override val btnLblCancel = "لغو" + override val btnLblSelectSaveFolder = "ذخیره به عنوان..." + override val btnLblApproveSaveFile = "تایید" + override val btnLblShowChangelog = "تغییرات" + override val btnLblApproveSelectedFile = "انتخاب" + override val btnLblStartConversion = "شروع" + override val btnLblCancelConversion = "لغو" + override val btnLblOpenAppLogFolder = "باز کردن پوشه لاگ برنامه" + override val btnTlpOpenFileChooser = "باز کردن پوشه" + override val btnTlpCancelFileChooser = "صرف نظر از انتخاب پرونده" + override val btnTlpCancelFileSave = "صرف نظر از تعیین پرونده" + override val btnTlpApproveSaveFolder = "انتخاب به عنوان پرونده ذخیره کلیپ" + override val btnTlpApproveSelectedFile = "انتخاب پرونده" + override val btnTlpUpFolder = "پوشه بالا" + override val btnTlpViewMenu = "منوی چینش" + override val btnTlpNewFolder = "ایجاد پوشه جدید" + override val txtLblSourceLocal = "منبع" + override val txtLblToday = "(امروز)" + override val txtLblYesterday = "(دیروز)" + override val txtLblPercentSign = "٪" + override val txtLblSaveIn = ":پوشه" + override val txtLblLookIn = ":پوشه" + override val txtLblClipLength = "طول کلیپ:" + override val txtLblInput = "ورودی" + override val txtLblOutput = "خروجی" + override val txtLblQuality = "کیفیت" + override val txtLblQuality1 = "۱" + override val txtLblQuality2 = "۲" + override val txtLblQuality3 = "۳" + override val txtLblQuality4 = "۴" + override val txtLblQuality5 = "۵" + override val txtLblSelectWatermark = "واترمارک" + override val txtLblSelectIntroImage = "عکس شروع" + override val txtLblSelectAlbumArt = "آلبوم‌آرت" + override val txtLblDragFileHere = "می‌توان کشید اینجا" + override val txtLblLanguage = "زبان (Language):" + override val txtLblCalendar = "گاه‌شماری:" + override val txtLblTheme = "پوسته:" + override val txtLblAspectRatio = "نسبت تصویر:" + override val txtLblFinishSound = "صدای موفقیت:" + override val txtLblScreenshotSound = "صدای دوربین:" + // Other possible labels: + // رفع لرزش کلیپ | ادغام فیلدها | ادغام دوبه‌دو فیلد | پردازش تصویر | تبدیل فیلد به فریم | ادغام فیلد تصویر | رفع همبافتگی + override val txtLblInterlacedFix = "ادغام فیلد تصویر:" + override val txtLblEnabled = "فعال" + override val txtLblDisabled = "غیرفعال" + override val txtLblChangelogFeature = "قابلیت‌های جدید" + override val txtLblChangelogBugFix = "مشکلات رفع شده" + override val txtLblChangelogUpdate = "بروزرسانی‌ها" + override val txtLblChangelogRemoval = "حذف شده‌ها" + override val txtLblChangelogInternal = "تغییرات درونی" + override val txtLblExistingFile = "پرونده از قبل وجود دارد. جایگزین شود؟" + override val txtLblMissingFile = "پرونده وجود ندارد" + override val txtLblFileName = ":نام پرونده" + override val txtLblFileType = ":نوع پرونده" + override val txtLblFileTypeAll = "تمام پرونده‌ها" + override val txtLblFileFormat = ":فرمت پرونده" + override val txtLblAspectRatioSource = "اصلی" + override val txtLblAspectRatio16To9 = "۱۶:۹" + override val txtLblCalendarGregorian = "میلادی" + override val txtLblCalendarSolarHijri = "خورشیدی" // OR more accurately: هجری خورشیدی + override val txtLblThemeLight = "روشن" + override val txtLblThemeDark = "تیره" + override val radLblFormatMp4 = "MP4" + override val radLblFormatMp3 = "MP3" + override val radLblFormatRaw = "خام (اصلی)" + override val radLblLanguagePersian = "فارسی" + override val radLblLanguageEnglish = "English" + override val txtLblClipCreationSuccess = "کلیپ با موفقیت ایجاد شد" + override val txtLblClipCreationFailure = "ایجاد کلیپ ناموفق بود" + override val txtDscScreenshotHelp = "روی دکمه زده و نگه دارید تا پوشه باز شود" + override val txtLblLocalFileSupportedTypesDescription = "\u202Aرسانه (${LocalSourceSupportedFileType.entries.joinToString { it.name }})" + override val txtLblIntroSupportedTypesDescription = "\u202Aعکس (${SupportedImageFormat.entries.joinToString { it.name }})" + override val txtLblCoverSupportedTypesDescription = "\u202Aعکس (${SupportedImageFormat.entries.joinToString { it.name }})" + override val txtLblHasNoDefault = "(بدون پیش‌فرض)" + override val txtLblHasDefault = "(دارای پیش‌فرض)" + override val txtLblAlpha = "آلفا:" + override val txtLblScale = "اندازه:" + override val txtLblMp3IntroNotSupported = "خروجی MP3 از عکس شروع پشتیبانی نمی‌کند" + override val txtLblRawIntroNotSupported = "خروجی خام از عکس شروع پشتیبانی نمی‌کند" + override val txtLblRawCoverNotSupported = "خروجی خام از واترمارک یا آلبوم‌آرت پشتیبانی نمی‌کند" + override val txtLblAudioFileIntroNotSupported = "ورودی صوتی از عکس شروع پشتیبانی نمی‌کند" + override val txtLblImageFileIntroNotSupported = "ورودی عکس از عکس شروع پشتیبانی نمی\u200Cکند" + override val txtLblMiscFileIntroNotSupported = "فرمت ورودی از عکس شروع پشتیبانی نمی‌کند" + override val txtLblAudioFileWatermarkNotSupported = "ورودی صوتی از واترمارک پشتیبانی نمی‌کند" + override val txtLblImageFileWatermarkNotSupported = "ورودی عکس از واترمارک پشتیبانی نمی\u200Cکند" + override val txtLblMiscFileWatermarkNotSupported = "فرمت ورودی از واترمارک پشتیبانی نمی‌کند" + override val txtLblErrorClipNotSet = "کلیپ مشخص نشده است" + override val txtLblErrorClipLengthZero = "طول کلیپ صفر است" + override val txtLblErrorClipLengthNegative = "طول کلیپ منفی است" + override val txtLblErrorClipStartAfterMediaEnd = "شروع کلیپ از طول رسانه بیشتر است" + override val txtLblErrorClipFromImageNotSupported = "ورودی عکس از ایجاد کلیپ پشتیبانی نمی‌کند" + override val txtLblErrorClipFromFormatNotSupported = "فرمت ورودی از ایجاد کلیپ پشتیبانی نمی‌کند" + override val txtLblErrorClipFileNotSet = "پرونده ذخیره مشخص نشده است" + override val txtLblReady = "آماده" + override val txtLblProgressInitializing = "راه‌اندازی..." + override val txtLblProgressCreating = "ایجاد کلیپ" + override val txtLblAboutDeveloper = "توسعه‌دهنده: مهدی حسین‌زاده" + override val txtLblAboutPoweredBy = "با افتخار، قدرت گرفته از نرم‌افزارهای متن‌باز و رایگان" + override val txtLblAboutKotlinLabel = "Kotlin" + override val txtLblAboutKotlinText = " از جت‌برینز به عنوان زبان برنامه‌نویسی" + override val txtLblAboutGradleLabel = "Gradle" + override val txtLblAboutGradleText = " برای ساخت پروژه و گرفتن خروجی" + override val txtLblAboutJetpackComposeLabel = "Jetpack Compose" + override val txtLblAboutJetpackComposeText = " از گوگل برای رابط کاربری" + override val txtLblAboutComposeMultiplatformLabel = "Compose Multiplatform" + override val txtLblAboutComposeMultiplatformText = " از جت‌برینز برای رابط کاربری" + override val txtLblAboutVlcLabel = "libVLC و vlcj" + override val txtLblAboutVlcText = " برای پخش و تعامل با رسانه" + override val txtLblAboutFfmpegLabel = "FFmpeg و JavaCV" + override val txtLblAboutFfmpegText = " برای برش و تبدیل رسانه" + override val txtLblAboutMaterialDesignLabel = "Material design" + override val txtLblAboutMaterialDesignText = " و آیکون‌های متریال از گوگل" + override val txtLblAboutInkscapeLabel = "Inkscape" + override val txtLblAboutInkscapeText = " برای ایجاد آیکون‌ها و گرافیک برداری" + override val txtLblAboutIntellijLabel = "IntelliJ IDEA" + override val txtLblAboutIntellijText = " به عنوان محیط کدنویسی" + override val txtLblAboutGitLabel = "Git" + override val txtLblAboutGitText = " برای کنترل نسخه و پیگیری تغییرات" + override val txtLblAboutGitHubLabel = "GitHub" + override val txtLblAboutGitHubText = " از مایکروسافت برای میزبانی کد" + override val txtLblAboutVazirmatnLabel = "Vazirmatn" + override val txtLblAboutVazirmatnText = " به عنوان قلم برای زبان فارسی" + + override fun totalClipCreationTime(duration: Duration) = "${timeString(duration)} طول کشید" + + private fun timeString(duration: Duration): String { + fun seconds(value: Long) = if (value == 0L) "" else LanguageFa.localizeDigits("$value ثانیه") + fun minutes(value: Long) = if (value == 0L) "" else LanguageFa.localizeDigits("$value دقیقه") + fun hours(value: Long) = if (value == 0L) "" else LanguageFa.localizeDigits("$value ساعت") + return if (duration < 1.seconds) { + "کمتر از یک ثانیه" + } else if (duration < 1.minutes) { + seconds(duration.inWholeSeconds % 60) + } else if (duration < 1.hours) { + buildString { + append(minutes(duration.inWholeMinutes % 60)) + val seconds = seconds(duration.inWholeSeconds % 60) + if (seconds != "") { + append(" و ") + append(seconds) + } + } + } else { + buildString { + append(hours(duration.inWholeHours % 60)) + val minutes = minutes(duration.inWholeMinutes % 60) + if (minutes != "") { + append(" و ") + append(minutes) + } + } + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/logging/LogbackConfig.kt b/src/main/kotlin/ir/mahozad/cutcon/logging/LogbackConfig.kt new file mode 100644 index 0000000..e9e01c0 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/logging/LogbackConfig.kt @@ -0,0 +1,22 @@ +package ir.mahozad.cutcon.logging + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.pattern.color.ANSIConstants +import ch.qos.logback.core.pattern.color.ForegroundCompositeConverterBase + +/** + * See https://stackoverflow.com/a/73338484 + * and the logback.xml file in the classpath. + */ +class CustomLevelHighlighter : ForegroundCompositeConverterBase() { + override fun getForegroundColorCode(event: ILoggingEvent) = + when (event.level) { + Level.ERROR -> ANSIConstants.RED_FG /* + ANSIConstants.BOLD */ + Level.WARN -> ANSIConstants.YELLOW_FG + Level.INFO -> ANSIConstants.BLUE_FG + Level.DEBUG -> ANSIConstants.CYAN_FG + Level.TRACE -> ANSIConstants.GREEN_FG + else -> ANSIConstants.DEFAULT_FG + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/model/Changelog.kt b/src/main/kotlin/ir/mahozad/cutcon/model/Changelog.kt new file mode 100644 index 0000000..afe1bdf --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/model/Changelog.kt @@ -0,0 +1,29 @@ +package ir.mahozad.cutcon.model + +import ir.mahozad.cutcon.localization.Language +import java.time.LocalDate + +data class Changelog(val versions: List) { + operator fun plus(newVersion: ChangelogVersion) = copy(versions = versions + newVersion) + fun replaceLast(newVersion: ChangelogVersion) = copy(versions = versions.dropLast(1) + newVersion) +} + +data class ChangelogVersion(val name: String, val date: LocalDate, val categories: List) { + operator fun plus(newCategory: ChangelogCategory) = copy(categories = categories + newCategory) +} + +data class ChangelogCategory(val type: CategoryType, val entries: List) { + operator fun plus(newEntry: ChangelogEntry) = copy(entries = entries + newEntry) +} + +data class ChangelogEntry(val items: List) { + operator fun plus(newItem: String) = copy(items = items + newItem) +} + +enum class CategoryType(override val label: (Language) -> String) : Labeled { + FEATURE(label = { it.messages.txtLblChangelogFeature }), + BUGFIX(label = { it.messages.txtLblChangelogBugFix }), + UPDATE(label = { it.messages.txtLblChangelogUpdate }), + REMOVAL(label = { it.messages.txtLblChangelogRemoval }), + INTERNAL(label = { it.messages.txtLblChangelogInternal }) +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/model/Exceptions.kt b/src/main/kotlin/ir/mahozad/cutcon/model/Exceptions.kt new file mode 100644 index 0000000..6fe7678 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/model/Exceptions.kt @@ -0,0 +1,5 @@ +package ir.mahozad.cutcon.model + +class FFmpegNonZeroExitCodeException(exitCode: Int?) : Exception("Exit code $exitCode") + +class FFmpegProcessStartFailureException(cause: Throwable) : Exception(cause) diff --git a/src/main/kotlin/ir/mahozad/cutcon/model/Format.kt b/src/main/kotlin/ir/mahozad/cutcon/model/Format.kt new file mode 100644 index 0000000..06811c8 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/model/Format.kt @@ -0,0 +1,57 @@ +package ir.mahozad.cutcon.model + +import androidx.compose.ui.res.loadImageBitmap +import ir.mahozad.cutcon.localization.Language + +/** + * These are the intersection of the formats supported by both FFmpeg and Skia [loadImageBitmap] + * (except the SVG format which we support with a workaround because FFmpeg does not support it universally). + */ +enum class SupportedImageFormat(vararg val extensions: String) { + SVG("svg" /* .svgz is NOT supported by skia (yet) */), + PNG("png"), + JPEG("jpg", "jpeg", "jfif", "jif", "jfi", "jpe"), + WebP("webp"), + GIF("gif"), + BMP("bmp", "dib"), + ICO("ico") +} + +val supportedImageFileExtensions = SupportedImageFormat + .entries + .flatMap { it.extensions.toList() } + .toTypedArray() + +enum class LocalSourceSupportedFileType(vararg val extensions: String) { + MP4("mp4"), + MP3("mp3"), + MKV("mkv"), + TS("ts"), + // We support displaying images but do not want to pollute this with all image types. + // The user can either select all types in the dialog or drag images on the player box. +} + +enum class Format( + override val label: (Language) -> String, + val actualName: (Source) -> String, + val extension: (Source) -> String +) : Labeled { + MP4( + label = { it.messages.radLblFormatMp4 }, + actualName = { "MP4" }, + extension = { "mp4" } + ), + MP3( + label = { it.messages.radLblFormatMp3 }, + actualName = { "MP3" }, + extension = { "mp3" } + ), + /** + * Alternative names: ORIGINAL or SOURCE or COPY + */ + RAW( + label = { it.messages.radLblFormatRaw }, + actualName = Source::formatName, + extension = Source::fileExtension + ) +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/model/Models.kt b/src/main/kotlin/ir/mahozad/cutcon/model/Models.kt new file mode 100644 index 0000000..62ac50d --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/model/Models.kt @@ -0,0 +1,199 @@ +package ir.mahozad.cutcon.model + +import androidx.compose.ui.graphics.Color +import com.github.mfathi91.time.PersianDate +import ir.mahozad.cutcon.detectMimeType +import ir.mahozad.cutcon.localization.Language +import ir.mahozad.cutcon.localization.LanguageFa +import java.net.URL +import java.nio.file.Path +import java.time.LocalDate +import kotlin.io.path.extension +import kotlin.time.Duration + +interface Labeled { + val label: (Language) -> String +} + +data class Progress(val fraction: Float, val length: Duration) { + val time get() = length * fraction.toDouble() + companion object { val ZERO = Progress(0f, Duration.ZERO) } +} + +enum class VersionComparisonResult { SAME, OLDER, NEWER } + +sealed interface Source : Labeled { + val mimeType: String? + val mediaType: MediaType + val formatName: String + val fileExtension: String + + enum class MediaType { VIDEO, AUDIO, IMAGE, UNKNOWN } + + data class Local(val path: Path) : Source { + override val label: (Language) -> String = { it.messages.txtLblSourceLocal } + override val fileExtension = path.extension.lowercase() + override val formatName = path.extension.uppercase() + override val mimeType = path.detectMimeType() + override val mediaType = when { + mimeType?.startsWith("video/") == true -> MediaType.VIDEO + mimeType?.startsWith("audio/") == true -> MediaType.AUDIO + mimeType?.startsWith("image/") == true -> MediaType.IMAGE + else -> MediaType.UNKNOWN + } + } +} + +sealed interface Status { + sealed interface Error : Status { + data object ClipFromImageNotSupported : Error + data object ClipFromFormatNotSupported : Error + data object FileNotSet : Error + data object ClipNotSet : Error + data object ClipLengthZero : Error + data object ClipLengthNegative : Error + data object ClipStartAfterMediaEnd : Error + } + sealed interface Finished : Status { + data class Success(val totalTime: Duration) : Finished + data class Failure(val throwable: Throwable) : Finished + } + data object Ready : Status + data object Initializing : Status + data class InProgress(val source: Source, val progress: Float) : Status +} + +enum class AspectRatio( + val ratio: Float?, + override val label: (Language) -> String +) : Labeled { + W16H9(ratio = 16f / 9f, label = { it.messages.txtLblAspectRatio16To9 }), + SOURCE(ratio = null, label = { it.messages.txtLblAspectRatioSource }) +} + +data class MediaInfo( + val url: URL, + val speed: Speed, + val progress: Progress, + val isResumed: Boolean, + val audioVolume: Float, + val isAudioMuted: Boolean, + val clipToLoop: Clip? +) + +enum class Quality( + override val label: (Language) -> String, + val value: Int +) : Labeled { + LOWEST(label = { it.messages.txtLblQuality1 }, value = 1), + LOW(label = { it.messages.txtLblQuality2 }, value = 2), + MEDIUM(label = { it.messages.txtLblQuality3 }, value = 3), + HIGH(label = { it.messages.txtLblQuality4 }, value = 4), + HIGHEST(label = { it.messages.txtLblQuality5 }, value = 5) +} + +enum class WatermarkPosition { + TOP_LEFT, TOP_MIDDLE, TOP_RIGHT, + CENTER_LEFT, CENTER, CENTER_RIGHT, + BOTTOM_LEFT, BOTTOM_MIDDLE, BOTTOM_RIGHT +} + +data class CoverOptions( + val path: Path?, + val scale: Float, + val opacity: Float, + val position: WatermarkPosition, +) + +data class IntroOptions( + val path: Path?, + val backgroundColor: Color, + val duration: Duration, +) + +data class ConverterFlags( + val isInterlacingFixEnabled: Boolean, + val isVideoAvailableInInput: Boolean = true +) + +data class Clip( + val start: Duration, + val end: Duration +) { + val duration = end - start +} + +enum class Speed(val value: Float) { + SLOW0_5(0.5f), + SLOW0_75(0.75f), + NORMAL(1.0f), + FAST1_5(1.5f), + FAST2_0(2.0f), + FAST2_5(2.5f), + FAST3_0(3.0f); + + operator fun dec(): Speed { + val i = (ordinal - 1).coerceAtLeast(0) + return entries[i] + } + + operator fun inc(): Speed { + val i = (ordinal + 1).coerceAtMost(Speed.entries.lastIndex) + return entries[i] + } +} + +data class DateItem(val date: String, val weekDay: String, val suffix: String?) +data class TimeItem(val time: String) + +enum class Calendar(override val label: (Language) -> String) : Labeled { + /** + * A calendar that is based on the movements of the sun. + * Because it was compiled during the reign of Jalaluddin Malik-Shah I, it is called Jalali calendar. + * Because it is based on the sun, it is also called the (En: Solar)/(Ar: Shamsi)/(Fa: Khorshidi) calendar. + * Its starting date (مبدا) was the year Malik-Shah sat on the throne. + * + * So, Jalali == Solar == Shamsi == Khorshidi. + * + * The calendar in use today in Iran (Solar Hijri/هجری خورشیدی) is Jalali/Solar but with two modifications: + * 1. Its starting date is the Hijrah (the journey of the prophet Muhammad and his followers from Mecca to Medina) + * 2. It has leap years (the original Jalali/Solar had variable months so, it did not need adjusting) + * + * All the above calendars are a type of broader category called Iranian (Persian) calendars. + */ + SOLAR_HIJRI({ it.messages.txtLblCalendarSolarHijri }) { + override fun format(date: LocalDate, language: Language): String { + return PersianDate + .fromGregorian(date) + .toString() + .let(language::localizeDigits) + .replace("-", if (language is LanguageFa) "/" else "-") + } + }, + + /** + * The most widely used calendar around the world today. + * + * In Iran, it is called "میلادی". + */ + GREGORIAN({ it.messages.txtLblCalendarGregorian }) { + override fun format(date: LocalDate, language: Language): String { + return date + .toString() + .let(language::localizeDigits) + .replace("-", if (language is LanguageFa) "/" else "-") + } + }; + + abstract fun format(date: LocalDate, language: Language): String +} + +enum class Theme(override val label: (Language) -> String) : Labeled { + LIGHT({ it.messages.txtLblThemeLight }), + DARK({ it.messages.txtLblThemeDark }) +} + +enum class Toggle(override val label: (Language) -> String) : Labeled { + ENABLED({ it.messages.txtLblEnabled }), + DISABLED({ it.messages.txtLblDisabled }) +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/model/Prefereces.kt b/src/main/kotlin/ir/mahozad/cutcon/model/Prefereces.kt new file mode 100644 index 0000000..105af4a --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/model/Prefereces.kt @@ -0,0 +1,14 @@ +package ir.mahozad.cutcon.model + +object PreferenceKeys { + const val PREF_THEME = "theme" + const val PREF_LANGUAGE = "language" + const val PREF_CALENDAR = "calendar" + const val PREF_ASPECT_RATIO = "aspect-ratio" + const val PREF_FINISH_SOUND = "finish-sound" + const val PREF_INTERLACED_FIX = "interlaced-fix" + const val PREF_SCREENSHOT_SOUND = "screenshot-sound" + const val PREF_LAST_OPEN_DIRECTORY = "last-open-directory" + const val PREF_LAST_SAVE_DIRECTORY = "last-save-directory" + const val PREF_LAST_SHOWN_CHANGELOG_VERSION = "last-shown-changelog-version" +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/model/Shortcut.kt b/src/main/kotlin/ir/mahozad/cutcon/model/Shortcut.kt new file mode 100644 index 0000000..4918cbe --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/model/Shortcut.kt @@ -0,0 +1,32 @@ +package ir.mahozad.cutcon.model + +import androidx.compose.ui.input.key.Key + +enum class Shortcut( + val keys: List, + val symbol: String +) { + SEEK_SHORT_BACKWARD( symbol = "\uD83E\uDC04", keys = listOf(Key.DirectionLeft)), + SEEK_SHORT_FORWARD( symbol = "\uD83E\uDC06", keys = listOf(Key.DirectionRight)), + SEEK_LONG_BACKWARD( symbol = "Page Up", keys = listOf(Key.PageUp)), + SEEK_LONG_FORWARD( symbol = "Page Down", keys = listOf(Key.PageDown)), + FULLSCREEN_TOGGLE( symbol = "F", keys = listOf(Key.F)), + FULLSCREEN_EXIT( symbol = "Esc", keys = listOf(Key.Escape)), + MINI_MODE_TOGGLE( symbol = ".", keys = listOf(Key.Period)), + SIDE_PANEL_TOGGLE( symbol = "/", keys = listOf(Key.Slash)), + CLIP_LOOP_TOGGLE( symbol = "R", keys = listOf(Key.R)), + CLIP_START_BEGINNING(symbol = "0", keys = listOf(Key.Zero, Key.NumPad0)), + CLIP_START_NOW( symbol = "1", keys = listOf(Key.One, Key.NumPad1)), + CLIP_END_NOW( symbol = "2", keys = listOf(Key.Two, Key.NumPad2)), + CLIP_END_FINISH( symbol = "3", keys = listOf(Key.Three, Key.NumPad3)), + SPEED_RESET( symbol = "*", keys = listOf(Key.Multiply, Key.NumPadMultiply)), + SPEED_DECREASE( symbol = "-", keys = listOf(Key.Minus, Key.NumPadSubtract)), + SPEED_INCREASE( symbol = "+", keys = listOf(Key.Plus, Key.NumPadAdd)), + AUDIO_MUTE_TOGGLE( symbol = "M", keys = listOf(Key.M)), + AUDIO_DECREASE( symbol = "\uD83E\uDC05", keys = listOf(Key.DirectionDown)), + AUDIO_INCREASE( symbol = "\uD83E\uDC07", keys = listOf(Key.DirectionUp)), + LIVE_PLAY( symbol = "L", keys = listOf(Key.L)), + PLAY_PAUSE( symbol = "Space", keys = listOf(Key.Spacebar)), + PIN_TOGGLE( symbol = "P", keys = listOf(Key.P)), + SCREENSHOT_TAKE( symbol = "S", keys = listOf(Key.S)) +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/Decorations.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/Decorations.kt new file mode 100644 index 0000000..a33e6b2 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/Decorations.kt @@ -0,0 +1,331 @@ +package ir.mahozad.cutcon.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.window.WindowDraggableArea +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowScope +import com.sun.jna.Native +import com.sun.jna.NativeLibrary +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.platform.win32.BaseTSD.LONG_PTR +import com.sun.jna.platform.win32.User32 +import com.sun.jna.platform.win32.WinDef.* +import com.sun.jna.platform.win32.WinUser.* +import com.sun.jna.win32.W32APIOptions +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.ui.icon.Close +import ir.mahozad.cutcon.ui.icon.Icons +import ir.mahozad.cutcon.ui.icon.Minimize + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun WindowScope.WindowDecoration( + isDecorationVisible: Boolean, + icon: Painter, + title: @Composable (defaultFontSize: TextUnit) -> Unit, + isMinimizable: Boolean, + onCloseRequest: () -> Unit, + content: @Composable () -> Unit +) { + val windowHandle = remember(window) { + val windowPointer = (window as? ComposeWindow) + ?.windowHandle + ?.let(::Pointer) + ?: Native.getWindowPointer(window) + HWND(windowPointer) + } + remember(windowHandle) { CustomWindowProcessor(windowHandle) } + // For rounded corners, the window transparent should be set to true which requires + // window undecorated to be set to true as well which causes the workaround for + // window shadow and minimize/restore/close animations to not work anymore. + Surface(modifier = Modifier.fillMaxHeight() /* Ensures the content fills the whole window height */) { + Column { + if (isDecorationVisible) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + WindowDraggableArea(modifier = Modifier.fillMaxWidth().height(30.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxHeight() + ) { + Spacer(Modifier.width(8.dp)) + Image( + painter = icon, + contentDescription = Messages.ICO_DSC_TITLE_BAR_ICON, + modifier = Modifier.height(20.dp) + ) + Spacer(Modifier.width(4.dp)) + title(defaultFontSize) + } + Row { + if (isMinimizable && window is ComposeWindow) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .width(48.dp) + .fillMaxHeight() + .clickable { + // See the code below for why + User32.INSTANCE.CloseWindow(windowHandle) + } + ) { + Icon( + imageVector = Icons.Custom.Minimize, + contentDescription = Messages.ICO_DSC_MINIMIZE, + modifier = Modifier.size(12.dp) + ) + } + } + var isHovered by remember { mutableStateOf(false) } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .width(48.dp) + .onPointerEvent(PointerEventType.Enter, onEvent = { isHovered = true }) + .onPointerEvent(PointerEventType.Exit, onEvent = { isHovered = false }) + .background(if (isHovered) Color(0xffc42b1c) else Color(0x00c42b1c)) + .fillMaxHeight() + .clickable(onClick = onCloseRequest) + ) { + Icon( + imageVector = Icons.Custom.Close, + contentDescription = Messages.ICO_DSC_CLOSE, + modifier = Modifier.size(14.dp), + tint = if (isHovered) Color.White else MaterialTheme.colors.onSurface + ) + } + } + } + } + } + } + content() + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun DialogDecoration( + icon: Painter, + title: @Composable (defaultFontSize: TextUnit) -> Unit, + modifier: Modifier, + onCloseRequest: () -> Unit, + content: @Composable () -> Unit +) { + Surface( + modifier = modifier + .border(Dp.Hairline, Color.Gray, RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) + ) { + Column { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth().height(30.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxHeight() + ) { + Spacer(Modifier.width(8.dp)) + Image( + painter = icon, + contentDescription = Messages.ICO_DSC_TITLE_BAR_ICON, + modifier = Modifier.height(20.dp) + ) + Spacer(Modifier.width(4.dp)) + title(defaultFontSize) + } + var isHovered by remember { mutableStateOf(false) } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .width(48.dp) + .onPointerEvent(PointerEventType.Enter, onEvent = { isHovered = true }) + .onPointerEvent(PointerEventType.Exit, onEvent = { isHovered = false }) + .background(if (isHovered) Color(0xffc42b1c) else Color(0x00c42b1c)) + .fillMaxHeight() + .clickable(onClick = onCloseRequest) + ) { + Icon( + imageVector = Icons.Custom.Close, + contentDescription = Messages.ICO_DSC_CLOSE, + modifier = Modifier.size(14.dp), + tint = if (isHovered) Color.White else MaterialTheme.colors.onSurface + ) + } + } + } + content() + } + } +} + +////////////////////////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////////////////////////// +// These are to retain the Windows minimize, restore, and close animations for the application window. +// See https://github.com/JetBrains/compose-multiplatform/issues/3388 +// and https://github.com/JetBrains/jewel/pull/173 +// and https://github.com/kalibetre/CustomDecoratedJFrame +// and https://gist.github.com/Guerra24/429de6cadda9318b030a7d12d0ad58d4 +// and https://medium.com/@kalbetre/customizing-the-title-bar-of-an-application-window-50a4ac3ed27e +// and https://github.com/JetBrains/compose-multiplatform/issues/3295#issuecomment-1668607200 +// and https://learn.microsoft.com/en-us/windows/win32/api/uxtheme/ns-uxtheme-margins +// and https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/nf-dwmapi-dwmextendframeintoclientarea +// and https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-showwindow +// and https://learn.microsoft.com/en-us/windows/win32/dwm/customframe +// and https://learn.microsoft.com/en-us/windows/apps/develop/title-bar +// and https://github.com/deimos1877/BorderlessWindow +// and https://github.com/java-native-access/jna/issues/1430 +// and https://stackoverflow.com/q/16765561 +// and https://stackoverflow.com/q/1742218 +// and https://stackoverflow.com/q/3979800 +// and https://stackoverflow.com/q/62374427 +// and https://bugs.openjdk.org/browse/JDK-8211907 +// and https://bugs.openjdk.org/browse/JDK-8037575 +// and https://github.com/jphp-group/jphp-gui-win-helpers-ext/blob/master/src-jvm/main/java/net/rebzzel/jphp/windows/extender/classes/Ext4JphpWindows.java +// and https://github.com/ocornut/imgui/issues/5315#issue-1236695428 +// and https://github.com/ocornut/imgui/issues/5315 +// and https://stackoverflow.com/q/22165258 +// and https://stackoverflow.com/q/51008461 +// and https://stackoverflow.com/q/34638183 +// and https://stackoverflow.com/a/18522099 +// and https://stackoverflow.com/a/27254024 +// and https://stackoverflow.com/a/76366710 +// Instead of (window as? ComposeWindow)?.isMinimized = true call the following to preserve the animation +// Requires the following dependencies: +// implementation("net.java.dev.jna:jna-jpms:5.13.0") +// implementation("net.java.dev.jna:jna-platform-jpms:5.13.0") +////////////////////////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////////////////////////// + +@Suppress("FunctionName") +private interface User32Ex : User32 { + fun SetWindowLong(hWnd: HWND, nIndex: Int, wndProc: WindowProc): LONG_PTR + fun SetWindowLong(hWnd: HWND, nIndex: Int, wndProc: LONG_PTR): LONG_PTR + fun SetWindowLongPtr(hWnd: HWND, nIndex: Int, wndProc: WindowProc): LONG_PTR + fun SetWindowLongPtr(hWnd: HWND, nIndex: Int, wndProc: LONG_PTR): LONG_PTR + fun CallWindowProc(proc: LONG_PTR, hWnd: HWND, uMsg: Int, uParam: WPARAM, lParam: LPARAM): LRESULT +} + +private class CustomWindowProcessor(private val windowHandle: HWND) : WindowProc { + + private val logger = logger(name = CustomWindowProcessor::class.simpleName ?: "") + // See https://learn.microsoft.com/en-us/windows/win32/winmsg/about-messages-and-message-queues#system-defined-messages + private val WM_NCCALCSIZE = 0x0083 + private val WM_NCHITTEST = 0x0084 + private val GWLP_WNDPROC = -4 + private val USER32EX_INSTANCE = runCatching { Native.load("user32", User32Ex::class.java, W32APIOptions.DEFAULT_OPTIONS) } + .onFailure { logger.warn(it) { "Could not load user32 library" } } + .getOrNull() + private var DEF_WND_PROC = if (is64Bit()) { + USER32EX_INSTANCE?.SetWindowLongPtr(windowHandle, GWLP_WNDPROC, this) ?: LONG_PTR(-1) + } else { + USER32EX_INSTANCE?.SetWindowLong(windowHandle, GWLP_WNDPROC, this) ?: LONG_PTR(-1) + }.also { + enableBorderAndShadow() + // enableTransparency(alpha = 255.toByte()) + } + + override fun callback(hWnd: HWND, uMsg: Int, wParam: WPARAM, lParam: LPARAM): LRESULT { + return when (uMsg) { + WM_NCCALCSIZE -> { LRESULT(0) } + WM_NCHITTEST -> { USER32EX_INSTANCE?.CallWindowProc(DEF_WND_PROC, hWnd, uMsg, wParam, lParam) ?: LRESULT(0) } + WM_DESTROY -> { + if (is64Bit()) { + USER32EX_INSTANCE?.SetWindowLongPtr(hWnd, GWLP_WNDPROC, DEF_WND_PROC) + } else { + USER32EX_INSTANCE?.SetWindowLong(hWnd, GWLP_WNDPROC, DEF_WND_PROC) + } + LRESULT(0) + } + else -> { USER32EX_INSTANCE?.CallWindowProc(DEF_WND_PROC, hWnd, uMsg, wParam, lParam) ?: LRESULT(0) } + } + } + + private fun enableBorderAndShadow() { + // Set margins to (0, 0, 0, 0) to disable the border and shadow + // (or, simply, don't call the enableBorderAndShadow function) + val margins = WindowMargins( + leftBorderWidth = 0, + topBorderHeight = 0, + rightBorderWidth = -1, + bottomBorderHeight = -1 + ) + val dwmApi = "dwmapi" + .runCatching(NativeLibrary::getInstance) + .onFailure { logger.warn(it) { "Could not load dwmapi library" } } + .getOrNull() + // dwmApi + // ?.runCatching { getFunction("DwmSetWindowAttribute") } + // ?.getOrNull() + // ?.invoke(arrayOf(windowHandle, 2, IntByReference(2), 4)) + dwmApi + ?.runCatching { getFunction("DwmExtendFrameIntoClientArea") } + ?.onFailure { logger.warn(it) { "Could not enable window native decorations (border/shadow/rounded corners)" } } + ?.getOrNull() + ?.invoke(arrayOf(windowHandle, margins)) + } + + /** + * See https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setlayeredwindowattributes + * @param alpha 0 for transparent, 255 for opaque + */ + private fun enableTransparency(alpha: Byte) { + val defaultStyle = User32.INSTANCE.GetWindowLong(windowHandle, GWL_EXSTYLE) + val newStyle = defaultStyle or User32.WS_EX_LAYERED + USER32EX_INSTANCE?.SetWindowLong(windowHandle, User32.GWL_EXSTYLE, newStyle) + USER32EX_INSTANCE?.SetLayeredWindowAttributes(windowHandle, 0, alpha, LWA_ALPHA) + } + + private fun is64Bit(): Boolean { + val bitMode = System.getProperty("com.ibm.vm.bitmode") + val model = System.getProperty("sun.arch.data.model", bitMode) + return model == "64" + } +} + +// See https://stackoverflow.com/q/62240901 +@Structure.FieldOrder( + "leftBorderWidth", + "rightBorderWidth", + "topBorderHeight", + "bottomBorderHeight" +) +data class WindowMargins( + @JvmField var leftBorderWidth: Int, + @JvmField var rightBorderWidth: Int, + @JvmField var topBorderHeight: Int, + @JvmField var bottomBorderHeight: Int +) : Structure(), Structure.ByReference diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/ErrorWindow.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/ErrorWindow.kt new file mode 100644 index 0000000..3bcd517 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/ErrorWindow.kt @@ -0,0 +1,87 @@ +package ir.mahozad.cutcon.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.singleWindowApplication +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.localization.Language +import ir.mahozad.cutcon.model.Theme +import ir.mahozad.cutcon.openAppLogFolder +import ir.mahozad.cutcon.ui.theme.AppTheme +import ir.mahozad.cutcon.ui.widget.StackTrace +import ir.mahozad.cutcon.viewModel +import kotlin.system.exitProcess + +fun errorWindow(throwable: Throwable?) { + val theme = viewModel.theme.value + val language = viewModel.language.value + singleWindowApplication( + alwaysOnTop = true, + resizable = false, + title = language.messages.appName, + state = WindowState( + width = 464.dp, + height = 424.dp, + position = WindowPosition(Alignment.Center) + ) + ) { + AppTheme(isDark = theme == Theme.DARK) { + WindowDecoration( + isDecorationVisible = true, + icon = painterResource("logo.svg"), + title = { Title(language, it) }, + isMinimizable = false, + onCloseRequest = { exitProcess(status = 1) } + ) { + CompositionLocalProvider(LocalLayoutDirection provides language.layoutDirection) { + Column( + horizontalAlignment = Alignment.Start, + modifier = Modifier.padding(16.dp) + ) { + Prompt(language) + Spacer(Modifier.height(8.dp)) + StackTrace(throwable) + } + } + } + } + } +} + +@Composable +private fun Prompt(language: Language) { + val scope = rememberCoroutineScope() + Row(verticalAlignment = Alignment.CenterVertically) { + Button(onClick = scope::openAppLogFolder) { + Text( + text = language.messages.openLogFolder, + fontSize = defaultFontSize + ) + } + Spacer(Modifier.width(8.dp)) + Text( + text = language.messages.error, + fontSize = defaultFontSize + ) + } +} + +@Composable +private fun Title(language: Language, fontSize: TextUnit) { + Text( + text = language.messages.appName, + fontSize = fontSize + ) +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/MainWindow.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/MainWindow.kt new file mode 100644 index 0000000..238df2a --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/MainWindow.kt @@ -0,0 +1,230 @@ +package ir.mahozad.cutcon.ui + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.* +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalLocalization +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.* +import androidx.compose.ui.window.FrameWindowScope +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.rememberWindowState +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import ir.mahozad.cutcon.* +import ir.mahozad.cutcon.localization.LanguageFa +import ir.mahozad.cutcon.model.Status +import ir.mahozad.cutcon.model.Theme +import ir.mahozad.cutcon.ui.dialog.AppExitConfirmDialog +import ir.mahozad.cutcon.ui.dialog.ChangelogDialog +import ir.mahozad.cutcon.ui.dialog.FailureDialog +import ir.mahozad.cutcon.ui.dialog.SuccessDialog +import ir.mahozad.cutcon.ui.panel.MainPanel +import ir.mahozad.cutcon.ui.panel.SidePanel +import ir.mahozad.cutcon.ui.theme.AppTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import java.awt.Taskbar +import javax.sound.sampled.AudioSystem +import kotlin.io.path.div +import kotlin.math.roundToInt + +private val logger = logger(name = "MainWindow") +private val taskbar by lazy { + runCatching(Taskbar::getTaskbar) + .onFailure { logger.warn(it) { "Getting OS taskbar failed" } } + .onSuccess { logger.debug { "Got the OS taskbar instance" } } + .getOrNull() +} + +@Composable +fun MainWindow(onExitRequest: () -> Unit) { + val theme by viewModel.theme.collectAsState() + val language by viewModel.language.collectAsState() + val calendar by viewModel.calendar.collectAsState() + val isAlwaysOnTop by viewModel.isAlwaysOnTop.collectAsState() + val isFullscreen by viewModel.isFullscreen.collectAsState() + val windowWidth by viewModel.windowWidth.collectAsState() + val windowHeight by viewModel.windowHeight.collectAsState() + val windowPosition by viewModel.windowPosition.collectAsState() + val windowState = rememberWindowState( + placement = WindowPlacement.Floating, + position = windowPosition, + size = DpSize(windowWidth.dp, windowHeight.dp) + ) + LaunchedEffect(Unit) { + snapshotFlow { windowState.position } + .onEach(viewModel::onWindowPositionChanged) + .launchIn(this) + } + LaunchedEffect(windowWidth, windowHeight, windowPosition, isFullscreen) { + windowState.size = DpSize(windowWidth.dp, windowHeight.dp) + windowState.position = windowPosition + windowState.placement = if (isFullscreen) WindowPlacement.Fullscreen else WindowPlacement.Floating + } + Window( + title = language.messages.appName, // Is used for app title in taskbar + icon = painterResource("logo.svg"), // Is used for app icon in taskbar + state = windowState, + resizable = false, + undecorated = false, // See window decoration component for more information + transparent = false, // See window decoration component for more information + alwaysOnTop = isAlwaysOnTop, + // Called when clicking on close button on app taskbar preview + onCloseRequest = { viewModel.onAppExitRequest(forceExit = false, onExitRequest) }, + onKeyEvent = { + viewModel.onKeyboardEvent(it) + false // Does not matter here + } + ) { + AppTheme(isDark = theme == Theme.DARK) { + CompositionLocalProvider( + LocalLanguage provides language, + LocalCalendar provides calendar, + LocalTextStyle provides LocalTextStyle.current.copy(fontFamily = language.fontFamily), + LocalLocalization provides language.contextMenuLocalization, + LocalLayoutDirection provides LayoutDirection.Ltr + ) { + Taskbar() + Dialogs { viewModel.onAppExitRequest(forceExit = true, exit = onExitRequest) } + WindowDecoration( + isDecorationVisible = !isFullscreen, + title = { WindowTitle(it) }, + icon = painterResource("logo.svg"), + isMinimizable = true, + onCloseRequest = { viewModel.onAppExitRequest(forceExit = false, exit = onExitRequest) } + ) { + MainContent() + } + } + } + } +} + +@Composable +private fun FrameWindowScope.Taskbar() { + val status by viewModel.status.collectAsState() + LaunchedEffect(status) { + val state = when (status) { + is Status.Initializing -> Taskbar.State.INDETERMINATE + is Status.InProgress -> Taskbar.State.NORMAL + else -> Taskbar.State.OFF + } + val value = (status as? Status.InProgress)?.progress ?: 0f + taskbar?.setWindowProgressValue(window, (value * 100).roundToInt()) + taskbar?.setWindowProgressState(window, state) + } +} + +@Composable +private fun WindowTitle(defaultFontSize: TextUnit) { + val language = LocalLanguage.current + Text( + text = language.messages.appName, + fontSize = defaultFontSize, + modifier = Modifier.offset(y = if (language is LanguageFa) (-1).dp else 0.dp) + ) +} + +/** + * See https://github.com/JetBrains/compose-multiplatform/issues/2481 + * and https://github.com/JetBrains/compose-multiplatform/issues/865 + * and https://plugins.jetbrains.com/plugin/16541-compose-multiplatform-ide-support/versions/snapshots + * + * FIXME: Preview does not work + */ +@Preview +@Composable +private fun MainContentPreview() { + val fakeWindowScope = object : FrameWindowScope { + override val window get() = TODO("Not implemented") // OR ComposeWindow() + } + with(fakeWindowScope) { MainContent() } +} + +/** + * To access the current window, the function extends on [FrameWindowScope]. + * We can also pass the window as an argument to the function. + * + * In previous versions of Compose JB/Desktop/Multiplatform accessing + * the window was done using `LocalAppWindow.current.window`. + * + * See https://github.com/JetBrains/compose-multiplatform/issues/176#issuecomment-812514936 + * and its subsequent comments. + */ +@Composable +private fun FrameWindowScope.MainContent() { + val isFullscreen by viewModel.isFullscreen.collectAsState() + val isSidePanelDisplayed by viewModel.isSidePanelDisplayed.collectAsState() + Row( + modifier = Modifier.padding(horizontal = if (isFullscreen) 0.dp else 8.dp), + horizontalArrangement = Arrangement.Absolute.spacedBy(8.dp) + ) { + MainPanel() + if (isSidePanelDisplayed) { + SidePanel() + } + } +} + +/** + * Note: The code to react upon change of a dialog display property and show the dialog should only be in a single place + * (for example, here) to avoid multiple pieces of code from showing multiple instances of a dialog simultaneously when + * the property becomes true. + * + * For example, in version 1.11.0 of the app, when the ShowChangelog button in about screen was clicked, the + * [MainViewModel.isChangelogDialogDisplayed] became true and the if statement here showed the dialog. But, there was + * also an if statement in about panel code itself that also showed the dialog. + * So, all in all, two dialogs were displayed simultaneously on top of each other. + * This was evident in the darker dialog scrim because of the two overlapping each other. + */ +@Composable +private fun Dialogs(onCloseRequest: () -> Unit) { + val status by viewModel.status.collectAsState() + val language = LocalLanguage.current + val isFinishSoundEnabled by viewModel.isFinishSoundEnabled.collectAsState() + val isChangelogDialogDisplayed by viewModel.isChangelogDialogDisplayed.collectAsState() + val isAppExitConfirmDialogDisplayed by viewModel.isAppExitConfirmDialogDisplayed.collectAsState() + LaunchedEffect(status, isFinishSoundEnabled) { + if (status is Status.Finished.Success && isFinishSoundEnabled) { + withContext(Dispatchers.IO) { + val soundPath = assetsPath / "notification.wav" + val audioStream = AudioSystem.getAudioInputStream(soundPath.toFile()) + val audioClip = AudioSystem.getClip() + audioClip.open(audioStream) + audioClip.start() + } + } + } + // This is needed because we have set the default parent layout direction to LTR + CompositionLocalProvider(LocalLayoutDirection provides language.layoutDirection) { + if (isChangelogDialogDisplayed) { + ChangelogDialog( + changelog = language.messages.changelog, + onCloseRequest = viewModel::onChangelogDialogDismissRequest + ) + } else if (isAppExitConfirmDialogDisplayed) { + AppExitConfirmDialog( + onDenied = viewModel::onAppExitConfirmDialogDismissRequest, + onConfirmed = onCloseRequest + ) + } else if (status is Status.Finished.Success) { + SuccessDialog( + totalTime = (status as? Status.Finished.Success)?.totalTime, + onCloseRequest = viewModel::onFinishDialogDismissRequest + ) + } else if (status is Status.Finished.Failure) { + FailureDialog( + throwable = (status as? Status.Finished.Failure)?.throwable, + onCloseRequest = viewModel::onFinishDialogDismissRequest + ) + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/AppExitConfirmDialog.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/AppExitConfirmDialog.kt new file mode 100644 index 0000000..0f28006 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/AppExitConfirmDialog.kt @@ -0,0 +1,75 @@ +package ir.mahozad.cutcon.ui.dialog + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.* +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.localization.LanguageEn +import ir.mahozad.cutcon.localization.LanguageFa +import ir.mahozad.cutcon.ui.DialogDecoration +import ir.mahozad.cutcon.ui.theme.AppTheme + +@Preview +@Composable +private fun AppExitConfirmDialogPreviewFa() { + CompositionLocalProvider(LocalLanguage provides LanguageFa) { + AppTheme { + AppExitConfirmDialog({}, {}) + } + } +} + +@Preview +@Composable +private fun AppExitConfirmDialogPreviewEn() { + CompositionLocalProvider(LocalLanguage provides LanguageEn) { + AppTheme { + AppExitConfirmDialog({}, {}) + } + } +} + +@Composable +fun AppExitConfirmDialog(onDenied: () -> Unit, onConfirmed: () -> Unit) { + val language = LocalLanguage.current + Dialog(/* Called when clicking outside the dialog: */ onDismissRequest = onDenied) { + DialogDecoration( + icon = painterResource("logo.svg"), + title = { Text(text = language.messages.appName, fontSize = (defaultFontSize.value - 1).sp) }, + modifier = Modifier.size(width = 360.dp, height = 146.dp), + onCloseRequest = onDenied + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier.fillMaxHeight().padding(horizontal = 16.dp) + ) { + Spacer(Modifier.height(10.dp)) + Column(modifier = Modifier.height(36.dp)) { + Text(text = language.messages.clipCreationIsAbandonedIfExitTheApp, fontSize = defaultFontSize) + Text(text = language.messages.areYouSureToExitTheApp, fontSize = defaultFontSize) + } + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + Button(onClick = onDenied, modifier = Modifier.width(64.dp)) { + Text(text = language.messages.no, fontSize = defaultFontSize) + } + Spacer(Modifier.width(48.dp)) + Button(onClick = onConfirmed, modifier = Modifier.width(64.dp)) { + Text(text = language.messages.yes, fontSize = defaultFontSize) + } + } + } + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/ChangelogDialog.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/ChangelogDialog.kt new file mode 100644 index 0000000..0cd5172 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/ChangelogDialog.kt @@ -0,0 +1,179 @@ +package ir.mahozad.cutcon.ui.dialog + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.LocalScrollbarStyle +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import ir.mahozad.cutcon.LocalCalendar +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.defaultIconSize +import ir.mahozad.cutcon.localization.LanguageEn +import ir.mahozad.cutcon.localization.LanguageFa +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.model.* +import ir.mahozad.cutcon.ui.DialogDecoration +import ir.mahozad.cutcon.ui.icon.* +import ir.mahozad.cutcon.ui.theme.AppTheme + +@Preview +@Composable +private fun ChangelogDialogPreviewFa() { + CompositionLocalProvider(LocalLanguage provides LanguageFa) { + AppTheme { + ChangelogDialog( + changelog = LanguageFa.messages.changelog, + onCloseRequest = {} + ) + } + } +} + +@Preview +@Composable +private fun ChangelogDialogPreviewEn() { + CompositionLocalProvider(LocalLanguage provides LanguageEn) { + AppTheme { + ChangelogDialog( + changelog = LanguageEn.messages.changelog, + onCloseRequest = {} + ) + } + } +} + +@Composable +fun ChangelogDialog(changelog: Changelog, onCloseRequest: () -> Unit) { + val language = LocalLanguage.current + Dialog(/* Called when clicking outside the dialog: */ onDismissRequest = onCloseRequest) { + DialogDecoration( + icon = painterResource("logo.svg"), + title = { Text(text = language.messages.dlgTitChangelog, fontSize = (defaultFontSize.value - 1).sp) }, + modifier = Modifier.size(424.dp, 380.dp), + onCloseRequest = onCloseRequest + ) { + Box(modifier = Modifier.padding(bottom = 16.dp, start = 8.dp, end = 8.dp)) { + val state = rememberLazyListState() + LazyColumn( + state = state, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + items( + key = { changelog.versions[it].name }, + count = changelog.versions.size, + itemContent = { ChangelogVersion(changelog.versions[it]) } + ) + } + VerticalScrollbar( + style = LocalScrollbarStyle.current.copy(minimalHeight = 64.dp), + adapter = rememberScrollbarAdapter(scrollState = state), + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + ) + } + } + } +} + +@Composable +private fun ChangelogVersion(version: ChangelogVersion) { + val language = LocalLanguage.current + val calendar = LocalCalendar.current + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Divider(Modifier.weight(1f)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(50)) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text(text = language.messages.versionPrefix, fontSize = 14.sp) + Spacer(Modifier.width(if (language.messages.versionPrefix.length > 1) 4.dp else 0.dp)) + Text(text = language.localizeDigits(version.name), fontSize = 14.sp) + Spacer(Modifier.width(4.dp)) + Text( + text = "(${calendar.format(version.date, language)})", + fontSize = 14.sp + ) + } + Divider(Modifier.weight(1f)) + } + Spacer(Modifier.height(8.dp)) + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + version.categories.forEach { + ChangelogCategory(it) + } + } + } +} + +@Composable +private fun ChangelogCategory(category: ChangelogCategory) { + val language = LocalLanguage.current + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp) + ) { + Icon( + imageVector = when (category.type) { + CategoryType.FEATURE -> Icons.Custom.Star + CategoryType.BUGFIX -> Icons.Custom.Bug + CategoryType.UPDATE -> Icons.Custom.Wrench + CategoryType.REMOVAL -> Icons.Custom.Clean + CategoryType.INTERNAL -> Icons.Custom.Commit + }, + modifier = Modifier.size(defaultIconSize), + contentDescription = Messages.ICO_DSC_CHANGELOG_CATEGORY + ) + Spacer(Modifier.width(2.dp)) + Text(text = category.type.label(language), fontSize = 14.sp, color = MaterialTheme.colors.primary) + } + Spacer(Modifier.height(2.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + category.entries.forEach { + ChangelogEntry(it) + } + } + } +} + +@Composable +private fun ChangelogEntry(entry: ChangelogEntry) { + Row( + // Uses top alignment so the bullet is shown properly for multiline entries as well + verticalAlignment = Alignment.Top, + modifier = Modifier.padding(start = 8.dp) + ) { + Icon( + imageVector = Icons.Custom.Bullet, + modifier = Modifier.padding(top = if (LocalLanguage.current is LanguageFa) 5.dp else 4.dp).size(5.dp), + contentDescription = Messages.ICO_DSC_CHANGELOG_ENTRY + ) + Spacer(Modifier.width(4.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + entry.items.forEach { + Text(text = it, fontSize = (defaultFontSize.value - 1).sp) + } + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/FinishDilaogs.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/FinishDilaogs.kt new file mode 100644 index 0000000..e682160 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/FinishDilaogs.kt @@ -0,0 +1,165 @@ +package ir.mahozad.cutcon.ui.dialog + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.localization.LanguageEn +import ir.mahozad.cutcon.localization.LanguageFa +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.success +import ir.mahozad.cutcon.ui.DialogDecoration +import ir.mahozad.cutcon.ui.icon.CheckMark +import ir.mahozad.cutcon.ui.icon.Icons +import ir.mahozad.cutcon.ui.icon.Warn +import ir.mahozad.cutcon.ui.theme.AppTheme +import ir.mahozad.cutcon.ui.widget.StackTrace +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +@Preview +@Composable +private fun SuccessDialogPreviewFa() { + CompositionLocalProvider(LocalLanguage provides LanguageFa) { + AppTheme { + SuccessDialog( + totalTime = 173.minutes, + onCloseRequest = {} + ) + } + } +} + +@Preview +@Composable +private fun SuccessDialogPreviewEn() { + CompositionLocalProvider(LocalLanguage provides LanguageEn) { + AppTheme { + SuccessDialog( + totalTime = 173.minutes, + onCloseRequest = {} + ) + } + } +} + +@Preview +@Composable +private fun FailureDialogPreviewFa() { + CompositionLocalProvider(LocalLanguage provides LanguageFa) { + AppTheme { + FailureDialog( + throwable = IllegalArgumentException(), + onCloseRequest = {} + ) + } + } +} + +@Preview +@Composable +private fun FailureDialogPreviewEn() { + CompositionLocalProvider(LocalLanguage provides LanguageEn) { + AppTheme { + FailureDialog( + throwable = IllegalArgumentException(), + onCloseRequest = {} + ) + } + } +} + +@Composable +fun SuccessDialog(totalTime: Duration?, onCloseRequest: () -> Unit) { + val language = LocalLanguage.current + FinishDialog( + modifier = Modifier.size(width = 230.dp, height = 96.dp), + onCloseRequest = onCloseRequest + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Custom.CheckMark, + contentDescription = Messages.ICO_DSC_SUCCESS, + tint = MaterialTheme.colors.success + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = language.messages.txtLblClipCreationSuccess, fontSize = 14.sp) + } + Row { + Spacer(Modifier.width(28.dp)) + totalTime?.let { + Text( + text = "(${language.messages.totalClipCreationTime(totalTime)})", + fontSize = 12.sp + ) + } + } + } + } +} + +@Composable +fun FailureDialog(throwable: Throwable?, onCloseRequest: () -> Unit) { + FinishDialog( + modifier = Modifier.size(width = 420.dp, height = 380.dp), + onCloseRequest = onCloseRequest + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Custom.Warn, + contentDescription = Messages.ICO_DSC_FAILURE, + tint = MaterialTheme.colors.error + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = LocalLanguage.current.messages.txtLblClipCreationFailure, + fontSize = 14.sp + ) + } + Box(modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp)) { + StackTrace(throwable) + } + } + } +} + +@Composable +private fun FinishDialog( + modifier: Modifier, + onCloseRequest: () -> Unit, + content: @Composable () -> Unit +) { + val language = LocalLanguage.current + // See https://github.com/JetBrains/compose-multiplatform/issues/3438 + Dialog(/* Called when clicking outside the dialog: */ onDismissRequest = onCloseRequest) { + DialogDecoration( + icon = painterResource("logo.svg"), + title = { Text(text = language.messages.appName, fontSize = (defaultFontSize.value - 1).sp) }, + modifier = modifier, + onCloseRequest = onCloseRequest + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier.fillMaxHeight().padding(horizontal = 8.dp) + ) { + Spacer(Modifier.height(10.dp)) + content() + Spacer(Modifier.height(10.dp)) + } + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/OpenFileDialog.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/OpenFileDialog.kt new file mode 100644 index 0000000..507535f --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/OpenFileDialog.kt @@ -0,0 +1,193 @@ +package ir.mahozad.cutcon.ui.dialog + +import androidx.compose.ui.awt.ComposeWindow +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import ir.mahozad.cutcon.localization.Language +import java.awt.FileDialog +import java.nio.file.Path +import javax.swing.JFileChooser +import javax.swing.JOptionPane +import javax.swing.UIManager +import javax.swing.filechooser.FileNameExtensionFilter + +private val logger = logger(name = "OpenFileDialog") + +/** + * Note that [FileDialog] shows the native operating system chooser while the [JFileChooser] + * shows the Java custom dialog (which can be made to *look like* the OS dialog by setting its look and feel), + * but even then, its layout, labels, and options are not exactly the same as the native one. + * FileDialog and JFileChooser have these differences: + * - FileDialog layout, labels, and options is the same as all applications that show the OS native file chooser + * - FileDialog buttons and labels (except dialog title) cannot be localized + * - FileDialog file filtering does not work on Windows as of Java 17 + * - FileDialog automatically remembers last opened directory even after app restart + * - FileDialog shows an old look and feel for buttons (when running the installed app exe) + * - FileDialog is displayed a little faster + * - FileDialog prompts for replace confirmation if choosing an existing file for save, + * but this has to be implemented manually in JFileChooser + * - The files and directories in FileDialog can be renamed and deleted + * (especially useful for manipulating a directory created in the dialog itself), + * but it does not seem to be possible in JFileChooser (unless, possibly, through manual implementation). + * + * ```kotlin + * val dialog = FileDialog(this /* OR null */).apply { + * setTitle(title) + * mode = FileDialog.SAVE + * file = startingDirectory?.let(defaultFileNameProvider) + * directory = startingDirectory?.toString() + * filenameFilter = FilenameFilter { _, name -> name.lowercase().endsWith(formatExtension) } + * isVisible = true + * } + * val result = dialog.directory?.let(::Path)?.resolve(dialog.file) + * logger.debug { "File chooser returned ${result?.toLtrString()}" } + * return result + * ``` + * + * See https://github.com/JetBrains/compose-multiplatform/issues/176 + */ +fun ComposeWindow.showOpenFileDialog( + language: Language, + title: String, + startingDirectory: Path?, + approveButtonLabel: String, + approveButtonTooltip: String, + fileExtensionDescription: String, + vararg fileExtensions: String +): Path? { + // NOTE: Make sure UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()) + // is called at the start of the program to get OS native look and feel + // + // See https://stackoverflow.com/q/4850315 + // and https://stackoverflow.com/q/16941623 + // and https://stackoverflow.com/a/29086587 + // + // "FileChooser.acceptAllFileFilterText" + // "FileChooser.ancestorInputMap" + // "FileChooser.byDateText" + // "FileChooser.byNameText" + // "FileChooser.cancelButtonMnemonic" + // "FileChooser.cancelButtonText" + // "FileChooser.chooseButtonText" + // "FileChooser.createButtonText" + // "FileChooser.desktopName" + // "FileChooser.detailsViewIcon" // For example: ImageIcon(FileSystem.class.getResource("folder.png")) + // "FileChooser.directoryDescriptionText" + // "FileChooser.directoryOpenButtonMnemonic" + // "FileChooser.directoryOpenButtonText" + // "FileChooser.fileDescriptionText" + // "FileChooser.fileNameLabelMnemonic" + // "FileChooser.fileNameLabelText" + // "FileChooser.openButtonToolTipText" + // "FileChooser.cancelButtonToolTipText" + // "FileChooser.upFolderToolTipText" + // "FileChooser.homeFolderToolTipText" + // "FileChooser.listViewButtonToolTipText" + // "FileChooser.fileNameHeaderText" + // "FileChooser.fileSizeGigaBytes" + // "FileChooser.fileSizeKiloBytes" + // "FileChooser.fileSizeMegaBytes" + // "FileChooser.filesOfTypeLabelMnemonic" + // "FileChooser.filesOfTypeLabelText" + // "FileChooser.helpButtonMnemonic" + // "FileChooser.helpButtonText" + // "FileChooser.homeFolderIcon" + // "FileChooser.listViewIcon" + // "FileChooser.saveInLabelText" + // "FileChooser.lookInLabelText" + // "FileChooser.lookInLabelMnemonic" + // "FileChooser.mac.newFolder" + // "FileChooser.mac.newFolder.subsequent" + // "FileChooser.newFolderAccessibleName" + // "FileChooser.newFolderButtonText" + // "FileChooser.newFolderErrorSeparator" + // "FileChooser.newFolderErrorText" + // "FileChooser.newFolderExistsErrorText" + // "FileChooser.newFolderIcon" + // "FileChooser.newFolderPromptText" + // "FileChooser.newFolderTitleText" + // "FileChooser.newFolderToolTipText" + // "FileChooser.acceptAllFileFilterText" + // "FileChooser.renameFileButtonText" + // "FileChooser.deleteFileButtonText" + // "FileChooser.filterLabelText" + // "FileChooser.detailsViewButtonToolTipText" + // "FileChooser.detailsViewButtonAccessibleName" + // "FileChooser.detailsViewActionLabel.textAndMnemonic" + // "FileChooser.listViewButtonToolTipText" + // "FileChooser.listViewButtonAccessibleName" + // "FileChooser.viewMenuButtonToolTipText" + // "FileChooser.fileSizeHeaderText" + // "FileChooser.fileDateHeaderText" + // "FileChooser.openButtonMnemonic + // "FileChooser.openButtonText" + // "FileChooser.openDialogTitleText" + // "FileChooser.openTitleText" + // "FileChooser.readOnly" + // "FileChooser.saveButtonMnemonic" + // "FileChooser.saveButtonText" + // "FileChooser.saveDialogFileNameLabelText" + // "FileChooser.saveDialogTitleText" + // "FileChooser.saveTitleText" + // "FileChooser.untitledFileName" + // "FileChooser.untitledFolderName" + // "FileChooser.upFolderIcon" + // "FileChooser.updateButtonMnemonic" + // "FileChooser.updateButtonText" + // "FileChooser.useSystemExtensionHiding" + // "FileChooser.usesSingleFilePane" + // + // Sets look and feel of file choosers to the native OS look and feel + // See https://stackoverflow.com/q/10083447 + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()) + UIManager.put("FileChooser.filesOfTypeLabelText", language.messages.txtLblFileType) + UIManager.put("FileChooser.fileNameLabelText", language.messages.txtLblFileName) + UIManager.put("FileChooser.lookInLabelText", language.messages.txtLblLookIn) + UIManager.put("FileChooser.cancelButtonText", language.messages.btnLblCancel) + UIManager.put("FileChooser.cancelButtonToolTipText", language.messages.btnTlpCancelFileChooser) + UIManager.put("FileChooser.acceptAllFileFilterText", language.messages.txtLblFileTypeAll) + UIManager.put("FileChooser.upFolderToolTipText", language.messages.btnTlpUpFolder) + UIManager.put("FileChooser.newFolderToolTipText", language.messages.btnTlpNewFolder) + UIManager.put("FileChooser.viewMenuButtonToolTipText", language.messages.btnTlpViewMenu) + // This is shown when clicking on a directory in the file chooser + UIManager.put("FileChooser.directoryOpenButtonText", language.messages.btnLblOpen) + UIManager.put("FileChooser.directoryOpenButtonToolTipText", language.messages.btnTlpOpenFileChooser) + // UIManager.put("FileChooser.saveButtonText", "Select") + val fileChooser = OpenFileDialog(language).apply { + // See https://stackoverflow.com/q/6724784 + // applyComponentOrientation(ComponentOrientation.RIGHT_TO_LEFT) + currentDirectory = startingDirectory?.toFile() + if (fileExtensions.isNotEmpty()) { + fileFilter = FileNameExtensionFilter(fileExtensionDescription, *fileExtensions) + } + isMultiSelectionEnabled = false + fileSelectionMode = JFileChooser.FILES_ONLY + dialogTitle = title + approveButtonText = approveButtonLabel + approveButtonToolTipText = approveButtonTooltip + } + val result = fileChooser.showOpenDialog(this /* OR null */) + logger.debug { "File chooser returned with result code $result" } + return if (result == JFileChooser.APPROVE_OPTION) fileChooser.selectedFile.toPath() else null +} + +private class OpenFileDialog( + private val language: Language +) : JFileChooser() { + override fun approveSelection() { + if (selectedFile.exists()) { + super.approveSelection() + } else { + // Could also have used showMessageDialog if localizing the ok button text wasn't needed + JOptionPane.showOptionDialog( + this, + language.messages.txtLblMissingFile, + language.messages.dlgTitMissingFile, + JOptionPane.OK_OPTION, + JOptionPane.WARNING_MESSAGE, + null, + arrayOf(language.messages.btnLblOk), + language.messages.btnLblOk + ) + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/SaveFileDialog.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/SaveFileDialog.kt new file mode 100644 index 0000000..c453a7e --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/dialog/SaveFileDialog.kt @@ -0,0 +1,109 @@ +package ir.mahozad.cutcon.ui.dialog + +import androidx.compose.ui.awt.ComposeWindow +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import ir.mahozad.cutcon.localization.Language +import java.awt.FileDialog +import java.nio.file.Path +import javax.swing.JFileChooser +import javax.swing.JOptionPane +import javax.swing.UIManager +import javax.swing.filechooser.FileNameExtensionFilter +import kotlin.io.path.Path +import kotlin.io.path.div +import kotlin.io.path.exists + +private val logger = logger(name = "SaveFileDialog") + +/** + * To see the difference between [FileDialog] and [JFileChooser], see [showOpenFileDialog]. + * + * To change the input file name based on file names in the new directory + * when user changes directory in the dialog, see the below code (and https://stackoverflow.com/a/28407071): + * + * ```kotlin + * fileChooser.addPropertyChangeListener(JFileChooser.DIRECTORY_CHANGED_PROPERTY) { + * val oldName = fileChooser.selectedFile?.name + * val newDirectory = fileChooser.currentDirectory.toPath() + * fileChooser.selectedFile = Path(defaultFileNameProvider(oldName, newDirectory)).toFile() + * } + *``` + */ +fun ComposeWindow.showSaveFileDialog( + language: Language, + title: String, + formatName: String, + formatExtension: String, + startingDirectory: Path?, + approveButtonLabel: String, + approveButtonTooltip: String, + defaultFileNameProvider: (Path) -> String +): Path? { + // Sets look and feel of file choosers to the native OS look and feel + // See https://stackoverflow.com/q/10083447 + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()) + UIManager.put("FileChooser.filesOfTypeLabelText", language.messages.txtLblFileFormat) + UIManager.put("FileChooser.fileNameLabelText", language.messages.txtLblFileName) + UIManager.put("FileChooser.cancelButtonText", language.messages.btnLblCancel) + UIManager.put("FileChooser.cancelButtonToolTipText", language.messages.btnTlpCancelFileSave) + UIManager.put("FileChooser.saveInLabelText", language.messages.txtLblSaveIn) + UIManager.put("FileChooser.upFolderToolTipText", language.messages.btnTlpUpFolder) + UIManager.put("FileChooser.newFolderToolTipText", language.messages.btnTlpNewFolder) + UIManager.put("FileChooser.viewMenuButtonToolTipText", language.messages.btnTlpViewMenu) + // This is shown when clicking on a directory in the file chooser + UIManager.put("FileChooser.directoryOpenButtonText", language.messages.btnLblOpen) + UIManager.put("FileChooser.directoryOpenButtonToolTipText", language.messages.btnTlpOpenFileChooser) + val fileChooser = SaveFileDialog(formatExtension, language).apply { + currentDirectory = startingDirectory?.toFile() + selectedFile = Path(defaultFileNameProvider(currentDirectory.toPath())).toFile() + fileFilter = FileNameExtensionFilter(formatName, formatExtension) + isAcceptAllFileFilterUsed = false + dialogType = JFileChooser.SAVE_DIALOG + dialogTitle = title + approveButtonText = approveButtonLabel + approveButtonToolTipText = approveButtonTooltip + } + val result = fileChooser.showSaveDialog(this /* OR null */) + logger.debug { "File chooser returned with result code $result" } + return if (result == JFileChooser.APPROVE_OPTION) fileChooser.selectedFile.toPath() else null +} + +private class SaveFileDialog( + private val fileExtension: String, + private val language: Language +) : JFileChooser() { + // To approve user file selection in the dialog, + // see https://stackoverflow.com/q/28637921 + // and https://stackoverflow.com/q/18926629 + // and https://stackoverflow.com/a/3729157 + override fun approveSelection() { + // User may enter just the name of an existing file without extension + // so here we normalize name + // NOTE: This is related to the code in MainViewModel::setSaveFile + val file = if (selectedFile.name.endsWith(".$fileExtension", ignoreCase = true)) { + selectedFile.toPath().parent / "${selectedFile.nameWithoutExtension}.$fileExtension" + } else { + selectedFile.toPath().parent / "${selectedFile.name}.$fileExtension" + } + // On Windows using NTFS, file name and extension are NOT case-sensitive + // so the following check ignores case (which is our desired logic). + // See https://stackoverflow.com/a/34717571 + if (file.exists()) { + // Could also have used showConfirmDialog if localizing the button texts wasn't needed + val overwritePromptResult = JOptionPane.showOptionDialog( + this, + language.messages.txtLblExistingFile, + language.messages.dlgTitExistingFile, + JOptionPane.YES_NO_OPTION, // OR YES_NO_CANCEL_OPTION + JOptionPane.WARNING_MESSAGE, + null, + arrayOf(language.messages.btnLblOk, language.messages.btnLblCancel), + language.messages.btnLblCancel + ) + if (overwritePromptResult != JOptionPane.YES_OPTION) { + return + } + } + super.approveSelection() + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/ArrowDown.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/ArrowDown.kt new file mode 100644 index 0000000..45df8d4 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/ArrowDown.kt @@ -0,0 +1,44 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.ArrowDown, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.ArrowDown: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.ArrowDown") { + path(fill = SolidColor(defaultIconColor)) { + moveTo(2.0f, 8.0f) + horizontalLineTo(22f) + lineTo(12f, 18f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/AspectRatio.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/AspectRatio.kt new file mode 100644 index 0000000..2bfacf6 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/AspectRatio.kt @@ -0,0 +1,67 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.AspectRatio, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.AspectRatio: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.AspectRatio") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(4.0f, 20.0f) + lineToRelative(0.0f, -16.0f) + lineToRelative(16.0f, -0.0f) + lineToRelative(0.0f, 16.0f) + close() + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(12.0f, 8.0f) + horizontalLineTo(8.0f) + verticalLineToRelative(4.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(16.0f, 12.0f) + verticalLineToRelative(4.0f) + horizontalLineToRelative(-4.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Base.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Base.kt new file mode 100644 index 0000000..d6588bd --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Base.kt @@ -0,0 +1,5 @@ +package ir.mahozad.cutcon.ui.icon + +object Icons { + object Custom +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Bug.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Bug.kt new file mode 100644 index 0000000..aae67c7 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Bug.kt @@ -0,0 +1,134 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Bug, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Bug: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Bug") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(17.0f, 11.0f) + verticalLineToRelative(4.0f) + moveToRelative(-10.0f, -4.0f) + arcToRelative(5.0f, 5.0f, 0.0f, false, true, 5.0f, -5.0f) + arcToRelative(5.0f, 5.0f, 0.0f, false, true, 5.0f, 5.0f) + moveToRelative(-10.0f, 4.0f) + arcToRelative(5.0f, 5.0f, 0.0f, false, false, 5.0f, 5.0f) + arcToRelative(5.0f, 5.0f, 0.0f, false, false, 5.0f, -5.0f) + moveToRelative(-10.0f, -4.0f) + verticalLineToRelative(4.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(10.0f, 11.0f) + horizontalLineToRelative(4.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(10.0f, 15.0f) + horizontalLineToRelative(4.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(17.0f, 13.0f) + horizontalLineToRelative(3.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(4.0f, 13.0f) + lineTo(7.0f, 13.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(16.0f, 18.0f) + horizontalLineToRelative(4.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(4.0f, 18.0f) + lineTo(8.0f, 18.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(16.0f, 8.0f) + horizontalLineToRelative(4.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(4.0f, 8.0f) + lineTo(8.0f, 8.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(11.0f, 6.0f) + lineToRelative(-3.0f, -3.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(13.0f, 6.0f) + lineToRelative(3.0f, -3.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Bullet.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Bullet.kt new file mode 100644 index 0000000..f4add3e --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Bullet.kt @@ -0,0 +1,44 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Bullet, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Bullet: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Bullet") { + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 12.0f) + moveToRelative(-12.0f, 0.0f) + arcToRelative(12.0f, 12.0f, 0.0f, true, true, 24.0f, 0.0f) + arcToRelative(12.0f, 12.0f, 0.0f, true, true, -24.0f, 0.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/CameraFill.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/CameraFill.kt new file mode 100644 index 0000000..2418182 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/CameraFill.kt @@ -0,0 +1,80 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathNode +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.CameraFill, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.CameraFill: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.CameraFill") { + group( + clipPathData = listOf( + PathNode.MoveTo(0.0f, 0.0f), + PathNode.RelativeVerticalTo(24.0f), + PathNode.RelativeHorizontalTo(24.0f), + PathNode.RelativeVerticalTo(-24.0f), + PathNode.RelativeHorizontalTo(-24.0f), + PathNode.Close, + PathNode.MoveTo(12.0f, 8.0f), + PathNode.RelativeCurveTo(2.2f, 0.0f, 4.0f, 1.8f, 4.0f, 4.0f), + PathNode.RelativeReflectiveCurveTo(-1.8f, 4.0f, -4.0f, 4.0f), + PathNode.RelativeReflectiveCurveTo(-4.0f, -1.8f, -4.0f, -4.0f), + PathNode.RelativeReflectiveCurveTo(1.8f, -4.0f, 4.0f, -4.0f), + PathNode.Close, + PathNode.MoveTo(12.0f, 10.0f), + PathNode.RelativeCurveTo(-1.12f, 0.0f, -2.0f, 0.884f, -2.0f, 2.0f), + PathNode.RelativeReflectiveCurveTo(0.884f, 2.0f, 2.0f, 2.0f), + PathNode.RelativeReflectiveCurveTo(2.0f, -0.884f, 2.0f, -2.0f), + PathNode.RelativeReflectiveCurveTo(-0.884f, -2.0f, -2.0f, -2.0f), + PathNode.Close + ) + ) { + path( + fill = SolidColor(defaultIconColor), + stroke = SolidColor(defaultIconColor), + pathFillType = PathFillType.EvenOdd, + strokeLineWidth = 2.0f + ) { + moveToRelative(3.0f, 19.0f) + verticalLineToRelative(-13.0f) + horizontalLineToRelative(4.0f) + lineToRelative(2.0f, -3.0f) + horizontalLineToRelative(6.0f) + lineToRelative(2.0f, 3.0f) + horizontalLineToRelative(4.0f) + verticalLineToRelative(13.0f) + close() + } + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/CameraOutline.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/CameraOutline.kt new file mode 100644 index 0000000..499de74 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/CameraOutline.kt @@ -0,0 +1,71 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.CameraOutline, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.CameraOutline: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.CameraOutline") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(12.0f, 3.0f) + horizontalLineToRelative(-3.0f) + lineToRelative(-2.0f, 3.0f) + horizontalLineToRelative(-4.0f) + verticalLineToRelative(13.0f) + horizontalLineToRelative(9.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(12.0f, 3.0f) + horizontalLineToRelative(3.0f) + lineToRelative(2.0f, 3.0f) + horizontalLineToRelative(4.0f) + verticalLineToRelative(13.0f) + horizontalLineToRelative(-9.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(9.0f, 12.0f) + arcToRelative(3.0f, 3.0f, 0.0f, true, true, 6.0f, 0.0f) + arcToRelative(3.0f, 3.0f, 0.0f, true, true, -6.0f, 0.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/CheckMark.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/CheckMark.kt new file mode 100644 index 0000000..44ed8e1 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/CheckMark.kt @@ -0,0 +1,47 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.CheckMark, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.CheckMark: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.CheckMark") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(3.0f, 11.0f) + lineTo(9f, 17f) + lineTo(21f, 5f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Clean.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Clean.kt new file mode 100644 index 0000000..7f0fc09 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Clean.kt @@ -0,0 +1,91 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Clean, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Clean: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Clean") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(10.0f, 5.0f) + arcToRelative(2.0f, 2.0f, 0.0f, false, true, 2.0f, -2.0f) + arcToRelative(2.0f, 2.0f, 0.0f, false, true, 2.0f, 2.0f) + moveToRelative(-4.0f, 0.0f) + verticalLineToRelative(5.0f) + lineTo(6.0f, 10.0f) + verticalLineToRelative(4.0f) + lineTo(18.0f, 14.0f) + verticalLineToRelative(-4.0f) + horizontalLineToRelative(-4.0f) + verticalLineToRelative(-5.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(6.0f, 13.0f) + lineToRelative(-2.0f, 5.0f) + verticalLineToRelative(2.0f) + lineTo(20.0f, 20.0f) + verticalLineToRelative(-2.0f) + lineToRelative(-2.0f, -5.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(12.0f, 20.0f) + verticalLineToRelative(-3.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(8.0f, 20.0f) + verticalLineToRelative(-3.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(16.0f, 20.0f) + verticalLineToRelative(-3.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Close.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Close.kt new file mode 100644 index 0000000..0036810 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Close.kt @@ -0,0 +1,54 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Close, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Close: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Close") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(20.0f, 4.0f) + lineTo(4.0f, 20.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(4.0f, 4.0f) + lineTo(20.0f, 20.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Commit.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Commit.kt new file mode 100644 index 0000000..6f3942c --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Commit.kt @@ -0,0 +1,64 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Commit, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Commit: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Commit") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(12.0f, 12.0f) + moveToRelative(-4.0f, 0.0f) + arcToRelative(4.0f, 4.0f, 0.0f, true, true, 8.0f, 0.0f) + arcToRelative(4.0f, 4.0f, 0.0f, true, true, -8.0f, 0.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(16.0f, 12.0f) + horizontalLineToRelative(6.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(8.0f, 12.0f) + lineTo(2.0f, 12.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Config.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Config.kt new file mode 100644 index 0000000..7effbc2 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Config.kt @@ -0,0 +1,86 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Config, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Config: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Config") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(4.0f, 6.0f) + horizontalLineToRelative(16.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(4.0f, 18.0f) + horizontalLineToRelative(16.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(4.0f, 12.0f) + horizontalLineToRelative(16.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(15.0f, 3.0f) + verticalLineToRelative(6.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(9.0f, 9.0f) + verticalLineToRelative(6.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(15.0f, 15.0f) + verticalLineToRelative(6.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Cover.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Cover.kt new file mode 100644 index 0000000..ae0230a --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Cover.kt @@ -0,0 +1,119 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Cover, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Cover: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Cover") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(12.0f, 9.0f) + verticalLineToRelative(-3.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(9.8787f, 9.8787f) + lineToRelative(-2.1213f, -2.1213f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(9.0f, 12.0f) + lineTo(6.0f, 12.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(9.8787f, 14.1213f) + lineToRelative(-2.1213f, 2.1213f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(12.0f, 15.0f) + verticalLineToRelative(3.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(14.1213f, 14.1213f) + lineToRelative(2.1213f, 2.1213f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(15.0f, 12.0f) + horizontalLineToRelative(3.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(14.1213f, 9.8787f) + lineToRelative(2.1213f, -2.1213f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 12.0f) + moveToRelative(-1.75f, 0.0f) + arcToRelative(1.75f, 1.75f, 0.0f, true, true, 3.5f, 0.0f) + arcToRelative(1.75f, 1.75f, 0.0f, true, true, -3.5f, 0.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(3.0f, 3.0f) + horizontalLineToRelative(18.0f) + verticalLineToRelative(18.0f) + horizontalLineToRelative(-18.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Curtain.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Curtain.kt new file mode 100644 index 0000000..00800f1 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Curtain.kt @@ -0,0 +1,97 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Curtain, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Curtain: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Curtain") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(3.0f, 3.0f) + horizontalLineToRelative(18.0f) + verticalLineToRelative(18.0f) + horizontalLineToRelative(-18.0f) + close() + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(8.0f, 3.0f) + curveTo(8.0f, 9.0f, 7.0f, 10.0f, 4.0f, 13.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(12.0f, 3.0f) + curveToRelative(0.0f, 8.0f, 3.0f, 10.0f, 9.0f, 10.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(16.0f, 3.0f) + curveToRelative(0.0f, 6.0f, 1.0f, 7.0f, 4.0f, 10.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(12.0f, 3.0f) + curveTo(12.0f, 11.0f, 9.0f, 13.0f, 3.0f, 13.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(3.0f, 13.0f) + curveToRelative(4.0f, 0.0f, 4.0f, 5.0f, 4.0f, 8.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(21.0f, 13.0f) + curveToRelative(-4.0f, 0.0f, -4.0f, 5.0f, -4.0f, 8.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Date.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Date.kt new file mode 100644 index 0000000..68cb481 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Date.kt @@ -0,0 +1,114 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Date, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Date: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Date") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(20.0f, 5.0f) + verticalLineTo(20.0f) + horizontalLineTo(4.0f) + verticalLineTo(5.0f) + close() + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(8.0f, 2.0f) + verticalLineTo(5.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(16.0f, 2.0f) + verticalLineTo(5.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(7.0f, 10.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + horizontalLineTo(7.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(11.0f, 10.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(-2.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(15.0f, 10.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(-2.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(4.0f, 5.0f) + verticalLineTo(7.0f) + horizontalLineTo(20.0f) + verticalLineTo(5.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(7.0f, 14.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + horizontalLineTo(7.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(11.0f, 14.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(-2.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(15.0f, 14.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(-2.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/DateEnter.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/DateEnter.kt new file mode 100644 index 0000000..11b80e6 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/DateEnter.kt @@ -0,0 +1,93 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.DateEnter, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.DateEnter: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.DateEnter") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(20.0f, 5.0f) + verticalLineTo(20.0f) + horizontalLineTo(4.0f) + verticalLineTo(5.0f) + close() + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(8.0f, 2.0f) + verticalLineTo(5.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(16.0f, 2.0f) + verticalLineTo(5.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(7.0f, 12.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + horizontalLineTo(7.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(11.0f, 12.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(-2.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(15.0f, 12.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(-2.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(4.0f, 5.0f) + verticalLineTo(7.0f) + horizontalLineTo(20.0f) + verticalLineTo(5.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/DateSelect.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/DateSelect.kt new file mode 100644 index 0000000..cf34ecf --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/DateSelect.kt @@ -0,0 +1,86 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.DateSelect, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.DateSelect: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.DateSelect") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(20.0f, 5.0f) + verticalLineTo(20.0f) + horizontalLineTo(4.0f) + verticalLineTo(5.0f) + close() + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(8.0f, 2.0f) + verticalLineTo(5.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(16.0f, 2.0f) + verticalLineTo(5.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(8.0f, 12.0f) + horizontalLineToRelative(8.0f) + verticalLineTo(10.0f) + horizontalLineTo(8.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(4.0f, 5.0f) + verticalLineTo(7.0f) + horizontalLineTo(20.0f) + verticalLineTo(5.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(8.0f, 16.0f) + horizontalLineToRelative(8.0f) + verticalLineTo(14.0f) + horizontalLineTo(8.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Delete.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Delete.kt new file mode 100644 index 0000000..fd0a053 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Delete.kt @@ -0,0 +1,81 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Delete, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Delete: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Delete") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(6.0f, 5.0f) + verticalLineToRelative(16.0f) + horizontalLineToRelative(12.0f) + verticalLineToRelative(-16.0f) + close() + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(10.0f, 8.0f) + verticalLineToRelative(10.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(14.0f, 8.0f) + verticalLineToRelative(10.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(4.0f, 5.0f) + lineTo(20.0f, 5.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(9.0f, 4.0f) + horizontalLineToRelative(6.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/EndCircle.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/EndCircle.kt new file mode 100644 index 0000000..d4b51f9 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/EndCircle.kt @@ -0,0 +1,57 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.EndCircle, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.EndCircle: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.EndCircle") { + path( + name = "circle", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(8.0f, 12.0f) + arcToRelative(6.0f, 6.0f, 0.0f, true, true, 12.0f, 0.0f) + arcToRelative(6.0f, 6.0f, 0.0f, true, true, -12.0f, 0.0f) + } + path( + name = "line", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(0.0f, 12.0f) + horizontalLineTo(9.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Folder.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Folder.kt new file mode 100644 index 0000000..5fe4a6c --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Folder.kt @@ -0,0 +1,59 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Folder, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Folder: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Folder") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(3.0f, 4.0f) + verticalLineToRelative(15.0f) + horizontalLineToRelative(18.0f) + verticalLineToRelative(-13.0f) + horizontalLineToRelative(-9.0f) + lineToRelative(-2.0f, -2.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(3.0f, 7.0f) + horizontalLineToRelative(9.0f) + verticalLineToRelative(-2.0f) + lineToRelative(-2.0f, -1.0f) + lineTo(3.0f, 4.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Forward30En.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Forward30En.kt new file mode 100644 index 0000000..1d68fed --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Forward30En.kt @@ -0,0 +1,75 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Forward30En, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Forward30En: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Forward30En") { + path( + name = "plus", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(4f, 9f) + verticalLineToRelative(6f) + moveToRelative(-3f, -3f) + horizontalLineToRelative(6f) + } + path( + name = "three", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(13f, 12f) + verticalLineToRelative(4f) + horizontalLineToRelative(-4f) + moveToRelative(0f, -8f) + horizontalLineToRelative(4f) + verticalLineToRelative(4f) + horizontalLineToRelative(-4f) + } + path( + name = "zero", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(20f, 16f) + verticalLineToRelative(-8f) + horizontalLineToRelative(-3f) + verticalLineToRelative(8f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Forward30Fa.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Forward30Fa.kt new file mode 100644 index 0000000..5ed405f --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Forward30Fa.kt @@ -0,0 +1,79 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Forward30Fa, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Forward30Fa: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Forward30Fa") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(6.0f, 9.0f) + verticalLineToRelative(6.0f) + moveTo(3.0f, 12.0f) + horizontalLineToRelative(6.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(11.0f, 6.0f) + lineTo(11.0f, 17.0f) + moveTo(19.0f, 6.0f) + verticalLineToRelative(4.0f) + lineTo(15.0f, 10.0f) + lineTo(15.0f, 6.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(19.0f, 16.0f) + verticalLineToRelative(-2.0f) + horizontalLineToRelative(-2.0f) + verticalLineToRelative(2.0f) + close() + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(14.0f, 10.0f) + horizontalLineTo(11.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Forward5En.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Forward5En.kt new file mode 100644 index 0000000..0cbf610 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Forward5En.kt @@ -0,0 +1,62 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Forward5En, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Forward5En: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Forward5En") { + path( + name = "plus", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(4f, 12f) + horizontalLineToRelative(6f) + moveTo(7f, 9f) + verticalLineToRelative(6f) + } + path( + name = "five", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(18f, 8f) + horizontalLineToRelative(-5f) + verticalLineToRelative(4f) + horizontalLineToRelative(4f) + verticalLineToRelative(4f) + horizontalLineToRelative(-5f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Forward5Fa.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Forward5Fa.kt new file mode 100644 index 0000000..57f5535 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Forward5Fa.kt @@ -0,0 +1,85 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Forward5Fa, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Forward5Fa: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Forward5Fa") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(2.0f, 13.0f) + horizontalLineToRelative(6.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(5.0f, 10.0f) + verticalLineToRelative(6.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(14.0f, 8.0f) + lineToRelative(-3.0f, 5.0f) + lineToRelative(-1.0f, 2.0f) + lineToRelative(1.0f, 2.0f) + horizontalLineToRelative(3.0f) + verticalLineToRelative(-3.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(14.0f, 8.0f) + lineToRelative(3.0f, 5.0f) + lineToRelative(1.0f, 2.0f) + lineToRelative(-1.0f, 2.0f) + lineToRelative(-3.0f, 0.0f) + lineToRelative(-0.0f, -3.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(14.0f, 6.0586f) + lineTo(12.834f, 8.002f) + lineTo(14.0f, 9.9434f) + lineTo(15.166f, 8.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/FullScreenEnter.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/FullScreenEnter.kt new file mode 100644 index 0000000..a4463d3 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/FullScreenEnter.kt @@ -0,0 +1,94 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.FullScreenEnter, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.FullScreenEnter: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.FullScreenEnter") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(4.0f, 9.0f) + verticalLineTo(4.0f) + horizontalLineToRelative(5.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(4.0f, 15.0f) + verticalLineToRelative(5.0f) + horizontalLineToRelative(5.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(20.0f, 15.0f) + verticalLineToRelative(5.0f) + horizontalLineToRelative(-5.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(20.0f, 9.0f) + verticalLineTo(4.0f) + horizontalLineToRelative(-5.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(4.0f, 4.0f) + lineToRelative(6.0f, 6.0f) + moveToRelative(4.0f, 4.0f) + lineToRelative(6.0f, 6.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(20.0f, 4.0f) + lineToRelative(-6.0f, 6.0f) + moveToRelative(-4.0f, 4.0f) + lineToRelative(-6.0f, 6.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Glob.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Glob.kt new file mode 100644 index 0000000..e0de03a --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Glob.kt @@ -0,0 +1,80 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Glob, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Glob: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Glob") { + path( + fill = null, + stroke = SolidColor(Color(0xFF000000)), + strokeLineWidth = 2.0f + ) { + moveTo(12.0f, 12.0f) + moveToRelative(-9.0f, 0.0f) + arcToRelative(9.0f, 9.0f, 0.0f, true, true, 18.0f, 0.0f) + arcToRelative(9.0f, 9.0f, 0.0f, true, true, -18.0f, 0.0f) + } + path( + fill = null, + stroke = SolidColor(Color(0xFF000000)), + strokeLineWidth = 2.0f + ) { + moveTo(4.0f, 9.0884f) + horizontalLineTo(20.0f) + } + path( + fill = null, + stroke = SolidColor(Color(0xFF000000)), + strokeLineWidth = 2.0f + ) { + moveTo(4.0f, 14.9116f) + horizontalLineTo(20.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(12.0f, 3.0f) + curveTo(7.5f, 9.0884f, 7.5f, 14.9116f, 12.0f, 21.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(12.0f, 3.0f) + curveTo(16.5f, 9.0884f, 16.5f, 14.9116f, 12.0f, 21.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Info.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Info.kt new file mode 100644 index 0000000..da20842 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Info.kt @@ -0,0 +1,62 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Info, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Info: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Info") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(3.0f, 12.0f) + arcToRelative(9.0f, 9.0f, 0.0f, true, true, 18.0f, 0.0f) + arcToRelative(9.0f, 9.0f, 0.0f, true, true, -18.0f, 0.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(12.0f, 17.0f) + verticalLineToRelative(-6.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(11.0f, 7.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(-2.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Interlaced.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Interlaced.kt new file mode 100644 index 0000000..95f45a8 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Interlaced.kt @@ -0,0 +1,102 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Interlaced, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Interlaced: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Interlaced") { + path(fill = SolidColor(defaultIconColor)) { + moveTo(3.0f, 3.0f) + verticalLineTo(21.0f) + horizontalLineTo(21.0f) + verticalLineTo(3.0f) + close() + moveTo(11.998f, 5.0f) + horizontalLineTo(19.0f) + verticalLineToRelative(1.0f) + horizontalLineToRelative(-7.0f) + verticalLineToRelative(1.0f) + horizontalLineToRelative(7.0f) + verticalLineTo(8.0f) + horizontalLineTo(12.002f) + verticalLineTo(9.0f) + horizontalLineTo(19.0f) + verticalLineToRelative(1.0f) + horizontalLineTo(12.002f) + verticalLineTo(11.0f) + horizontalLineTo(19.0f) + verticalLineToRelative(1.0f) + horizontalLineToRelative(-6.998f) + verticalLineToRelative(1.0f) + horizontalLineTo(19.0f) + verticalLineToRelative(1.0f) + horizontalLineToRelative(-6.998f) + verticalLineToRelative(1.0f) + horizontalLineTo(19.0f) + verticalLineToRelative(1.0f) + horizontalLineToRelative(-6.998f) + verticalLineToRelative(1.0f) + horizontalLineTo(19.0f) + verticalLineToRelative(1.0f) + horizontalLineToRelative(-6.998f) + verticalLineToRelative(1.0f) + horizontalLineTo(5.0f) + verticalLineTo(18.0f) + horizontalLineTo(11.998f) + verticalLineTo(17.0f) + horizontalLineTo(5.0f) + verticalLineTo(16.0f) + horizontalLineTo(11.998f) + verticalLineTo(15.0f) + horizontalLineTo(5.0f) + verticalLineTo(14.0f) + horizontalLineTo(11.998f) + verticalLineTo(13.0f) + horizontalLineTo(5.0f) + verticalLineTo(12.0f) + horizontalLineTo(11.998f) + verticalLineTo(11.0f) + horizontalLineTo(5.0f) + verticalLineTo(10.0f) + horizontalLineToRelative(6.998f) + verticalLineToRelative(-1.0f) + horizontalLineTo(5.0f) + verticalLineTo(8.0f) + horizontalLineTo(11.998f) + verticalLineTo(7.0f) + horizontalLineTo(4.998f) + verticalLineTo(6.0f) + horizontalLineToRelative(7.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Lan.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Lan.kt new file mode 100644 index 0000000..8305c20 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Lan.kt @@ -0,0 +1,89 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Lan, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Lan: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Lan") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(15.0f, 3.0f) + verticalLineToRelative(5.0f) + horizontalLineToRelative(-6.0f) + verticalLineToRelative(-5.0f) + close() + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(10.0f, 21.0f) + verticalLineToRelative(-5.0f) + horizontalLineToRelative(-6.0f) + verticalLineToRelative(5.0f) + close() + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(20.0f, 16.0f) + verticalLineToRelative(5.0f) + horizontalLineToRelative(-6.0f) + verticalLineToRelative(-5.0f) + close() + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(12.0f, 8.0f) + verticalLineToRelative(4.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(7.0f, 16.0f) + verticalLineToRelative(-4.0f) + horizontalLineToRelative(10.0f) + verticalLineToRelative(4.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Live.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Live.kt new file mode 100644 index 0000000..95d529f --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Live.kt @@ -0,0 +1,81 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Live, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Live: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Live") { + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 12.0f) + moveToRelative(-2.0f, 0.0f) + arcToRelative(2.0f, 2.0f, 0.0f, true, true, 4.0f, 0.0f) + arcToRelative(2.0f, 2.0f, 0.0f, true, true, -4.0f, 0.0f) + } + path( + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f, + strokeLineCap = StrokeCap.Butt, + ) { + moveToRelative(15.0f, 6.8038f) + arcToRelative(6.0f, 6.0f, 0.0f, false, true, 3.0f, 5.1962f) + arcToRelative(6.0f, 6.0f, 0.0f, false, true, -3.0f, 5.1962f) + } + path( + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f, + strokeLineCap = StrokeCap.Butt + ) { + moveToRelative(17.0f, 3.3397f) + arcToRelative(10.0f, 10.0f, 0.0f, false, true, 5.0f, 8.6603f) + arcToRelative(10.0f, 10.0f, 0.0f, false, true, -5.0f, 8.6603f) + } + path( + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f, + strokeLineCap = StrokeCap.Butt + ) { + moveToRelative(9.0f, 17.196f) + arcToRelative(6.0f, 6.0f, 0.0f, false, true, -3.0f, -5.1962f) + arcToRelative(6.0f, 6.0f, 0.0f, false, true, 3.0f, -5.1962f) + } + path( + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f, + strokeLineCap = StrokeCap.Butt + ) { + moveToRelative(7.0f, 20.66f) + arcToRelative(10.0f, 10.0f, 0.0f, false, true, -5.0f, -8.6603f) + arcToRelative(10.0f, 10.0f, 0.0f, false, true, 5.0f, -8.6603f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/LoopOff.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/LoopOff.kt new file mode 100644 index 0000000..0264c83 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/LoopOff.kt @@ -0,0 +1,62 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.LoopOff, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.LoopOff: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.LoopOff") { + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 9.0f) + lineTo(16.0254f, 5.5f) + lineTo(12.0f, 2.0f) + verticalLineToRelative(2.5f) + curveToRelative(-2.6779f, 0.0f, -5.1572f, 1.4309f, -6.4961f, 3.75f) + curveToRelative(-1.3389f, 2.3191f, -1.3389f, 5.1809f, 0.0f, 7.5f) + lineToRelative(1.7324f, -1.0f) + curveToRelative(-0.9833f, -1.7031f, -0.9833f, -3.7969f, 0.0f, -5.5f) + curveTo(8.2196f, 7.5469f, 10.0334f, 6.5f, 12.0f, 6.5f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 15.0f) + lineTo(7.9746f, 18.5f) + lineTo(12.0f, 22.0f) + verticalLineToRelative(-2.5f) + curveToRelative(2.6779f, 0.0f, 5.1572f, -1.4309f, 6.4961f, -3.75f) + curveToRelative(1.3389f, -2.3191f, 1.3389f, -5.1809f, 0.0f, -7.5f) + lineToRelative(-1.7324f, 1.0f) + curveToRelative(0.9833f, 1.7031f, 0.9833f, 3.7969f, 0.0f, 5.5f) + curveTo(15.7804f, 16.4531f, 13.9666f, 17.5f, 12.0f, 17.5f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/LoopOn.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/LoopOn.kt new file mode 100644 index 0000000..575c891 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/LoopOn.kt @@ -0,0 +1,68 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.LoopOn, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.LoopOn: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.LoopOn") { + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 1.0f) + arcTo(11.0f, 11.0f, 0.0f, false, false, 1.0f, 12.0f) + arcTo(11.0f, 11.0f, 0.0f, false, false, 12.0f, 23.0f) + arcTo(11.0f, 11.0f, 0.0f, false, false, 23.0f, 12.0f) + arcTo(11.0f, 11.0f, 0.0f, false, false, 12.0f, 1.0f) + close() + moveTo(12.0f, 2.0f) + lineTo(16.0254f, 5.5f) + lineTo(12.0f, 9.0f) + lineTo(12.0f, 6.5f) + curveTo(10.0334f, 6.5f, 8.2196f, 7.5469f, 7.2363f, 9.25f) + curveTo(6.253f, 10.9531f, 6.253f, 13.0469f, 7.2363f, 14.75f) + lineTo(5.5039f, 15.75f) + curveTo(4.165f, 13.4309f, 4.165f, 10.5691f, 5.5039f, 8.25f) + curveTo(6.8428f, 5.9309f, 9.3221f, 4.5f, 12.0f, 4.5f) + lineTo(12.0f, 2.0f) + close() + moveTo(18.4961f, 8.25f) + curveTo(19.835f, 10.5691f, 19.835f, 13.4309f, 18.4961f, 15.75f) + curveTo(17.1572f, 18.0691f, 14.6779f, 19.5f, 12.0f, 19.5f) + lineTo(12.0f, 22.0f) + lineTo(7.9746f, 18.5f) + lineTo(12.0f, 15.0f) + lineTo(12.0f, 17.5f) + curveTo(13.9666f, 17.5f, 15.7804f, 16.4531f, 16.7637f, 14.75f) + curveTo(17.747f, 13.0469f, 17.747f, 10.9531f, 16.7637f, 9.25f) + lineTo(18.4961f, 8.25f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/MiniScreen.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/MiniScreen.kt new file mode 100644 index 0000000..c29f18f --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/MiniScreen.kt @@ -0,0 +1,77 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.MiniScreen, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.MiniScreen: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.MiniScreen") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(9.0f, 20.0f) + horizontalLineTo(7.0f) + moveTo(4.0f, 21.0f) + verticalLineTo(19.0f) + moveTo(4.0f, 17.0f) + verticalLineTo(15.0f) + moveTo(4.0f, 13.0f) + verticalLineTo(11.0f) + moveTo(4.0f, 9.0f) + verticalLineTo(7.0f) + moveTo(3.0f, 4.0f) + horizontalLineToRelative(2.0f) + moveToRelative(2.0f, 0.0f) + horizontalLineToRelative(2.0f) + moveToRelative(2.0f, 0.0f) + horizontalLineToRelative(2.0f) + moveToRelative(2.0f, 0.0f) + horizontalLineToRelative(2.0f) + moveToRelative(2.0f, 0.0f) + horizontalLineToRelative(2.0f) + moveToRelative(-1.0f, 3.0f) + verticalLineToRelative(2.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(20.0f, 20.0f) + verticalLineToRelative(-8.0f) + horizontalLineToRelative(-8.0f) + verticalLineToRelative(8.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Minimize.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Minimize.kt new file mode 100644 index 0000000..638229a --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Minimize.kt @@ -0,0 +1,46 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Minimize, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Minimize: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Minimize") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(2.0f, 12.0f) + horizontalLineTo(22.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Minus.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Minus.kt new file mode 100644 index 0000000..80c2006 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Minus.kt @@ -0,0 +1,46 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Minus, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Minus: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Minus") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(5.0f, 12.0f) + horizontalLineTo(19.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Notification.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Notification.kt new file mode 100644 index 0000000..347c466 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Notification.kt @@ -0,0 +1,74 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Notification, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Notification: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Notification") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(4.0f, 17.0f) + lineTo(20.0f, 17.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(7.0f, 17.0f) + verticalLineToRelative(-8.0f) + curveToRelative(0.0f, -2.7614f, 2.2386f, -5.0f, 5.0f, -5.0f) + curveToRelative(2.7614f, 0.0f, 5.0f, 2.2386f, 5.0f, 5.0f) + verticalLineToRelative(8.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(14.0f, 19.0f) + arcToRelative(2.0f, 2.0f, 0.0f, false, true, -1.0f, 1.732f) + arcToRelative(2.0f, 2.0f, 0.0f, false, true, -2.0f, 0.0f) + arcTo(2.0f, 2.0f, 0.0f, false, true, 10.0f, 19.0f) + } + path( + fill = SolidColor(defaultIconColor), + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 1.0f + ) { + moveToRelative(11.0f, 4.0f) + verticalLineToRelative(-2.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Pause.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Pause.kt new file mode 100644 index 0000000..a776422 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Pause.kt @@ -0,0 +1,52 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Pause, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Pause: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Pause") { + path(fill = SolidColor(defaultIconColor)) { + moveTo(6.0f, 5.0f) + horizontalLineToRelative(4.0f) + verticalLineToRelative(14.0f) + horizontalLineToRelative(-4.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(14.0f, 5.0f) + horizontalLineToRelative(4.0f) + verticalLineToRelative(14.0f) + horizontalLineToRelative(-4.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/PinOff.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/PinOff.kt new file mode 100644 index 0000000..d175388 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/PinOff.kt @@ -0,0 +1,57 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.PinOff, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.PinOff: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.PinOff") { + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(13.0f, 16.0f) + horizontalLineToRelative(6.0f) + lineTo(17.0f, 14.0f) + verticalLineTo(5.0f) + lineTo(19.0f, 3.0f) + horizontalLineTo(5.0f) + lineToRelative(2.0f, 2.0f) + verticalLineToRelative(9.0f) + lineToRelative(-2.0f, 2.0f) + horizontalLineToRelative(6.0f) + verticalLineToRelative(6.0f) + horizontalLineToRelative(2.0f) + moveTo(9.0f, 5.0f) + horizontalLineToRelative(6.0f) + verticalLineToRelative(9.0f) + horizontalLineTo(9.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/PinOn.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/PinOn.kt new file mode 100644 index 0000000..325ca6a --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/PinOn.kt @@ -0,0 +1,52 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.PinOn, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.PinOn: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.PinOn") { + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(13.0f, 16.0f) + horizontalLineToRelative(6.0f) + lineTo(17.0f, 14.0f) + verticalLineTo(5.0f) + lineTo(19.0f, 3.0f) + horizontalLineTo(5.0f) + lineToRelative(2.0f, 2.0f) + verticalLineToRelative(9.0f) + lineToRelative(-2.0f, 2.0f) + horizontalLineToRelative(6.0f) + verticalLineToRelative(6.0f) + horizontalLineToRelative(2.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Play.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Play.kt new file mode 100644 index 0000000..726c956 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Play.kt @@ -0,0 +1,44 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Play, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Play: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Play") { + path(fill = SolidColor(defaultIconColor)) { + moveTo(7.0f, 4.0f) + verticalLineTo(20.0f) + lineTo(19.0f, 12.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Plus.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Plus.kt new file mode 100644 index 0000000..85fa6c5 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Plus.kt @@ -0,0 +1,54 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Plus, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Plus: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Plus") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(5.0f, 12.0f) + horizontalLineTo(19.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(12.0f, 19.0f) + verticalLineTo(5.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/README.md b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/README.md new file mode 100644 index 0000000..d6b95bb --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/README.md @@ -0,0 +1,7 @@ +Some of the source (original) SVG files from which the icons were made +are in the [raw/icons.svg](../../../../../../../raw/icons.svg) file at the project root. + +To convert SVGs to Compose paths +see https://github.com/DevSrSouza/svg-to-compose +and https://github.com/mohsenoid/SvgToCompose +and https://github.com/DenisMondon/Svg2Compose diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/RegularScreen.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/RegularScreen.kt new file mode 100644 index 0000000..71d75ca --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/RegularScreen.kt @@ -0,0 +1,74 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.RegularScreen, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.RegularScreen: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.RegularScreen") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(15.0f, 20.0f) + horizontalLineToRelative(5.0f) + verticalLineToRelative(-5.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(15.0f, 4.0f) + horizontalLineToRelative(5.0f) + verticalLineToRelative(5.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(9.0f, 4.0f) + horizontalLineToRelative(-5.0f) + verticalLineToRelative(5.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(9.0f, 20.0f) + horizontalLineToRelative(-5.0f) + verticalLineTo(15.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Rewind30En.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Rewind30En.kt new file mode 100644 index 0000000..1927376 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Rewind30En.kt @@ -0,0 +1,73 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Rewind30En, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Rewind30En: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Rewind30En") { + path( + name = "minus", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(1f, 12f) + horizontalLineToRelative(6f) + } + path( + name = "three", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(13f, 12f) + verticalLineToRelative(4f) + horizontalLineToRelative(-4f) + moveToRelative(0f, -8f) + horizontalLineToRelative(4f) + verticalLineToRelative(4f) + horizontalLineToRelative(-4f) + } + path( + name = "zero", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(20f, 16f) + verticalLineToRelative(-8f) + horizontalLineToRelative(-3f) + verticalLineToRelative(8f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Rewind30Fa.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Rewind30Fa.kt new file mode 100644 index 0000000..947c29b --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Rewind30Fa.kt @@ -0,0 +1,77 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Rewind30Fa, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Rewind30Fa: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Rewind30Fa") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f, + ) { + moveTo(3.0f, 12.0f) + lineTo(9.0f, 12.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f, + ) { + moveTo(11.0f, 6.0f) + lineTo(11.0f, 17.0f) + moveTo(19.0f, 6.0f) + verticalLineToRelative(4.0f) + lineTo(15.0f, 10.0f) + lineTo(15.0f, 6.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f, + ) { + moveToRelative(19.0f, 16.0f) + verticalLineToRelative(-2.0f) + horizontalLineToRelative(-2.0f) + verticalLineToRelative(2.0f) + close() + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f, + ) { + moveTo(14.0f, 10.0f) + horizontalLineTo(11.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Rewind5En.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Rewind5En.kt new file mode 100644 index 0000000..05b827b --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Rewind5En.kt @@ -0,0 +1,60 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Rewind5En, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Rewind5En: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Rewind5En") { + path( + name = "minus", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(4f, 12f) + horizontalLineToRelative(6f) + } + path( + name = "five", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(18f, 8f) + horizontalLineToRelative(-5f) + verticalLineToRelative(4f) + horizontalLineToRelative(4f) + verticalLineToRelative(4f) + horizontalLineToRelative(-5f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Rewind5Fa.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Rewind5Fa.kt new file mode 100644 index 0000000..9bd5a00 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Rewind5Fa.kt @@ -0,0 +1,77 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Rewind5Fa, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Rewind5Fa: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Rewind5Fa") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f, + ) { + moveToRelative(2.0f, 13.0f) + horizontalLineToRelative(6.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f, + ) { + moveToRelative(14.0f, 8.0f) + lineToRelative(-3.0f, 5.0f) + lineToRelative(-1.0f, 2.0f) + lineToRelative(1.0f, 2.0f) + horizontalLineToRelative(3.0f) + verticalLineToRelative(-3.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f, + ) { + moveToRelative(14.0f, 8.0f) + lineToRelative(3.0f, 5.0f) + lineToRelative(1.0f, 2.0f) + lineToRelative(-1.0f, 2.0f) + lineToRelative(-3.0f, 0.0f) + lineToRelative(-0.0f, -3.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(14.0f, 6.0586f) + lineTo(12.834f, 8.002f) + lineTo(14.0f, 9.9434f) + lineTo(15.166f, 8.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Settings.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Settings.kt new file mode 100644 index 0000000..66c9560 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Settings.kt @@ -0,0 +1,156 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Settings, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Settings: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Settings") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(12.0f, 12.0f) + moveToRelative(-2.0f, 0.0f) + arcToRelative(2.0f, 2.0f, 0.0f, true, true, 4.0f, 0.0f) + arcToRelative(2.0f, 2.0f, 0.0f, true, true, -4.0f, 0.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(14.0f, 18.0f) + verticalLineToRelative(3.0f) + horizontalLineToRelative(-4.0f) + verticalLineToRelative(-3.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(18.1962f, 13.268f) + lineToRelative(2.5981f, 1.5f) + lineToRelative(-2.0f, 3.4641f) + lineToRelative(-2.5981f, -1.5f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(16.1962f, 7.268f) + lineToRelative(2.5981f, -1.5f) + lineToRelative(2.0f, 3.4641f) + lineToRelative(-2.5981f, 1.5f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(10.0f, 6.0f) + verticalLineTo(3.0f) + horizontalLineToRelative(4.0f) + verticalLineToRelative(3.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(5.8038f, 10.732f) + lineToRelative(-2.5981f, -1.5f) + lineToRelative(2.0f, -3.4641f) + lineToRelative(2.5981f, 1.5f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(7.8038f, 16.7321f) + lineToRelative(-2.5981f, 1.5f) + lineToRelative(-2.0f, -3.4641f) + lineToRelative(2.5981f, -1.5f) + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(13.0f, 5.0723f) + verticalLineToRelative(2.0293f) + curveToRelative(1.0616f, 0.2165f, 2.0261f, 0.7706f, 2.7441f, 1.582f) + lineTo(17.5f, 7.6719f) + curveTo(16.3897f, 6.2614f, 14.7767f, 5.3288f, 13.0f, 5.0723f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(18.4996f, 9.4022f) + lineToRelative(-1.7574f, 1.0146f) + curveToRelative(0.3434f, 1.0276f, 0.3457f, 2.14f, 0.002f, 3.1675f) + lineToRelative(1.7541f, 1.0148f) + curveToRelative(0.6664f, -1.6668f, 0.6675f, -3.53f, 0.001f, -5.1969f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(17.4996f, 16.3299f) + lineToRelative(-1.7574f, -1.0146f) + curveToRelative(-0.7182f, 0.8112f, -1.6804f, 1.3694f, -2.7422f, 1.5855f) + lineToRelative(-0.002f, 2.0265f) + curveToRelative(1.7767f, -0.2563f, 3.3909f, -1.1869f, 4.5013f, -2.5973f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(11.0f, 18.9277f) + lineTo(11.0f, 16.8984f) + curveTo(9.9384f, 16.682f, 8.9739f, 16.1278f, 8.2559f, 15.3164f) + lineTo(6.5f, 16.3281f) + curveTo(7.6103f, 17.7386f, 9.2233f, 18.6712f, 11.0f, 18.9277f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(5.5004f, 14.5978f) + lineToRelative(1.7574f, -1.0146f) + curveToRelative(-0.3433f, -1.0276f, -0.3457f, -2.14f, -0.002f, -3.1675f) + lineTo(5.5017f, 9.4009f) + curveToRelative(-0.6664f, 1.6668f, -0.6676f, 3.53f, -0.001f, 5.1969f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(6.5004f, 7.6701f) + lineTo(8.2578f, 8.6847f) + curveTo(8.9761f, 7.8736f, 9.9383f, 7.3154f, 11.0f, 7.0993f) + lineToRelative(0.002f, -2.0265f) + curveTo(9.2253f, 5.3291f, 7.6111f, 6.2597f, 6.5007f, 7.6701f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Shutter.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Shutter.kt new file mode 100644 index 0000000..4859a1c --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Shutter.kt @@ -0,0 +1,70 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Shutter, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Shutter: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Shutter") { + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(12.0f, 2.0f) + curveToRelative(-2.6209f, 0.0f, -5.136f, 1.0345f, -7.0039f, 2.8731f) + lineToRelative(3.5391f, 6.1269f) + lineToRelative(5.1172f, -8.8613f) + curveTo(13.1064f, 2.0468f, 12.5537f, 2.0004f, 12.0f, 2.0f) + close() + moveTo(14.6758f, 2.3652f) + lineTo(11.1328f, 8.5f) + horizontalLineToRelative(10.2324f) + curveToRelative(-1.1199f, -2.9974f, -3.6066f, -5.2778f, -6.6895f, -6.1348f) + close() + moveTo(4.3008f, 5.666f) + curveTo(2.8241f, 7.4476f, 2.011f, 9.6861f, 2.0f, 12.0f) + curveToRelative(0.0098f, 0.8447f, 0.1266f, 1.6847f, 0.3477f, 2.5f) + horizontalLineToRelative(7.0527f) + close() + moveTo(14.5996f, 9.4999f) + lineTo(19.6992f, 18.3339f) + curveTo(21.1759f, 16.5524f, 21.989f, 14.3139f, 22.0f, 12.0f) + curveToRelative(-3.0E-4f, -0.8434f, -0.1072f, -1.6834f, -0.3184f, -2.5f) + close() + moveTo(15.4648f, 12.9999f) + lineTo(10.3516f, 21.8574f) + curveToRelative(0.5446f, 0.093f, 1.096f, 0.1407f, 1.6484f, 0.1426f) + curveToRelative(2.6209f, 0.0f, 5.136f, -1.0345f, 7.0039f, -2.8731f) + close() + moveTo(2.6602f, 15.4999f) + curveToRelative(1.1232f, 2.9864f, 3.604f, 5.2575f, 6.6777f, 6.1133f) + lineToRelative(3.5293f, -6.1133f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SidePanelOff.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SidePanelOff.kt new file mode 100644 index 0000000..6f2a331 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SidePanelOff.kt @@ -0,0 +1,62 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.SidePanelOff, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.SidePanelOff: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.SidePanelOff") { + path( + name = "main", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(16f, 20f) + verticalLineToRelative(-16f) + horizontalLineToRelative(-13f) + verticalLineToRelative(16f) + close() + } + path( + name = "side", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(21f, 20f) + verticalLineToRelative(-16f) + horizontalLineToRelative(-5f) + verticalLineToRelative(16f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SidePanelOn.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SidePanelOn.kt new file mode 100644 index 0000000..159a742 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SidePanelOn.kt @@ -0,0 +1,62 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.SidePanelOn, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.SidePanelOn: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.SidePanelOn") { + path( + name = "main", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(16f, 20f) + verticalLineToRelative(-16f) + horizontalLineToRelative(-13f) + verticalLineToRelative(16f) + close() + } + path( + name = "side", + fill = SolidColor(defaultIconColor), + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2f + ) { + moveTo(21f, 20f) + verticalLineToRelative(-16f) + horizontalLineToRelative(-5f) + verticalLineToRelative(16f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SpeedFast.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SpeedFast.kt new file mode 100644 index 0000000..e8437c2 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SpeedFast.kt @@ -0,0 +1,100 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.SpeedFast, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.SpeedFast: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Speed") { + path( + fill = null, + stroke = SolidColor(defaultIconColor) + ) { + moveTo(12.0f, 12.0f) + moveToRelative(-9.0f, 0.0f) + arcToRelative(9.0f, 9.0f, 0.0f, true, true, 18.0f, 0.0f) + arcToRelative(9.0f, 9.0f, 0.0f, true, true, -18.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 6.0f) + moveToRelative(-1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, 2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, -2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(18.0f, 12.0f) + moveToRelative(-1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, 2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, -2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 18.0f) + moveToRelative(-1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, 2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, -2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(6.0f, 12.0f) + moveToRelative(-1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, 2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, -2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(8.0f, 8.0f) + moveToRelative(-1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, 2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, -2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(8.0f, 16.0f) + moveToRelative(-1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, 2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, -2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(16.2426f, 16.2426f) + lineToRelative(-2.439f, -5.1044f) + lineToRelative(-0.003f, 0.003f) + arcToRelative(2.0f, 2.0f, 0.0f, false, false, -0.3867f, -0.5552f) + arcToRelative(2.0f, 2.0f, 0.0f, false, false, -2.8284f, 0.0f) + arcToRelative(2.0f, 2.0f, 0.0f, false, false, 0.0f, 2.8284f) + arcToRelative(2.0f, 2.0f, 0.0f, false, false, 0.5524f, 0.3895f) + arcToRelative(2.0f, 2.0f, 0.0f, false, false, 0.0331f, 0.0165f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(16.0f, 8.0f) + moveToRelative(1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, -2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, 2.0f, 0.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SpeedNormal.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SpeedNormal.kt new file mode 100644 index 0000000..9fe44ac --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SpeedNormal.kt @@ -0,0 +1,101 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.SpeedNormal, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.SpeedNormal: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Speed") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(12.0f, 12.0f) + moveToRelative(-9.0f, 0.0f) + arcToRelative(9.0f, 9.0f, 0.0f, true, true, 18.0f, 0.0f) + arcToRelative(9.0f, 9.0f, 0.0f, true, true, -18.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(18.0f, 12.0f) + moveToRelative(-1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, 2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, -2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 18.0f) + moveToRelative(-1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, 2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, -2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(6.0f, 12.0f) + moveToRelative(-1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, 2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, -2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(8.0f, 8.0f) + moveToRelative(-1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, 2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, -2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(8.0f, 16.0f) + moveToRelative(-1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, 2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, -2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(12.0f, 6.0f) + lineToRelative(-1.8848f, 5.334f) + horizontalLineToRelative(0.0042f) + arcToRelative(2.0f, 2.0f, 0.0f, false, false, -0.1191f, 0.666f) + arcToRelative(2.0f, 2.0f, 0.0f, false, false, 2.0f, 2.0f) + arcToRelative(2.0f, 2.0f, 0.0f, false, false, 2.0f, -2.0f) + arcToRelative(2.0f, 2.0f, 0.0f, false, false, -0.1152f, -0.666f) + arcToRelative(2.0f, 2.0f, 0.0f, false, false, -0.0117f, -0.0351f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(16.0f, 8.0f) + moveToRelative(1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, -2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, 2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(16.0f, 16.0f) + moveToRelative(1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, -2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, 2.0f, 0.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SpeedSlow.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SpeedSlow.kt new file mode 100644 index 0000000..8342198 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/SpeedSlow.kt @@ -0,0 +1,101 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.SpeedSlow, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.SpeedSlow: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Speed") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(12.0f, 12.0f) + moveToRelative(9.0f, 0.0f) + arcToRelative(9.0f, 9.0f, 0.0f, true, false, -18.0f, 0.0f) + arcToRelative(9.0f, 9.0f, 0.0f, true, false, 18.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 6.0f) + moveToRelative(1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, -2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, 2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(6.0f, 12.0f) + moveToRelative(1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, -2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, 2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 18.0f) + moveToRelative(1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, -2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, 2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(18.0f, 12.0f) + moveToRelative(1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, -2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, 2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(16.0f, 8.0f) + moveToRelative(1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, -2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, 2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(16.0f, 16.0f) + moveToRelative(1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, -2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, false, 2.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(7.7574f, 16.2426f) + lineToRelative(2.439f, -5.1044f) + lineToRelative(0.003f, 0.003f) + arcToRelative(2.0f, 2.0f, 0.0f, false, true, 0.3867f, -0.5552f) + arcToRelative(2.0f, 2.0f, 0.0f, false, true, 2.8284f, 0.0f) + arcToRelative(2.0f, 2.0f, 0.0f, false, true, 0.0f, 2.8284f) + arcToRelative(2.0f, 2.0f, 0.0f, false, true, -0.5524f, 0.3895f) + arcToRelative(2.0f, 2.0f, 0.0f, false, true, -0.0331f, 0.0165f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(8.0f, 8.0f) + moveToRelative(-1.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, 2.0f, 0.0f) + arcToRelative(1.0f, 1.0f, 0.0f, true, true, -2.0f, 0.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Star.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Star.kt new file mode 100644 index 0000000..ab6c408 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Star.kt @@ -0,0 +1,57 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeJoin.Companion.Round +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Star, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Star: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Star") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f, + strokeLineJoin = Round + ) { + moveToRelative(12.0f, 4.0f) + lineToRelative(2.3511f, 4.7639f) + lineToRelative(5.2573f, 0.7639f) + lineToRelative(-3.8042f, 3.7082f) + lineToRelative(0.8981f, 5.2361f) + lineTo(12.0f, 16.0f) + lineTo(7.2977f, 18.4721f) + lineTo(8.1958f, 13.2361f) + lineTo(4.3915f, 9.5279f) + lineTo(9.6489f, 8.7639f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/StartCircle.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/StartCircle.kt new file mode 100644 index 0000000..1977532 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/StartCircle.kt @@ -0,0 +1,57 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.StartCircle, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.StartCircle: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.StartCircle") { + path( + name = "circle", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(4.0f, 12.0f) + arcToRelative(6.0f, 6.0f, 0.0f, true, true, 12.0f, 0.0f) + arcToRelative(6.0f, 6.0f, 0.0f, true, true, -12.0f, 0.0f) + } + path( + name = "line", + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(15.0f, 12.0f) + horizontalLineToRelative(9.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Theme.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Theme.kt new file mode 100644 index 0000000..26c6645 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Theme.kt @@ -0,0 +1,75 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Theme, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Theme: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Theme") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(11.7782f, 4.2218f) + lineToRelative(7.7782f, 7.7782f) + lineToRelative(-7.7782f, 7.7782f) + lineToRelative(-7.7782f, -7.7782f) + close() + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(12.4853f, 4.9289f) + lineTo(9.6569f, 2.1005f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(20.7782f, 18.7782f) + moveToRelative(-2.0f, 0.0f) + arcToRelative(2.0f, 2.0f, 0.0f, true, true, 4.0f, 0.0f) + arcToRelative(2.0f, 2.0f, 0.0f, true, true, -4.0f, 0.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(19.0461f, 17.7782f) + lineToRelative(1.7321f, -3.0f) + lineToRelative(1.7321f, 3.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(4.0f, 12.0f) + lineTo(19.0f, 12.0f) + lineTo(12.0f, 19.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Undo.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Undo.kt new file mode 100644 index 0000000..914fa22 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Undo.kt @@ -0,0 +1,58 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Undo, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Undo: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Undo") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(9.0f, 12.0f) + lineTo(5.0f, 8.0f) + lineTo(9.0f, 4.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(5.0f, 18.0f) + horizontalLineToRelative(10.0f) + curveToRelative(2.7614f, 0.0f, 5.0f, -2.2386f, 5.0f, -5.0f) + curveToRelative(0.0f, -2.7614f, -2.2386f, -5.0f, -5.0f, -5.0f) + horizontalLineTo(6.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/VolumeEmpty.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/VolumeEmpty.kt new file mode 100644 index 0000000..5005fb0 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/VolumeEmpty.kt @@ -0,0 +1,47 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.VolumeEmpty, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.VolumeEmpty: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.VolumeEmpty") { + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 3.0f) + lineTo(8.0f, 7.0f) + lineTo(3.0f, 8.0f) + verticalLineToRelative(8.0f) + lineToRelative(5.0f, 1.0f) + lineToRelative(4.0f, 4.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/VolumeFull.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/VolumeFull.kt new file mode 100644 index 0000000..d9f5492 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/VolumeFull.kt @@ -0,0 +1,65 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.VolumeFull, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.VolumeFull: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.VolumeFull") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(14.0f, 5.0f) + arcToRelative(7.0f, 7.0f, 0.0f, false, true, 7.0f, 7.0f) + arcToRelative(7.0f, 7.0f, 0.0f, false, true, -7.0f, 7.0f) + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(14.0f, 9.0f) + arcToRelative(3.0f, 3.0f, 0.0f, false, true, 3.0f, 3.0f) + arcToRelative(3.0f, 3.0f, 0.0f, false, true, -3.0f, 3.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 3.0f) + lineTo(8.0f, 7.0f) + lineTo(3.0f, 8.0f) + verticalLineToRelative(8.0f) + lineToRelative(5.0f, 1.0f) + lineToRelative(4.0f, 4.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/VolumeHalf.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/VolumeHalf.kt new file mode 100644 index 0000000..432dd15 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/VolumeHalf.kt @@ -0,0 +1,56 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.VolumeHalf, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.VolumeHalf: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.VolumeHalf") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(14.0f, 9.0f) + arcToRelative(3.0f, 3.0f, 0.0f, false, true, 3.0f, 3.0f) + arcToRelative(3.0f, 3.0f, 0.0f, false, true, -3.0f, 3.0f) + } + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 3.0f) + lineTo(8.0f, 7.0f) + lineTo(3.0f, 8.0f) + verticalLineToRelative(8.0f) + lineToRelative(5.0f, 1.0f) + lineToRelative(4.0f, 4.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/VolumeMuted.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/VolumeMuted.kt new file mode 100644 index 0000000..8378fbc --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/VolumeMuted.kt @@ -0,0 +1,54 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.VolumeMuted, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.VolumeMuted: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.VolumeMuted") { + path(fill = SolidColor(defaultIconColor)) { + moveTo(12.0f, 3.0f) + lineTo(8.0f, 7.0f) + lineTo(3.0f, 8.0f) + verticalLineToRelative(8.0f) + lineToRelative(5.0f, 1.0f) + lineToRelative(4.0f, 4.0f) + close() + moveTo(10.0f, 8.0f) + verticalLineToRelative(8.0f) + lineTo(9.0f, 15.0f) + lineTo(5.0f, 14.0f) + lineTo(5.0f, 10.0f) + lineTo(9.0f, 9.0f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Warn.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Warn.kt new file mode 100644 index 0000000..0613c4d --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Warn.kt @@ -0,0 +1,62 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Warn, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Warn: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Warn") { + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(11.0f, 15.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(-2.0f) + close() + } + path(fill = SolidColor(defaultIconColor)) { + moveToRelative(11.0f, 7.0f) + verticalLineToRelative(6.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(-6.0f) + close() + } + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveTo(12.0f, 12.0f) + moveToRelative(-8.0f, 0.0f) + arcToRelative(8.0f, 8.0f, 0.0f, true, true, 16.0f, 0.0f) + arcToRelative(8.0f, 8.0f, 0.0f, true, true, -16.0f, 0.0f) + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Wrench.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Wrench.kt new file mode 100644 index 0000000..dc00a29 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/icon/Wrench.kt @@ -0,0 +1,59 @@ +package ir.mahozad.cutcon.ui.icon + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.materialIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconColor + +@Preview +@Composable +private fun IconPreview() { + Image( + imageVector = Icons.Custom.Wrench, + modifier = Modifier + .size(256.dp) + .background(Color.Yellow), + contentDescription = null + ) +} + +private var icon: ImageVector? = null +val Icons.Custom.Wrench: ImageVector + get() { + if (icon != null) { + return icon!! + } + icon = materialIcon(name = "Custom.Wrench") { + path( + fill = null, + stroke = SolidColor(defaultIconColor), + strokeLineWidth = 2.0f + ) { + moveToRelative(12.4049f, 15.405f) + lineToRelative(5.0358f, 5.0358f) + lineToRelative(3.0f, -3.0f) + lineToRelative(-5.0358f, -5.0358f) + lineToRelative(-0.808f, -0.808f) + curveToRelative(0.272f, -0.6625f, 0.412f, -1.3717f, 0.4121f, -2.0879f) + curveToRelative(0.0f, -3.0376f, -2.4624f, -5.5f, -5.5f, -5.5f) + curveToRelative(-0.8221f, 0.003f, -1.633f, 0.1908f, -2.373f, 0.5488f) + lineToRelative(4.6309f, 4.6309f) + lineToRelative(-2.5781f, 2.5781f) + lineToRelative(-4.6309f, -4.6309f) + curveToRelative(-0.358f, 0.7401f, -0.5455f, 1.551f, -0.5488f, 2.373f) + curveToRelative(0.0f, 3.0376f, 2.4624f, 5.5f, 5.5f, 5.5f) + curveToRelative(0.7162f, -1.0E-4f, 1.4254f, -0.1401f, 2.0879f, -0.4121f) + close() + } + } + return icon!! + } diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/panel/AboutPanel.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/panel/AboutPanel.kt new file mode 100644 index 0000000..8403eee --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/panel/AboutPanel.kt @@ -0,0 +1,270 @@ +package ir.mahozad.cutcon.ui.panel + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.* +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.LocalCalendar +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.localization.LanguageEn +import ir.mahozad.cutcon.localization.LanguageFa +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.ui.theme.AppTheme + +private val entryFontSize = (defaultFontSize.value - 1).sp +private val entryIconSize = 18.dp +private const val LINK_TAG = "link" + +@Preview +@Composable +private fun AboutPanelPreviewFa() { + CompositionLocalProvider(LocalLanguage provides LanguageFa) { + AppTheme { + AboutPanel() + } + } +} + +@Preview +@Composable +private fun AboutPanelPreviewEn() { + CompositionLocalProvider(LocalLanguage provides LanguageEn) { + AppTheme { + AboutPanel() + } + } +} + +@Composable +fun AboutPanel() { + Column(verticalArrangement = spacedBy(4.dp)) { + AppGeneralInfo() + Divider(modifier = Modifier.padding(vertical = 8.dp)) + PoweredByEntries() + } +} + +@Composable +private fun AppGeneralInfo() { + Spacer(Modifier.height(8.dp)) + LogoNameVersionDate() + Divider(modifier = Modifier.padding(vertical = 8.dp)) + DeveloperEntry() +} + +@Composable +private fun LogoNameVersionDate() { + val language = LocalLanguage.current + val calendar = LocalCalendar.current + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource("logo.svg"), + contentDescription = Messages.ICO_DSC_LOGO, + modifier = Modifier.size(36.dp) + ) + Spacer(Modifier.width(8.dp)) + // Row is needed to be able to use Modifier.alignByBaseLine() + Row { + Text( + text = language.messages.appName, + fontSize = 16.sp, + modifier = Modifier.alignByBaseline() + ) + Spacer(Modifier.width(8.dp)) + Text( + text = language.messages.versionPrefix, + fontSize = defaultFontSize, + modifier = Modifier.alignByBaseline() + ) + Spacer(Modifier.width(if (language is LanguageFa) 3.dp else 0.dp)) + Text( + text = language.localizeDigits(BuildConfig.APP_VERSION), + fontSize = defaultFontSize, + modifier = Modifier.alignByBaseline() + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "(${calendar.format(BuildConfig.APP_RELEASE_DATE, language)})", + fontSize = defaultFontSize, + modifier = Modifier.alignByBaseline() + ) + } + } +} + +@Composable +private fun DeveloperEntry() { + val language = LocalLanguage.current + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource("icon/mahozad.svg"), + contentDescription = Messages.ICO_DSC_MAHOZAD_LOGO, + modifier = Modifier.size(entryIconSize) + ) + Spacer(Modifier.width(4.dp)) + val developerString = buildAnnotatedString { + append("${language.messages.txtLblAboutDeveloper} (") + pushStringAnnotation(tag = LINK_TAG, annotation = "https://mahozad.ir") + withStyle(style = SpanStyle(color = MaterialTheme.colors.primary)) { + append("https://mahozad.ir") + } + pop() + append(")") + } + TextWithLink(annotatedString = developerString) + } +} + +@Composable +private fun PoweredByEntries() { + val language = LocalLanguage.current + AboutEntry(icon = "icon/open-source.svg", normalText = language.messages.txtLblAboutPoweredBy) + AboutEntry( + icon = "icon/kotlin-logo.svg", + link = "https://kotlinlang.org", + linkText = language.messages.txtLblAboutKotlinLabel, + normalText = language.messages.txtLblAboutKotlinText + ) + AboutEntry( + icon = "icon/gradle-logo.svg", + link = "https://gradle.org", + linkText = language.messages.txtLblAboutGradleLabel, + normalText = language.messages.txtLblAboutGradleText + ) + AboutEntry( + icon = "icon/compose-logo.svg", + link = "https://developer.android.com/jetpack/compose", + linkText = language.messages.txtLblAboutJetpackComposeLabel, + normalText = language.messages.txtLblAboutJetpackComposeText + ) + AboutEntry( + icon = "icon/compose-multiplatform-logo.svg", + link = "https://jetbrains.com/lp/compose-multiplatform", + linkText = language.messages.txtLblAboutComposeMultiplatformLabel, + normalText = language.messages.txtLblAboutComposeMultiplatformText + ) + AboutEntry( + icon = "icon/vlc-logo.svg", + link = "https://videolan.org/vlc/libvlc.html", + linkText = language.messages.txtLblAboutVlcLabel, + normalText = language.messages.txtLblAboutVlcText + ) + AboutEntry( + icon = "icon/ffmpeg-logo.svg", + link = "https://github.com/bytedeco/javacv", + linkText = language.messages.txtLblAboutFfmpegLabel, + normalText = language.messages.txtLblAboutFfmpegText + ) + AboutEntry( + icon = "icon/material-you-logo.svg", + link = "https://m3.material.io", + linkText = language.messages.txtLblAboutMaterialDesignLabel, + normalText = language.messages.txtLblAboutMaterialDesignText + ) + AboutEntry( + icon = "icon/inkscape-logo.svg", + link = "https://inkscape.org", + linkText = language.messages.txtLblAboutInkscapeLabel, + normalText = language.messages.txtLblAboutInkscapeText + ) + AboutEntry( + icon = "icon/intellij-logo.svg", + link = "https://www.jetbrains.com/idea", + linkText = language.messages.txtLblAboutIntellijLabel, + normalText = language.messages.txtLblAboutIntellijText + ) + AboutEntry( + icon = "icon/git-logo.svg", + link = "https://git-scm.com", + linkText = language.messages.txtLblAboutGitLabel, + normalText = language.messages.txtLblAboutGitText + ) + AboutEntry( + icon = "icon/github-logo.svg", + link = "https://github.com", + linkText = language.messages.txtLblAboutGitHubLabel, + normalText = language.messages.txtLblAboutGitHubText + ) + AboutEntry( + icon = "icon/vazir-logo.svg", + link = "https://github.com/rastikerdar/vazirmatn", + linkText = language.messages.txtLblAboutVazirmatnLabel, + normalText = language.messages.txtLblAboutVazirmatnText + ) +} + +@OptIn(ExperimentalTextApi::class) +@Composable +private fun AboutEntry( + icon: String, + link: String? = null, + linkText: String? = null, + normalText: String +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(20.dp) + ) { + Image( + painter = painterResource(icon), + modifier = Modifier.size(entryIconSize), + contentDescription = Messages.ICO_DSC_SOFTWARE_LOGO + ) + Spacer(Modifier.width(4.dp)) + val primaryColor = MaterialTheme.colors.primary + val annotatedString = buildAnnotatedString { + if (link != null) { + withAnnotation(tag = LINK_TAG, annotation = link) { + withStyle(style = SpanStyle(color = primaryColor)) { + append(linkText) + } + } + } + append(normalText) + } + TextWithLink(annotatedString = annotatedString) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TextWithLink(annotatedString: AnnotatedString) { + val urlHandler = LocalUriHandler.current + var mousePinterIcon by remember { mutableStateOf(PointerIcon.Default) } + ClickableText( + text = annotatedString, + // FIXME: The text seems a little bit more bold than other regular texts in the app + style = LocalTextStyle.current.copy(fontSize = entryFontSize, color = LocalContentColor.current), + modifier = Modifier.pointerHoverIcon(icon = mousePinterIcon), + onHover = { offset -> + annotatedString + .takeIf { offset != null } + ?.getStringAnnotations(tag = LINK_TAG, start = offset!!, end = offset) + ?.firstOrNull() + ?.let { mousePinterIcon = PointerIcon.Hand } + ?: run { mousePinterIcon = PointerIcon.Default } + }, + onClick = { offset -> + annotatedString + .getStringAnnotations(tag = LINK_TAG, start = offset, end = offset) + .firstOrNull() + ?.item + ?.let(urlHandler::openUri) + } + ) +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/panel/ConfigPanel.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/panel/ConfigPanel.kt new file mode 100644 index 0000000..bd9946b --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/panel/ConfigPanel.kt @@ -0,0 +1,189 @@ +package ir.mahozad.cutcon.ui.panel + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.* +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.FrameWindowScope +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.MainViewModel +import ir.mahozad.cutcon.component.DefaultDateTimeChecker +import ir.mahozad.cutcon.component.DefaultMediaPlayer +import ir.mahozad.cutcon.component.DefaultSaveFileNameGenerator +import ir.mahozad.cutcon.component.DefaultUrlMaker +import ir.mahozad.cutcon.converter.DefaultConverterFactory +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.localization.LanguageEn +import ir.mahozad.cutcon.localization.LanguageFa +import ir.mahozad.cutcon.model.Quality +import ir.mahozad.cutcon.model.Status.* +import ir.mahozad.cutcon.ui.theme.AppTheme +import ir.mahozad.cutcon.ui.widget.* +import kotlinx.coroutines.Dispatchers +import java.util.prefs.Preferences + +@Preview +@Composable +private fun ConfigPanelPreviewFa() { + val fakeWindowScope = object : FrameWindowScope { + override val window get() = ComposeWindow() + } + CompositionLocalProvider(LocalLanguage provides LanguageFa) { + AppTheme { + fakeWindowScope.ConfigPanel( + MainViewModel( + dispatcher = Dispatchers.Main, + urlMaker = DefaultUrlMaker, + mediaPlayer = DefaultMediaPlayer(), + dateTimeChecker = DefaultDateTimeChecker(Dispatchers.Main), + converterFactory = DefaultConverterFactory(Dispatchers.Main), + saveFileNameGenerator = DefaultSaveFileNameGenerator, + settings = Preferences.userRoot().node("/${BuildConfig.APP_NAME}/preview")!! + ).apply { setLanguage(LanguageFa) } + ) + } + } +} + +@Preview +@Composable +private fun ConfigPanelPreviewEn() { + val fakeWindowScope = object : FrameWindowScope { + override val window get() = ComposeWindow() + } + CompositionLocalProvider(LocalLanguage provides LanguageEn) { + AppTheme { + fakeWindowScope.ConfigPanel( + MainViewModel( + dispatcher = Dispatchers.Main, + urlMaker = DefaultUrlMaker, + mediaPlayer = DefaultMediaPlayer(), + dateTimeChecker = DefaultDateTimeChecker(Dispatchers.Main), + converterFactory = DefaultConverterFactory(Dispatchers.Main), + saveFileNameGenerator = DefaultSaveFileNameGenerator, + settings = Preferences.userRoot().node("/${BuildConfig.APP_NAME}/preview")!! + ).apply { setLanguage(LanguageEn) } + ) + } + } +} + +@Composable +fun FrameWindowScope.ConfigPanel(viewModel: MainViewModel) { + // See https://stackoverflow.com/a/71182514 + val language = LocalLanguage.current + val source by viewModel.source.collectAsState() + val status by viewModel.status.collectAsState() + val format by viewModel.format.collectAsState() + val quality by viewModel.quality.collectAsState() + val saveFile by viewModel.saveFile.collectAsState() + val coverBitmap by viewModel.coverBitmap.collectAsState() + val coverOptions by viewModel.coverOptions.collectAsState() + val introBitmap by viewModel.introBitmap.collectAsState() + val introOptions by viewModel.introOptions.collectAsState() + val lastOpenDirectory by viewModel.lastOpenDirectory.collectAsState() + val lastSaveDirectory by viewModel.lastSaveDirectory.collectAsState() + val isQualityInputApplicable by viewModel.isQualityInputApplicable.collectAsState() + val isInputEnabled by remember { derivedStateOf { status !is Initializing && status !is InProgress } } + Column(modifier = Modifier.fillMaxHeight()) { + Spacer(Modifier.height(8.dp)) + LabeledDivider(label = LocalLanguage.current.messages.txtLblInput) + Spacer(Modifier.height(8.dp)) + SourceInput( + source = source, + lastOpenDirectory = lastOpenDirectory, + onSetSourceToLocalRequest = viewModel::setSourceToLocal + ) + Spacer(Modifier.height(16.dp)) + LabeledDivider(label = LocalLanguage.current.messages.txtLblOutput) + Spacer(Modifier.height(8.dp)) + FormatInput( + isEnabled = isInputEnabled, + format = format, + onChange = viewModel::setFormat + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + IntroInput( + source = source, + isEnabled = isInputEnabled, + image = introBitmap, + targetFormat = format, + options = introOptions, + lastOpenDirectory = lastOpenDirectory, + modifier = Modifier.width(118.dp), + onFileChange = viewModel::setIntroFile, + onDurationChange = viewModel::setIntroDuration, + onBackgroundColorChange = viewModel::setIntroBackgroundColor + ) + CoverInput( + source = source, + isEnabled = isInputEnabled, + image = coverBitmap, + targetFormat = format, + options = coverOptions, + lastOpenDirectory = lastOpenDirectory, + modifier = Modifier.weight(1f), + onFileChange = viewModel::setCoverFile, + onScaleChange = viewModel::setWaterMarkScale, + onOpacityChange = viewModel::setWaterMarkOpacity, + onPositionChange = viewModel::setWatermarkPosition + ) + } + Spacer(Modifier.height(16.dp)) + LabeledDivider(label = LocalLanguage.current.messages.txtLblQuality) + Spacer(Modifier.height(2.dp)) + QualityInput( + min = Quality.LOWEST.value, + max = Quality.HIGHEST.value, + quality = quality, + isEnabled = isInputEnabled, + isApplicable = isQualityInputApplicable, + onChange = viewModel::setQuality + ) + SaveAsInput( + isEnabled = isInputEnabled, + source = source, + destination = saveFile, + targetFormat = format, + lastSaveDirectory = lastSaveDirectory, + defaultNameProvider = viewModel::generateSaveFileDefaultName, + onFileSpecified = viewModel::setSaveFile + ) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = if (status is Ready) viewModel::startProcess else viewModel::cancelProcess, + enabled = status is Ready || status is InProgress + ) { + Text( + if (status is Initializing || status is InProgress) { + language.messages.btnLblCancelConversion + } else { + language.messages.btnLblStartConversion + } + ) + } + Spacer(Modifier.height(7.dp)) + StatusLabel(status) + } +} + +@Composable +private fun LabeledDivider(label: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.height(16.dp) + ) { + // Divider(modifier = Modifier.weight(1f)) + Text(text = label, fontSize = defaultFontSize) + Divider(modifier = Modifier.weight(1f)) + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/panel/MainPanel.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/panel/MainPanel.kt new file mode 100644 index 0000000..ace87b2 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/panel/MainPanel.kt @@ -0,0 +1,508 @@ +package ir.mahozad.cutcon.ui.panel + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import ir.mahozad.cutcon.* +import ir.mahozad.cutcon.localization.LanguageFa +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.model.Shortcut +import ir.mahozad.cutcon.ui.icon.* +import ir.mahozad.cutcon.ui.widget.* +import java.net.URI +import kotlin.io.path.toPath +import kotlin.time.Duration.Companion.seconds + +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalComposeUiApi::class, +) +@Composable +fun MainPanel() { + val image by viewModel.displayImage.collectAsState(null) + val aspectRatio by viewModel.aspectRatio.collectAsState() + val isFullscreen by viewModel.isFullscreen.collectAsState() + val isMiniScreen by viewModel.isMiniScreen.collectAsState() + var isDragging by remember { mutableStateOf(false) } + Column( + horizontalAlignment = Alignment.Start, + modifier = if (isFullscreen) { + Modifier.fillMaxWidth() + } else { + Modifier.width(DISPLAY_WIDTH.dp) + } + ) { + Box( + modifier = Modifier + .combinedClickable( + indication = null, // This is required to remove the subtle indication when clicking on display image + interactionSource = remember(::MutableInteractionSource), + onDoubleClick = { if (isFullscreen) viewModel.exitFullscreen() else viewModel.enterFullscreen() }, + onClick = {}, + ).then( + if (isFullscreen) { + Modifier.fillMaxSize() + } else if (isMiniScreen) { + Modifier.width(DISPLAY_WIDTH_MINI.dp).height(DISPLAY_HEIGHT_MINI.dp) + } else { + Modifier.width(DISPLAY_WIDTH.dp).height(DISPLAY_HEIGHT.dp) + } + ) + ) { + Display( + image = image, + aspectRatio = aspectRatio, + modifier = Modifier + .fillMaxSize() + .onExternalDrag( + onDragStart = { isDragging = true }, + onDragExit = { isDragging = false }, + onDrop = { state -> + isDragging = false + val dragData = state.dragData + if (dragData is DragData.FilesList) { + dragData + .readFiles() + .first() + .let(::URI) + .let(URI::toPath) + .let(viewModel::setSourceToLocal) + } + } + ) + ) + if (isFullscreen) { + ExitFullScreenButton() + } + if (isDragging) { + PlayMediaPreview() + } + } + if (isMiniScreen && !isFullscreen) { + ControlsForMiniScreen() + } else if (!isFullscreen) { + ControlsForRegularScreen() + } + } +} + +@Composable +private fun PlayMediaPreview() { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.surface.copy(alpha = 0.8f)) + ) { + Icon( + imageVector = Icons.Custom.Play, + contentDescription = Messages.ICO_DSC_PLAY_FILE, + modifier = Modifier.size(64.dp).align(Alignment.Center) + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterialApi::class) +@Composable +private fun ExitFullScreenButton() { + // Do not move this up and outside the if block + // (so the offset resets automatically when fullscreen exits) + var offsetOfCloseButton by remember { mutableStateOf((-48).dp) } + val offsetOfCloseButtonAnimated by animateDpAsState(offsetOfCloseButton) + Box( + contentAlignment = Alignment.TopCenter, + modifier = Modifier + .fillMaxWidth() + .height(128.dp) + .onPointerEvent(eventType = PointerEventType.Enter) { offsetOfCloseButton = 64.dp } + .onPointerEvent(eventType = PointerEventType.Exit) { offsetOfCloseButton = (-48).dp } + ) { + Surface( + color = MaterialTheme.colors.surface.copy(alpha = 0.66f), + shape = CircleShape, + onClick = viewModel::exitFullscreen, + elevation = 0.dp, + modifier = Modifier + .offset(y = offsetOfCloseButtonAnimated) + .border(Dp.Hairline, MaterialTheme.colors.onSurface.copy(alpha = 0.5f), CircleShape) + ) { + CustomIcon( + icon = Icons.Custom.Close, + tint = MaterialTheme.colors.onSurface, + description = Messages.ICO_DSC_EXIT_FULLSCREEN + ) + } + } +} + +@Composable +private fun ControlsForRegularScreen() { + val clip by viewModel.clip.collectAsState() + val mediaInfo by viewModel.mediaInfo.collectAsState() + val isScreenshotInputEnabled by viewModel.isScreenshotInputEnabled.collectAsState() + MediaPlayerProgress( + isWavy = mediaInfo.isResumed, + progress = mediaInfo.progress, + onSeek = viewModel::setSeek + ) + Row { + Box(modifier = Modifier.width(4 * 48.dp)) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + TimestampInputs() + ClipControls() + Spacer(Modifier.height(7.dp)) + ClipLength(clip) + } + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + Row { + SeekBackwardShortButton() + PlayPauseButton(iconPadding = 10.dp) + SeekForwardShortButton() + } + Row { + SeekBackwardLongButton() + ScreenshotButton(isEnabled = isScreenshotInputEnabled, onClick = viewModel::takeScreenshot) + SeekForwardLongButton() + } + } + Column(modifier = Modifier.width(192.dp)) { + AudioInput( + viewModel = viewModel, + iconPadding = 12.dp, + mainModifier = Modifier.padding(end = 14.dp), + sliderModifier = Modifier + ) + SpeedInput( + speed = mediaInfo.speed, + onReset = viewModel::resetSpeed, + onChange = viewModel::setSpeed + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + PinButton(iconPadding = 12.dp) + FullscreenEnterButton() + MiniScreenEnterButton() + SidePanelToggleButton() + } + } + } +} + +@Composable +private fun SeekBackwardShortButton() { + val language = LocalLanguage.current + val mediaInfo by viewModel.mediaInfo.collectAsState() + Tooltip( + language.messages.seek5SecondsBackward, + Shortcut.SEEK_SHORT_BACKWARD.symbol + ) { + IconButton(onClick = { + viewModel.setSeek(((mediaInfo.progress.time - 5.seconds) / mediaInfo.progress.length).toFloat()) + }) { + CustomIcon( + icon = if (language is LanguageFa) { + Icons.Custom.Rewind5Fa + } else { + Icons.Custom.Rewind5En + }, + description = Messages.ICO_DSC_REWIND_5_SECONDS + ) + } + } +} + +@Composable +private fun SeekBackwardLongButton() { + val language = LocalLanguage.current + val mediaInfo by viewModel.mediaInfo.collectAsState() + Tooltip( + language.messages.seek30SecondsBackward, + Shortcut.SEEK_LONG_BACKWARD.symbol + ) { + IconButton(onClick = { + viewModel.setSeek(((mediaInfo.progress.time - 30.seconds) / mediaInfo.progress.length).toFloat()) + }) { + Icon( + imageVector = if (language is LanguageFa) { + Icons.Custom.Rewind30Fa + } else { + Icons.Custom.Rewind30En + }, + contentDescription = Messages.ICO_DSC_REWIND_30_SECONDS, + modifier = Modifier.padding(6.dp).size(defaultIconSize) + ) + } + } +} + +@Composable +private fun SeekForwardShortButton() { + val language = LocalLanguage.current + val mediaInfo by viewModel.mediaInfo.collectAsState() + Tooltip( + language.messages.seek5SecondsForward, + Shortcut.SEEK_SHORT_FORWARD.symbol + ) { + IconButton(onClick = { + viewModel.setSeek(((mediaInfo.progress.time + 5.seconds) / mediaInfo.progress.length).toFloat()) + }) { + CustomIcon( + icon = if (language is LanguageFa) { + Icons.Custom.Forward5Fa + } else { + Icons.Custom.Forward5En + }, + description = Messages.ICO_DSC_FORWARD_5_SECONDS + ) + } + } +} + +@Composable +private fun SeekForwardLongButton() { + val language = LocalLanguage.current + val mediaInfo by viewModel.mediaInfo.collectAsState() + Tooltip( + language.messages.seek30SecondsForward, + Shortcut.SEEK_LONG_FORWARD.symbol + ) { + IconButton(onClick = { + viewModel.setSeek(((mediaInfo.progress.time + 30.seconds) / mediaInfo.progress.length).toFloat()) + }) { + Icon( + imageVector = if (language is LanguageFa) { + Icons.Custom.Forward30Fa + } else { + Icons.Custom.Forward30En + }, + contentDescription = Messages.ICO_DSC_FORWARD_30_SECONDS, + modifier = Modifier.padding(6.dp).size(defaultIconSize) + ) + } + } +} + +@Composable +private fun FullscreenEnterButton() { + Tooltip( + LocalLanguage.current.messages.switchToFullscreen, + Shortcut.FULLSCREEN_TOGGLE.symbol, + Shortcut.FULLSCREEN_EXIT.symbol + ) { + IconButton(onClick = viewModel::enterFullscreen) { + CustomIcon( + icon = Icons.Custom.FullScreenEnter, + description = Messages.ICO_DSC_ENTER_FULLSCREEN + ) + } + } +} + +@Composable +private fun MiniScreenEnterButton() { + Tooltip( + LocalLanguage.current.messages.switchToMiniMode, + Shortcut.MINI_MODE_TOGGLE.symbol + ) { + IconButton(onClick = viewModel::toggleMiniScreen) { + CustomIcon( + icon = Icons.Custom.MiniScreen, + description = Messages.ICO_DSC_ENTER_MINI_SCREEN + ) + } + } +} + +@Composable +private fun SidePanelToggleButton() { + val language = LocalLanguage.current + val isSidePanelDisplayed by viewModel.isSidePanelDisplayed.collectAsState() + Tooltip( + if (isSidePanelDisplayed) { + language.messages.hideSidePanel + } else { + language.messages.showSidePanel + }, + Shortcut.SIDE_PANEL_TOGGLE.symbol + ) { + IconButton(onClick = viewModel::toggleSidePanel) { + CustomIcon( + icon = if (isSidePanelDisplayed) Icons.Custom.SidePanelOn else Icons.Custom.SidePanelOff, + description = Messages.ICO_DSC_TOGGLE_SIDE_PANEL + ) + } + } +} + +@Composable +private fun TimestampInputs() { + val clipStartMinuteInput by viewModel.clipStartMinuteInput.collectAsState() + val clipStartSecondInput by viewModel.clipStartSecondInput.collectAsState() + val clipEndMinuteInput by viewModel.clipEndMinuteInput.collectAsState() + val clipEndSecondInput by viewModel.clipEndSecondInput.collectAsState() + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.height(48.dp)) { + TimestampInput( + startRoundness = 24.dp, + minuteInput = clipStartMinuteInput, + secondInput = clipStartSecondInput, + onMinuteChange = viewModel::onClipStartMinuteChanged, + onSecondChange = viewModel::onClipStartSecondChanged, + ) + Spacer(Modifier.width(8.dp)) + TimestampInput( + endRoundness = 24.dp, + minuteInput = clipEndMinuteInput, + secondInput = clipEndSecondInput, + onMinuteChange = viewModel::onClipEndMinuteChanged, + onSecondChange = viewModel::onClipEndSecondChanged, + ) + } +} + +@Composable +private fun ClipControls() { + val language = LocalLanguage.current + val mediaInfo by viewModel.mediaInfo.collectAsState() + val isLoopToggleable by viewModel.isLoopToggleable.collectAsState() + Row { + Tooltip( + language.messages.setClipStart, + Shortcut.CLIP_START_BEGINNING.symbol, + Shortcut.CLIP_START_NOW.symbol + ) { + IconButton(onClick = viewModel::onSetClipStartToNow) { + CustomIcon( + icon = Icons.Custom.StartCircle, + description = Messages.ICO_DSC_SET_CLIP_START_NOW + ) + } + } + Tooltip( + if (mediaInfo.clipToLoop == null) { + language.messages.turnOnClipLoop + } else { + language.messages.turnOffClipLoop + }, + Shortcut.CLIP_LOOP_TOGGLE.symbol + ) { + IconButton( + enabled = isLoopToggleable, + onClick = viewModel::toggleClipLoop + ) { + CustomIcon( + icon = if (mediaInfo.clipToLoop == null) Icons.Custom.LoopOff else Icons.Custom.LoopOn, + description = Messages.ICO_DSC_TOGGLE_CLIP_LOOP + ) + } + } + Tooltip( + language.messages.setClipEnd, + Shortcut.CLIP_END_NOW.symbol, + Shortcut.CLIP_END_FINISH.symbol + ) { + IconButton(onClick = viewModel::onSetClipEndToNow) { + CustomIcon( + icon = Icons.Custom.EndCircle, + description = Messages.ICO_DSC_SET_CLIP_END_NOW + ) + } + } + } +} + +@Composable +private fun ControlsForMiniScreen() { + val mediaInfo by viewModel.mediaInfo.collectAsState() + MediaPlayerProgress( + isWavy = mediaInfo.isResumed, + progress = mediaInfo.progress, + onSeek = viewModel::setSeek + ) + Row( + modifier = Modifier.fillMaxWidth().offset(y = (-8).dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + PlayPauseButton(iconPadding = 0.dp) + AudioInput( + viewModel = viewModel, + iconPadding = 0.dp, + mainModifier = Modifier, + sliderModifier = Modifier.width(100.dp).offset(x = (-8).dp) + ) + PinButton(iconPadding = 0.dp) + Tooltip( + LocalLanguage.current.messages.switchToNormalMode, + Shortcut.MINI_MODE_TOGGLE.symbol + ) { + IconButton(onClick = viewModel::toggleMiniScreen) { + Icon( + imageVector = Icons.Custom.RegularScreen, + contentDescription = Messages.ICO_DSC_ENTER_REGULAR_SCREEN, + modifier = Modifier.size(defaultIconSize) + ) + } + } + } +} + +@Composable +private fun PlayPauseButton(iconPadding: Dp) { + val mediaInfo by viewModel.mediaInfo.collectAsState() + Tooltip( + if (mediaInfo.isResumed) { + LocalLanguage.current.messages.pauseMediaPlayback + } else { + LocalLanguage.current.messages.resumeMediaPlayback + }, + Shortcut.PLAY_PAUSE.symbol + ) { + IconButton(onClick = viewModel::toggleResume) { + Icon( + imageVector = if (mediaInfo.isResumed) Icons.Custom.Pause else Icons.Custom.Play, + contentDescription = Messages.ICO_DSC_PLAY_PAUSE, + modifier = Modifier.padding(iconPadding).size(defaultIconSize + 2.dp) + ) + } + } +} + +@Composable +private fun PinButton(iconPadding: Dp) { + val isAlwaysOnTop by viewModel.isAlwaysOnTop.collectAsState() + Tooltip( + if (isAlwaysOnTop) { + LocalLanguage.current.messages.unPinAppWindow + } else { + LocalLanguage.current.messages.pinAppWindow + }, + Shortcut.PIN_TOGGLE.symbol + ) { + IconButton(onClick = viewModel::toggleIsAlwaysOnTop) { + Icon( + imageVector = if (isAlwaysOnTop) Icons.Custom.PinOn else Icons.Custom.PinOff, + contentDescription = Messages.ICO_DSC_TOGGLE_ALWAYS_ON_TOP, + modifier = Modifier.padding(iconPadding).size(defaultIconSize) + ) + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/panel/SettingsPanel.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/panel/SettingsPanel.kt new file mode 100644 index 0000000..01b3c39 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/panel/SettingsPanel.kt @@ -0,0 +1,155 @@ +package ir.mahozad.cutcon.ui.panel + +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ir.mahozad.cutcon.* +import ir.mahozad.cutcon.localization.Language +import ir.mahozad.cutcon.localization.LanguageEn +import ir.mahozad.cutcon.localization.LanguageFa +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.localization.Messages.Companion.ICO_DSC_SETTINGS_ASPECT_RATIO +import ir.mahozad.cutcon.model.Labeled +import ir.mahozad.cutcon.model.Toggle +import ir.mahozad.cutcon.ui.icon.* +import ir.mahozad.cutcon.ui.widget.RadioGroup + +private enum class LanguageEnum : Labeled { + PERSIAN { + override val label: (Language) -> String = { + it.messages.radLblLanguagePersian + } + }, + ENGLISH { + override val label: (Language) -> String = { + it.messages.radLblLanguageEnglish + } + } +} + +@Composable +fun SettingsPanel() { + Column { + val language = LocalLanguage.current + val scope = rememberCoroutineScope() + val theme by viewModel.theme.collectAsState() + val calendar by viewModel.calendar.collectAsState() + val aspectRatio by viewModel.aspectRatio.collectAsState() + val isFinishSoundEnabled by viewModel.isFinishSoundEnabled.collectAsState() + val isScreenshotSoundEnabled by viewModel.isScreenshotSoundEnabled.collectAsState() + val isInterlacedFixEnabled by viewModel.isInterlacedFixEnabled.collectAsState() + // Overrides the font for when the language is not Persian (to show the Persian words with this font) + Spacer(Modifier.height(8.dp)) + CompositionLocalProvider( + LocalTextStyle provides LocalTextStyle.current.copy(fontFamily = LanguageFa.fontFamily) + ) { + Settings( + value = if (language is LanguageFa) { + LanguageEnum.PERSIAN + } else { + LanguageEnum.ENGLISH + }, + title = language.messages.txtLblLanguage, + icon = Icons.Custom.Glob, + iconDescription = Messages.ICO_DSC_SETTINGS_LANGUAGE, + onChange = { + viewModel.setLanguage( + if (it == LanguageEnum.PERSIAN) { + LanguageFa + } else { + LanguageEn + } + ) + } + ) + } + Settings( + value = theme, + title = language.messages.txtLblTheme, + icon = Icons.Custom.Theme, + iconDescription = Messages.ICO_DSC_SETTINGS_THEME, + onChange = viewModel::setTheme + ) + Settings( + value = calendar, + title = language.messages.txtLblCalendar, + icon = Icons.Custom.Date, + iconDescription = Messages.ICO_DSC_SETTINGS_CALENDAR, + onChange = viewModel::setCalendar + ) + Settings( + value = aspectRatio, + title = language.messages.txtLblAspectRatio, + icon = Icons.Custom.AspectRatio, + iconDescription = ICO_DSC_SETTINGS_ASPECT_RATIO, + onChange = viewModel::setAspectRatio + ) + Settings( + value = if (isInterlacedFixEnabled) Toggle.ENABLED else Toggle.DISABLED, + title = language.messages.txtLblInterlacedFix, + icon = Icons.Custom.Interlaced, + iconDescription = Messages.ICO_DSC_SETTINGS_INTERLACED_FIX, + onChange = { viewModel.setIsInterlacedFixEnabled(it == Toggle.ENABLED) } + ) + Settings( + value = if (isScreenshotSoundEnabled) Toggle.ENABLED else Toggle.DISABLED, + title = language.messages.txtLblScreenshotSound, + icon = Icons.Custom.Shutter, + iconDescription = Messages.ICO_DSC_SETTINGS_SCREENSHOT_SOUND, + onChange = { viewModel.setIsScreenshotSoundEnabled(it == Toggle.ENABLED) } + ) + Settings( + value = if (isFinishSoundEnabled) Toggle.ENABLED else Toggle.DISABLED, + title = language.messages.txtLblFinishSound, + icon = Icons.Custom.Notification, + iconDescription = Messages.ICO_DSC_SETTINGS_FINISH_SOUND, + onChange = { viewModel.setIsFinishSoundEnabled(it == Toggle.ENABLED) } + ) + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = scope::openAppLogFolder, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = language.messages.btnLblOpenAppLogFolder, fontSize = defaultFontSize) + } + } +} + +@Composable +private fun Settings( + value: T, + title: String, + icon: ImageVector, + iconDescription: String, + onChange: (T) -> Unit +) where T : Enum, T : Labeled { + val language = LocalLanguage.current + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = iconDescription, + modifier = Modifier.size(defaultIconSize) + ) + Spacer(Modifier.width(4.dp)) + Text( + text = title, + fontSize = (defaultFontSize.value - 1).sp, + modifier = Modifier.width(if (language == LanguageFa) 96.dp else 96.dp) + ) + RadioGroup( + value = value, + isEnabled = true, + modifier = Modifier.fillMaxWidth(), + weights = { 1f }, + onChange = onChange + ) + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/panel/SidePanel.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/panel/SidePanel.kt new file mode 100644 index 0000000..30e6c6c --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/panel/SidePanel.kt @@ -0,0 +1,80 @@ +package ir.mahozad.cutcon.ui.panel + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.FrameWindowScope +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultIconSize +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.ui.icon.Config +import ir.mahozad.cutcon.ui.icon.Icons +import ir.mahozad.cutcon.ui.icon.Info +import ir.mahozad.cutcon.ui.icon.Settings +import ir.mahozad.cutcon.viewModel + +@Composable +fun FrameWindowScope.SidePanel() { + val sidePanelSelectedTabIndex by viewModel.sidePanelSelectedTabIndex.collectAsState() + Column { + TabRow( + selectedTabIndex = sidePanelSelectedTabIndex, + backgroundColor = MaterialTheme.colors.surface, + modifier = Modifier.height(36.dp) + ) { + Tab( + selected = sidePanelSelectedTabIndex == 0, + text = { + Icon( + imageVector = Icons.Custom.Config, + contentDescription = Messages.ICO_DSC_PANEL_CONFIG, + modifier = Modifier.size(defaultIconSize) + ) + }, + onClick = { viewModel.setSidePanelSelectedTabIndex(0) } + ) + Tab( + selected = sidePanelSelectedTabIndex == 1, + text = { + Icon( + imageVector = Icons.Custom.Settings, + contentDescription = Messages.ICO_DSC_PANEL_SETTINGS, + modifier = Modifier.size(defaultIconSize) + ) + }, + onClick = { viewModel.setSidePanelSelectedTabIndex(1) } + ) + Tab( + selected = sidePanelSelectedTabIndex == 2, + text = { + Icon( + imageVector = Icons.Custom.Info, + contentDescription = Messages.ICO_DSC_PANEL_ABOUT, + modifier = Modifier.size(defaultIconSize) + ) + }, + onClick = { viewModel.setSidePanelSelectedTabIndex(2) } + ) + } + Spacer(Modifier.height(8.dp)) + CompositionLocalProvider(LocalLayoutDirection provides LocalLanguage.current.layoutDirection) { + when (sidePanelSelectedTabIndex) { + 0 -> ConfigPanel(viewModel) + 1 -> SettingsPanel() + 2 -> AboutPanel() + } + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/theme/Theme.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/theme/Theme.kt new file mode 100644 index 0000000..2a1c6ee --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/theme/Theme.kt @@ -0,0 +1,47 @@ +package ir.mahozad.cutcon.ui.theme + +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import ir.mahozad.cutcon.LocalLanguage + +private val LightColors = lightColors( + primary = Color(0xff208bb2), + secondary = Color(0xff208bb2), + primaryVariant = Color(0xff1faedc) +) + +private val DarkColors = darkColors( + primary = Color(0xff1faedc), + secondary = Color(0xff1faedc), + primaryVariant = Color(0xff208bb2) +) + +@Composable +fun AppTheme( + isDark: Boolean = false, + content: @Composable () -> Unit +) { + val language = LocalLanguage.current + MaterialTheme( + colors = if (isDark) DarkColors else LightColors, + typography = MaterialTheme.typography.copy( + // For buttons because they have hard-coded text style + button = MaterialTheme.typography.button.copy(fontFamily = language.fontFamily), + // For dropdown items because they have hard-coded text style + subtitle1 = MaterialTheme.typography.subtitle1.copy(fontFamily = language.fontFamily) + ) + ) { + CompositionLocalProvider( + LocalTextStyle provides LocalTextStyle.current.copy(fontFamily = language.fontFamily), + LocalLayoutDirection provides language.layoutDirection + ) { + content() + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/AudioInput.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/AudioInput.kt new file mode 100644 index 0000000..1c2f21e --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/AudioInput.kt @@ -0,0 +1,70 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Slider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.MainViewModel +import ir.mahozad.cutcon.defaultIconSize +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.model.Shortcut +import ir.mahozad.cutcon.ui.icon.* + +@Composable +fun AudioInput( + viewModel: MainViewModel, + iconPadding: Dp, + mainModifier: Modifier, + sliderModifier: Modifier, +) { + val mediaInfo by viewModel.mediaInfo.collectAsState() + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = mainModifier + ) { + Tooltip( + if (mediaInfo.isAudioMuted) { + LocalLanguage.current.messages.unMuteMediaAudio + } else { + LocalLanguage.current.messages.muteMediaAudio + }, + Shortcut.AUDIO_MUTE_TOGGLE.symbol, + Shortcut.AUDIO_DECREASE.symbol, + Shortcut.AUDIO_INCREASE.symbol + ) { + IconButton(onClick = viewModel::toggleAudioMute) { + Icon( + imageVector = if (mediaInfo.isAudioMuted) { + Icons.Custom.VolumeMuted + } else if (mediaInfo.audioVolume > 0.66) { + Icons.Custom.VolumeFull + } else if (mediaInfo.audioVolume > 0.05) { + Icons.Custom.VolumeHalf + } else { + Icons.Custom.VolumeEmpty + }, + contentDescription = Messages.ICO_DSC_AUDIO_VOLUME, + modifier = Modifier.padding(iconPadding).size(defaultIconSize) + ) + } + } + // TODO: Make the slider change volume in logarithmic manner + // See https://www.dr-lex.be/info-stuff/volumecontrols.html + // and https://ux.stackexchange.com/q/79672/117386 + // and https://dcordero.me/posts/logarithmic_volume_control.html + Slider( + value = mediaInfo.audioVolume, + onValueChange = viewModel::setVolume, + modifier = sliderModifier + ) + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/ClipLength.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/ClipLength.kt new file mode 100644 index 0000000..3ccfe7b --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/ClipLength.kt @@ -0,0 +1,54 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.animation.AnimatedContent +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultChipHeight +import ir.mahozad.cutcon.defaultDurationConverter +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.model.Clip +import kotlin.time.Duration.Companion.seconds + +@Preview +@Composable +private fun ClipLengthPreview() { + ClipLength(Clip(78.seconds, 139.seconds)) +} + +@Composable +fun ClipLength(clip: Clip) { + val language = LocalLanguage.current + CompositionLocalProvider(LocalLayoutDirection provides language.layoutDirection) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .width(152.dp) + .height(defaultChipHeight) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.5f), RoundedCornerShape(percent = 50)) + ) { + Text(text = language.messages.txtLblClipLength, fontSize = defaultFontSize) + Spacer(Modifier.width(4.dp)) + // See https://semicolonspace.com/jetpack-compose-animate-content-changes/ + AnimatedContent(targetState = clip) { + Text( + text = "${/* For minus sign in a negative duration in RTL layouts to stay on the left side */"\u202D"}${ + language.localizeDigits(defaultDurationConverter.format(it.duration, numberOfParts = 2)) + }", + fontSize = defaultFontSize + ) + } + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/CoverInput.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/CoverInput.kt new file mode 100644 index 0000000..39878a6 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/CoverInput.kt @@ -0,0 +1,564 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.FrameWindowScope +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.defaultIconSize +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.model.* +import ir.mahozad.cutcon.ui.dialog.showOpenFileDialog +import ir.mahozad.cutcon.ui.icon.Cover +import ir.mahozad.cutcon.ui.icon.Delete +import ir.mahozad.cutcon.ui.icon.Icons +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.net.URI +import java.nio.file.Path +import kotlin.io.path.toPath +import kotlin.math.roundToInt + +const val COVER_PREVIEW_SIZE = 66f + +private val height = 92.dp + +/** + * Input for video watermark or audio album art. + */ +@Composable +fun FrameWindowScope.CoverInput( + isEnabled: Boolean, + image: ImageBitmap?, + options: CoverOptions, + source: Source, + targetFormat: Format, + lastOpenDirectory: Path?, + modifier: Modifier, + onFileChange: (Path?) -> Unit, + onScaleChange: (Float) -> Unit, + onOpacityChange: (Float) -> Unit, + onPositionChange: (WatermarkPosition) -> Unit +) { + val language = LocalLanguage.current + if ( + (targetFormat == Format.RAW) || + (targetFormat == Format.MP4 && source.mediaType != Source.MediaType.VIDEO) + ) { + PromptBox( + isEnabled = isEnabled, + modifier = modifier, + text = if (targetFormat == Format.RAW) { + language.messages.txtLblRawCoverNotSupported + } else if (source.mediaType == Source.MediaType.AUDIO) { + language.messages.txtLblAudioFileWatermarkNotSupported + } else if (source.mediaType == Source.MediaType.IMAGE) { + language.messages.txtLblImageFileWatermarkNotSupported + } else { + language.messages.txtLblMiscFileWatermarkNotSupported + } + ) + } else if (image == null) { + SelectBox( + isEnabled = isEnabled, + targetFormat = targetFormat, + onFileChange = onFileChange, + modifier = modifier, + lastOpenDirectory = lastOpenDirectory + ) + } else { + ConfigBox( + isEnabled = isEnabled, + image = image, + options = options, + modifier = modifier, + targetFormat = targetFormat, + onScaleChange = onScaleChange, + onOpacityChange = onOpacityChange, + onPositionChange = onPositionChange, + onRemoveRequest = { onFileChange(null) } + ) + } +} + +@Composable +private fun PromptBox(isEnabled: Boolean, text: String, modifier: Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .height(height) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.BackgroundOpacity)) + ) { + Text( + text = text, + fontSize = (defaultFontSize.value - 1).sp, + modifier = Modifier.padding(horizontal = 12.dp), + color = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun FrameWindowScope.SelectBox( + isEnabled: Boolean, + targetFormat: Format, + lastOpenDirectory: Path?, + modifier: Modifier, + onFileChange: (Path?) -> Unit +) { + val language = LocalLanguage.current + val scope = rememberCoroutineScope() + val primaryColor = MaterialTheme.colors.primary + val primaryVariantColor = MaterialTheme.colors.primaryVariant + var isDragging by remember { mutableStateOf(false) } + var isDialogDisplayed by remember { mutableStateOf(false) } + /** + * See [SaveAsInput] for more information about this. + */ + var dialogClosedTime by remember { mutableLongStateOf(0) } + Surface( + color = if (!isEnabled) { + Color.LightGray.copy(ContentAlpha.disabled) + } else if (isDragging) { + MaterialTheme.colors.primary.copy(alpha = 0.3f) + } else { + MaterialTheme.colors.primary.copy(alpha = 0.1f) + }, + modifier = modifier + .height(height) + .drawBehind { + val stroke = Stroke( + width = if (isDragging) 2f else 1f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(5f, 5f), 0f) + ) + drawRoundRect( + color = if (!isEnabled) { + Color.LightGray + } else if (isDragging) { + primaryVariantColor + } else { + primaryColor + }, + style = stroke, + cornerRadius = CornerRadius(4.dp.toPx()) + ) + } + .clickable(enabled = isEnabled) { + if ( + isDialogDisplayed || + System.currentTimeMillis() - dialogClosedTime < 300 + ) { + return@clickable + } + dialogClosedTime = System.currentTimeMillis() + isDialogDisplayed = true + scope.launch(Dispatchers.IO) { + window.showOpenFileDialog( + language = language, + title = if (targetFormat == Format.MP4) { + language.messages.dlgTitSelectWatermark + } else { + language.messages.dlgTitSelectAlbumArt + }, + startingDirectory = lastOpenDirectory, + approveButtonLabel = language.messages.btnLblApproveSelectedFile, + approveButtonTooltip = language.messages.btnTlpApproveSelectedFile, + fileExtensionDescription = language.messages.txtLblCoverSupportedTypesDescription, + fileExtensions = supportedImageFileExtensions + ) + .let(onFileChange) + dialogClosedTime = System.currentTimeMillis() + isDialogDisplayed = false + } + } + .onExternalDrag( + enabled = isEnabled, + onDragStart = { isDragging = true }, + onDragExit = { isDragging = false }, + onDrop = { state -> + isDragging = false + val dragData = state.dragData + if (dragData is DragData.FilesList) { + dragData + .readFiles() + .first() + .let(::URI) + .let(URI::toPath) + .let(onFileChange) + } + } + ) + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(start = 14.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (isDialogDisplayed) { + Spinner(modifier = Modifier.size(defaultIconSize).padding(6.dp)) + } else { + Icon( + imageVector = Icons.Custom.Cover, + contentDescription = Messages.ICO_DSC_ADD_COVER, + modifier = Modifier.size(defaultIconSize), + tint = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + } + Spacer(Modifier.width(4.dp)) + Text( + text = if (targetFormat == Format.MP4) { + language.messages.txtLblSelectWatermark + } else { + language.messages.txtLblSelectAlbumArt + }, + fontSize = (defaultFontSize.value - 1).sp, + color = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + } + Spacer(Modifier.height(2.dp)) + Text( + text = language.messages.txtLblDragFileHere, + fontSize = (defaultFontSize.value - 3).sp, + color = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + Spacer(Modifier.height(4.dp)) + Text( + text = if (targetFormat == Format.MP4) { + language.messages.txtLblHasNoDefault + } else { + language.messages.txtLblHasDefault + }, + fontSize = (defaultFontSize.value - 3).sp, + color = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + } + } +} + +@Composable +private fun ConfigBox( + isEnabled: Boolean, + image: ImageBitmap, + options: CoverOptions, + targetFormat: Format, + modifier: Modifier, + onScaleChange: (Float) -> Unit, + onOpacityChange: (Float) -> Unit, + onPositionChange: (WatermarkPosition) -> Unit, + onRemoveRequest: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.height(height) + ) { + CoverImage( + image = image, + isEnabled = isEnabled, + onRemoveRequest = onRemoveRequest + ) + if (targetFormat == Format.MP4) { + WatermarkConfig( + isEnabled = isEnabled, + options = options, + onPositionChange = onPositionChange, + onOpacityChange = onOpacityChange, + onScaleChange = onScaleChange + ) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun CoverImage( + isEnabled: Boolean, + image: ImageBitmap, + onRemoveRequest: () -> Unit +) { + var isCoverHovered by remember { mutableStateOf(false) } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(70.dp) + .onPointerEvent(eventType = PointerEventType.Enter) { isCoverHovered = true } + .onPointerEvent(eventType = PointerEventType.Exit) { isCoverHovered = false } + ) { + // See https://stackoverflow.com/questions/64742457/how-to-load-image-in-kotlin-compose-desktop + Image( + bitmap = image, + contentDescription = Messages.ICO_DSC_COVER_PREVIEW, + colorFilter = if (!isEnabled) { + ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0F) }) + } else { + null + }, + modifier = Modifier + .size(COVER_PREVIEW_SIZE.dp) + .border(Dp.Hairline, MaterialTheme.colors.onSurface.copy(alpha = 0.5f)) + .blur(if (isCoverHovered && isEnabled) 8.dp else 0.dp) + .drawBehind { + // Draws checkerboard in case the image contains transparent parts + val tileSize = 4f + val tileCount = (size.width / tileSize).toInt() + val darkColor = Color.hsl(0f, 0f, 0.8f) + val lightColor = Color.hsl(1f, 1f, 1f) + for (i in 0..tileCount) { + for (j in 0..tileCount) { + drawRect( + topLeft = Offset(i * tileSize, j * tileSize), + color = if ((i + j) % 2 == 0) darkColor else lightColor, + size = Size(tileSize, tileSize) + ) + } + } + } + ) + if (isCoverHovered && isEnabled) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background(MaterialTheme.colors.surface.copy(alpha = 0.6f)) + .size(COVER_PREVIEW_SIZE.dp) + .clickable { + onRemoveRequest() + isCoverHovered = false + } + ) { + CustomIcon( + icon = Icons.Custom.Delete, + description = Messages.ICO_DSC_REMOVE_COVER + ) + } + } + } +} + +@Composable +private fun WatermarkConfig( + isEnabled: Boolean, + options: CoverOptions, + onPositionChange: (WatermarkPosition) -> Unit, + onOpacityChange: (Float) -> Unit, + onScaleChange: (Float) -> Unit +) { + val language = LocalLanguage.current + WatermarkPlacement( + isEnabled = isEnabled, + position = options.position, + onChange = onPositionChange + ) + Column(verticalArrangement = Arrangement.Center) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = language.messages.txtLblAlpha, + fontSize = (defaultFontSize.value - 1).sp, + modifier = Modifier.width(40.dp), + color = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + CustomTextField( + isEnabled = isEnabled, + value = (options.opacity * 100).roundToInt(), + max = 100 + ) { + onOpacityChange(it / 100f) + } + } + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = language.messages.txtLblScale, + fontSize = (defaultFontSize.value - 1).sp, + modifier = Modifier.width(40.dp), + color = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + CustomTextField( + isEnabled = isEnabled, + value = (options.scale * 100).roundToInt(), + max = 999 + ) { + onScaleChange(it / 100f) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun WatermarkPlacement( + isEnabled: Boolean, + position: WatermarkPosition, + onChange: (WatermarkPosition) -> Unit +) { + // Ensures the buttons are left-to-right regardless of the language direction + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + FlowRow(maxItemsInEachRow = 3, modifier = Modifier.selectableGroup()) { + for (i in 0..8) { + WatermarkPlacementOption(isEnabled = isEnabled, isSelected = (position.ordinal == i)) { + onChange(WatermarkPosition.entries[i]) + } + } + } + } +} + +@Composable +private fun WatermarkPlacementOption( + isEnabled: Boolean, + isSelected: Boolean, + onClick: () -> Unit +) { + RadioButton( + selected = isSelected, + enabled = isEnabled, + onClick = null, // For accessibility with screen readers + modifier = Modifier + .selectable( + selected = isSelected, + enabled = isEnabled, + interactionSource = remember(::MutableInteractionSource), + indication = null, + onClick = onClick + ) + ) +} + +// FIXME: Duplicate of code in timestamp input to some extent +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun CustomTextField(isEnabled: Boolean, value: Int, max: Int, onValueChange: (Int) -> Unit) { + val language = LocalLanguage.current + val colors = TextFieldDefaults.textFieldColors() + var input by remember { mutableStateOf(value.toString()) } + val interactionSource = remember(::MutableInteractionSource) + BasicTextField( + value = language.localizeDigits(input), + onValueChange = { newString -> + val intValue = newString.toIntOrNull() ?: -1 + input = (newString.takeIf { intValue in 0..max || it.isBlank() } ?: input).take(3) + newString.toIntOrNull()?.takeIf { it in 1..max }?.let(onValueChange) + }, + cursorBrush = SolidColor(MaterialTheme.colors.onSurface), + textStyle = LocalTextStyle.current.copy( + fontSize = 11.sp, + textAlign = TextAlign.Center, + color = LocalContentColor.current + ), + interactionSource = interactionSource, + visualTransformation = SuffixTransformer2(language.messages.txtLblPercentSign), + enabled = isEnabled, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + // To modify the width, also, modify the content padding start and end values in the TextFieldDecoration below + .width(44.dp) + .height(24.dp) + .clip(RoundedCornerShape(percent = 50)) + .background(colors.backgroundColor(true).value) + // Should consume (true) (except for TAB) to prevent bugs with app custom shortcuts + // See https://github.com/JetBrains/compose-multiplatform/issues/1925 + .onKeyEvent { it.key != Key.Tab } + ) { + TextFieldDefaults.TextFieldDecorationBox( + value = language.localizeDigits(input), + innerTextField = it, + singleLine = true, + enabled = isEnabled, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + // Keeps horizontal paddings but changes the vertical + contentPadding = TextFieldDefaults.textFieldWithoutLabelPadding( + top = 0.dp, + bottom = 0.dp, + start = 0.dp, + end = 0.dp + ) + ) + } +} + +private class SuffixTransformer2(private val suffix: String) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val result = text + AnnotatedString(suffix) + val textWithSuffixMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + override fun transformedToOriginal(offset: Int): Int { + return if (text.isEmpty()) { + 0 + } else if (offset < text.length) { + offset + } else { + text.length + } + } + } + return TransformedText(result, textWithSuffixMapping) + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/CustomIconButton.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/CustomIconButton.kt new file mode 100644 index 0000000..2c1bc39 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/CustomIconButton.kt @@ -0,0 +1,37 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultIconSize + +@Composable +fun CustomIcon( + icon: ImageVector, + description: String, + tint: Color? = null +) { + if (tint == null) { + Icon( + imageVector = icon, + contentDescription = description, + modifier = Modifier + .padding(12.dp) + .size(defaultIconSize) + ) + } else { + Icon( + imageVector = icon, + tint = tint, + contentDescription = description, + modifier = Modifier + .padding(12.dp) + .size(defaultIconSize) + ) + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Display.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Display.kt new file mode 100644 index 0000000..9b9039d --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Display.kt @@ -0,0 +1,96 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.calculateMaxSizeInFrame +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.model.AspectRatio + +@Composable +fun Display( + image: ImageBitmap?, + aspectRatio: AspectRatio, + modifier: Modifier +) { + var frameSize by remember { mutableStateOf(IntSize.Zero) } + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .onGloballyPositioned { frameSize = it.size } + ) { + if (image != null) { + val imageSize = calculateMaxSizeInFrame( + frameWidth = frameSize.width.dp, + frameHeight = frameSize.height.dp, + desiredAspectRatio = aspectRatio.ratio ?: (image.width.toFloat() / image.height) + ) + Image( + bitmap = image, + // Clipping is needed here as well for when the image is smaller than the frame + modifier = Modifier.size(imageSize).clip(RoundedCornerShape(8.dp)), + alignment = Alignment.Center, + contentScale = ContentScale.FillBounds, + filterQuality = FilterQuality.Low, + contentDescription = Messages.IMG_DSC_DISPLAY_IMAGE + ) + } + TransitionEffect(isTransitioning = image == null) + } +} + +@Composable +private fun TransitionEffect(isTransitioning: Boolean) { + val heightFactor by animateFloatAsState(if (isTransitioning) 1f else 0f) + val clipShape = remember(heightFactor) { + GenericShape { size, _ -> + addRect( + Rect( + top = 0f, + left = 0f, + right = size.width, + bottom = size.height * heightFactor + ) + ) + } + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .clip(clipShape) + .background( + if (MaterialTheme.colors.isLight) { + Color.hsv(0f, 0f, 0.88f) + } else { + Color.hsv(0f, 0f, 0.18f) + } + ) + ) { + Image( + painter = painterResource("logo.svg"), + contentDescription = Messages.ICO_DSC_LOGO, + modifier = Modifier.size(128.dp) + ) + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Dropdown.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Dropdown.kt new file mode 100644 index 0000000..35583a9 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Dropdown.kt @@ -0,0 +1,89 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.defaultInputHeight +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.ui.icon.ArrowDown +import ir.mahozad.cutcon.ui.icon.Icons + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun CustomExposedDropDown( + label: @Composable BoxScope.() -> Unit, + items: List<@Composable RowScope.() -> Unit>, + isEnabled: Boolean, + modifier: Modifier = Modifier, + startRoundness: Dp, + endRoundness: Dp, + onChange: (Int) -> Unit, +) { + var isExpanded by remember { mutableStateOf(false) } + val iconRotation by animateFloatAsState(if (isExpanded) 180f else 0f) + + ExposedDropdownMenuBox( + expanded = isExpanded, + modifier = modifier.width(IntrinsicSize.Min), + onExpandedChange = { if (isEnabled) isExpanded = it } + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = defaultInputHeight) + .clip( + RoundedCornerShape( + topStart = startRoundness, + bottomStart = startRoundness, + topEnd = endRoundness, + bottomEnd = endRoundness + ) + ) + .background(color = MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.BackgroundOpacity)) + .clickable(enabled = isEnabled, onClick = {}) + ) { + label() + Icon( + imageVector = Icons.Custom.ArrowDown, + contentDescription = Messages.ICO_DSC_OPEN_DROPDOWN, + tint = if (isEnabled) { + MaterialTheme.colors.primary + } else { + MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + }, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 12.dp) + .size(12.dp) + .rotate(iconRotation) + ) + } + if (isEnabled) { + ExposedDropdownMenu( + expanded = isExpanded, + onDismissRequest = { isExpanded = false }, + ) { + items.forEachIndexed { i, item -> + DropdownMenuItem( + content = item, + onClick = { + onChange(i) + isExpanded = false + } + ) + } + } + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/FormatInput.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/FormatInput.kt new file mode 100644 index 0000000..0c47ce2 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/FormatInput.kt @@ -0,0 +1,28 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import ir.mahozad.cutcon.model.Format + +@Preview +@Composable +private fun FormatInputPreview() { + FormatInput(isEnabled = true, format = Format.MP4, onChange = {}) +} + +@Composable +fun FormatInput( + isEnabled: Boolean, + format: Format, + onChange: (Format) -> Unit +) { + RadioGroup( + value = format, + isEnabled = isEnabled, + modifier = Modifier.fillMaxWidth(), + weights = { if (it == 0) 1.1f else if (it == 1) 0.9f else 1.2f }, + onChange = onChange + ) +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/IntroInput.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/IntroInput.kt new file mode 100644 index 0000000..3af04cd --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/IntroInput.kt @@ -0,0 +1,488 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.FrameWindowScope +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.defaultIconSize +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.model.* +import ir.mahozad.cutcon.ui.dialog.showOpenFileDialog +import ir.mahozad.cutcon.ui.icon.Curtain +import ir.mahozad.cutcon.ui.icon.Delete +import ir.mahozad.cutcon.ui.icon.Icons +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.net.URI +import java.nio.file.Path +import kotlin.io.path.toPath +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +const val INTRO_PREVIEW_SIZE = 66f + +private val height = 92.dp + +@Composable +fun FrameWindowScope.IntroInput( + isEnabled: Boolean, + image: ImageBitmap?, + options: IntroOptions, + source: Source, + targetFormat: Format, + lastOpenDirectory: Path?, + modifier: Modifier, + onFileChange: (Path?) -> Unit, + onDurationChange: (Duration) -> Unit, + onBackgroundColorChange: (Color) -> Unit, +) { + val language = LocalLanguage.current + if ( + (targetFormat == Format.MP3) || + (targetFormat == Format.RAW) || + (targetFormat == Format.MP4 && source.mediaType != Source.MediaType.VIDEO) + ) { + PromptBox( + isEnabled = isEnabled, + modifier = modifier, + text = if (targetFormat == Format.MP3) { + language.messages.txtLblMp3IntroNotSupported + } else if (targetFormat == Format.RAW) { + language.messages.txtLblRawIntroNotSupported + } else if (source.mediaType == Source.MediaType.AUDIO) { + language.messages.txtLblAudioFileIntroNotSupported + } else if (source.mediaType == Source.MediaType.IMAGE) { + language.messages.txtLblImageFileIntroNotSupported + } else { + language.messages.txtLblMiscFileIntroNotSupported + } + ) + } else if (image == null) { + SelectBox( + isEnabled = isEnabled, + targetFormat = targetFormat, + onFileChange = onFileChange, + modifier = modifier, + lastOpenDirectory = lastOpenDirectory + ) + } else { + ConfigBox( + isEnabled = isEnabled, + image = image, + options = options, + modifier = modifier, + onDurationChange = onDurationChange, + onBackgroundColorChange = onBackgroundColorChange, + onRemoveRequest = { onFileChange(null) } + ) + } +} + +@Composable +private fun PromptBox(isEnabled: Boolean, text: String, modifier: Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .height(height) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.BackgroundOpacity)) + ) { + Text( + text = text, + fontSize = (defaultFontSize.value - 1).sp, + modifier = Modifier.padding(horizontal = 12.dp), + color = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun FrameWindowScope.SelectBox( + isEnabled: Boolean, + targetFormat: Format, + lastOpenDirectory: Path?, + modifier: Modifier, + onFileChange: (Path?) -> Unit +) { + val language = LocalLanguage.current + val scope = rememberCoroutineScope() + val primaryColor = MaterialTheme.colors.primary + val primaryVariantColor = MaterialTheme.colors.primaryVariant + var isDragging by remember { mutableStateOf(false) } + var isDialogDisplayed by remember { mutableStateOf(false) } + /** + * See [SaveAsInput] for more information about this. + */ + var dialogClosedTime by remember { mutableLongStateOf(0) } + Surface( + color = if (!isEnabled) { + Color.LightGray.copy(ContentAlpha.disabled) + } else if (isDragging) { + MaterialTheme.colors.primary.copy(alpha = 0.3f) + } else { + MaterialTheme.colors.primary.copy(alpha = 0.1f) + }, + modifier = modifier + .height(height) + .drawBehind { + val stroke = Stroke( + width = if (isDragging) 2f else 1f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(5f, 5f), 0f) + ) + drawRoundRect( + color = if (!isEnabled) { + Color.LightGray + } else if (isDragging) { + primaryVariantColor + } else { + primaryColor + }, + style = stroke, + cornerRadius = CornerRadius(4.dp.toPx()) + ) + } + .clickable(enabled = isEnabled) { + if ( + isDialogDisplayed || + System.currentTimeMillis() - dialogClosedTime < 300 + ) { + return@clickable + } + dialogClosedTime = System.currentTimeMillis() + isDialogDisplayed = true + scope.launch(Dispatchers.IO) { + window.showOpenFileDialog( + language = language, + title = language.messages.dlgTitSelectIntroImage, + startingDirectory = lastOpenDirectory, + approveButtonLabel = language.messages.btnLblApproveSelectedFile, + approveButtonTooltip = language.messages.btnTlpApproveSelectedFile, + fileExtensionDescription = language.messages.txtLblIntroSupportedTypesDescription, + fileExtensions = supportedImageFileExtensions + ) + .let(onFileChange) + dialogClosedTime = System.currentTimeMillis() + isDialogDisplayed = false + } + } + .onExternalDrag( + enabled = isEnabled, + onDragStart = { isDragging = true }, + onDragExit = { isDragging = false }, + onDrop = { state -> + isDragging = false + val dragData = state.dragData + if (dragData is DragData.FilesList) { + dragData + .readFiles() + .first() + .let(::URI) + .let(URI::toPath) + .let(onFileChange) + } + } + ) + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(start = 14.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (isDialogDisplayed) { + Spinner(modifier = Modifier.size(defaultIconSize).padding(6.dp)) + } else { + Icon( + imageVector = Icons.Custom.Curtain, + contentDescription = Messages.ICO_DSC_ADD_INTRO, + modifier = Modifier.size(defaultIconSize), + tint = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + } + Spacer(Modifier.width(4.dp)) + Text( + text = language.messages.txtLblSelectIntroImage, + fontSize = (defaultFontSize.value - 1).sp, + color = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + } + Spacer(Modifier.height(2.dp)) + Text( + text = language.messages.txtLblDragFileHere, + fontSize = (defaultFontSize.value - 3).sp, + color = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + Spacer(Modifier.height(4.dp)) + Text( + text = if (targetFormat == Format.MP4) { + language.messages.txtLblHasNoDefault + } else { + language.messages.txtLblHasDefault + }, + fontSize = (defaultFontSize.value - 3).sp, + color = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + } + } +} + +@Composable +private fun ConfigBox( + isEnabled: Boolean, + image: ImageBitmap, + options: IntroOptions, + modifier: Modifier, + onDurationChange: (Duration) -> Unit, + onBackgroundColorChange: (Color) -> Unit, + onRemoveRequest: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.height(height) + ) { + IntroImage( + isEnabled = isEnabled, + image = image, + backgroundColor = options.backgroundColor, + onRemoveRequest = onRemoveRequest + ) + IntroConfig( + isEnabled = isEnabled, + options = options, + onDurationChange = onDurationChange, + onBackgroundColorChange = onBackgroundColorChange + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun IntroImage( + isEnabled: Boolean, + image: ImageBitmap, + backgroundColor: Color, + onRemoveRequest: () -> Unit +) { + var isImageHovered by remember { mutableStateOf(false) } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(70.dp) + .onPointerEvent(eventType = PointerEventType.Enter) { isImageHovered = true } + .onPointerEvent(eventType = PointerEventType.Exit) { isImageHovered = false } + ) { + // See https://stackoverflow.com/questions/64742457/how-to-load-image-in-kotlin-compose-desktop + Image( + bitmap = image, + contentDescription = Messages.ICO_DSC_INTRO_PREVIEW, + colorFilter = if (!isEnabled) { + ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0F) }) + } else { + null + }, + modifier = Modifier + .size(INTRO_PREVIEW_SIZE.dp) + .border(Dp.Hairline, MaterialTheme.colors.onSurface.copy(alpha = 0.5f)) + .blur(if (isImageHovered && isEnabled) 8.dp else 0.dp) + .drawBehind { drawRect(color = backgroundColor) } + ) + if (isImageHovered && isEnabled) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background(MaterialTheme.colors.surface.copy(alpha = 0.6f)) + .size(INTRO_PREVIEW_SIZE.dp) + .clickable { + onRemoveRequest() + isImageHovered = false + } + ) { + CustomIcon( + icon = Icons.Custom.Delete, + description = Messages.ICO_DSC_REMOVE_INTRO + ) + } + } + } +} + +@Composable +private fun IntroConfig( + isEnabled: Boolean, + options: IntroOptions, + onDurationChange: (Duration) -> Unit, + onBackgroundColorChange: (Color) -> Unit +) { + Column(verticalArrangement = Arrangement.Center) { + CustomTextField(isEnabled = isEnabled, value = options.duration.inWholeSeconds.toInt()) { + onDurationChange(it.seconds) + } + Spacer(Modifier.height(14.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + BackgroundColor( + isEnabled = isEnabled, + color = Color.Black, + isSelected = options.backgroundColor == Color.Black + ) { + onBackgroundColorChange(Color.Black) + } + Spacer(Modifier.width(8.dp)) + BackgroundColor( + isEnabled = isEnabled, + color = Color.White, + isSelected = options.backgroundColor == Color.White, + ) { + onBackgroundColorChange(Color.White) + } + } + } +} + +@Composable +private fun BackgroundColor( + isEnabled: Boolean, + color: Color, + isSelected: Boolean, + onSelect: () -> Unit +) { + Box( + modifier = Modifier + .selectable(enabled = isEnabled, selected = isSelected, onClick = onSelect) + .background(color) + .size(16.dp) + .border( + width = 2.dp, + color = if (isSelected) { + MaterialTheme.colors.primary + } else { + MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + } + ) + ) +} + +// FIXME: Duplicate of code in timestamp input to some extent +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun CustomTextField(isEnabled: Boolean, value: Int, onValueChange: (Int) -> Unit) { + val language = LocalLanguage.current + val colors = TextFieldDefaults.textFieldColors() + var input by remember { mutableStateOf(value.toString()) } + val interactionSource = remember(::MutableInteractionSource) + BasicTextField( + value = language.localizeDigits(input), + onValueChange = { newString -> + input = (newString.takeIf { (it.toIntOrNull() ?: -1) >= 0 || it.isBlank() } ?: input).take(3) + newString.toIntOrNull()?.takeIf { it > 0 }?.let(onValueChange) + }, + cursorBrush = SolidColor(MaterialTheme.colors.onSurface), + textStyle = LocalTextStyle.current.copy( + fontSize = 11.sp, + textAlign = TextAlign.Center, + color = LocalContentColor.current + ), + interactionSource = interactionSource, + visualTransformation = SuffixTransformer1(" ${language.messages.second}"), + enabled = isEnabled, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + // To modify the width, also, modify the content padding start and end values in the TextFieldDecoration below + .width(44.dp) + .height(24.dp) + .clip(RoundedCornerShape(percent = 50)) + .background(colors.backgroundColor(true).value) + // Should consume (true) (except for TAB) to prevent bugs with app custom shortcuts + // See https://github.com/JetBrains/compose-multiplatform/issues/1925 + .onKeyEvent { it.key != Key.Tab } + ) { + TextFieldDefaults.TextFieldDecorationBox( + value = language.localizeDigits(input), + innerTextField = it, + singleLine = true, + enabled = isEnabled, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + // Keeps horizontal paddings but changes the vertical + contentPadding = TextFieldDefaults.textFieldWithoutLabelPadding( + top = 0.dp, + bottom = 0.dp, + start = 0.dp, + end = 0.dp + ) + ) + } +} + +private class SuffixTransformer1(private val suffix: String) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val result = text + AnnotatedString(suffix) + val textWithSuffixMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + override fun transformedToOriginal(offset: Int): Int { + return if (text.isEmpty()) { + 0 + } else if (offset < text.length) { + offset + } else { + text.length + } + } + } + return TransformedText(result, textWithSuffixMapping) + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/MediaPlayerProgress.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/MediaPlayerProgress.kt new file mode 100644 index 0000000..1b5feaa --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/MediaPlayerProgress.kt @@ -0,0 +1,113 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.animation.core.spring +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.component.DefaultMediaPlayer +import ir.mahozad.cutcon.model.Progress +import ir.mahozad.cutcon.ui.theme.AppTheme +import ir.mahozad.multiplatform.wavyslider.material.WavySlider +import ir.mahozad.multiplatform.wavyslider.material3.WaveAnimationSpecs +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@Preview +@Composable +private fun MediaPlayerProgressPreview() { + AppTheme { + val progress = Progress(0.27f, 100.seconds) + MediaPlayerProgress(progress = progress, isWavy = true, onSeek = {}) + } +} + +/** + * The higher the value of this, the better as higher values are more safe to ensure no glitch + * (with the compromise that when seeking is finished i.e. mouse key is released, + * it may take longer for the first new media progress to start showing on the bar + * because *the internal seek variable* would still be showing on the bar) + * The number below is also related to the frequency of media player progress emission + * (see [DefaultMediaPlayer.createProgressFlow] function) + */ +private const val PROGRESS_UPDATE_DELAY = 1_000 + +/** + * See the section about progress and seeking in main README for more information. + */ +@Composable +fun MediaPlayerProgress( + progress: Progress, + isWavy: Boolean, + onSeek: (Float) -> Unit +) { + // Fixes the following problem: + // when dragging the slider thumb and then releasing it, + // for a moment the previous position of the thumb was shown + var lastSeekAttempt by remember { mutableLongStateOf(System.currentTimeMillis()) } + // See https://stackoverflow.com/q/66386039 + var isSeeking by remember { mutableStateOf(false) } + var seek by remember { mutableFloatStateOf(progress.fraction) } + var time by remember { mutableStateOf(progress.time) } + LaunchedEffect(isSeeking, lastSeekAttempt, progress.time) { + while (isSeeking || (System.currentTimeMillis() - lastSeekAttempt) < PROGRESS_UPDATE_DELAY) { + time = (progress.length.inWholeMilliseconds * seek).toDouble().milliseconds + delay(30.milliseconds) + } + time = progress.time + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // The width of the timestamp components is fixed (constant). + // This is to prevent their width to change when their text changes (because the font is not monospace). + // If, instead, the dynamic width were used (i.e. not specifying a constant width == the default wrap size), + // a change in timestamps text (and hence their component width) + // caused the width of the slider to change as well by a tiny amount + // because the slider is set to take the remaining width of the parent. + // This caused problem when dragging the slider thumb with mouse + // (could not drag the thumb continuously, as it got stuck and did not change anymore). + // Could also have used a monospace font and then this wouldn't be required anymore. + Timestamp(time = time, textAlign = TextAlign.Start) + WavySlider( + value = if (isSeeking || (System.currentTimeMillis() - lastSeekAttempt) < PROGRESS_UPDATE_DELAY) { + seek + } else { + progress.fraction + }, + incremental = true, + waveLength = 20.dp, + waveHeight = if (isWavy) 8.dp else 0.dp, + onValueChange = { + isSeeking = true + seek = it + }, + onValueChangeFinished = { + lastSeekAttempt = System.currentTimeMillis() + isSeeking = false + onSeek(seek) + }, + animationSpecs = SliderDefaults.WaveAnimationSpecs.copy( + waveHeightAnimationSpec = spring(dampingRatio = 1f) + ), + // See https://stackoverflow.com/q/69688427 + modifier = Modifier + // The amount of seeking using keyboard arrow keys when the slider has focus is different from + // our own manual seeking in the viewModel (which takes effect when slider does not have focus). + // To see this behaviour do this: + // seek with mouse (which causes the slider to get focus), then hit keyboard left or right arrow keys + // See the fixed bug https://github.com/JetBrains/compose-multiplatform/issues/3283 + // If this is not the desired behaviour, uncomment the following line. + // .focusProperties { canFocus = false } + .weight(1f) + ) + Timestamp(time = progress.length, textAlign = TextAlign.End) + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/QualityInput.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/QualityInput.kt new file mode 100644 index 0000000..4a47604 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/QualityInput.kt @@ -0,0 +1,91 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.* +import androidx.compose.material.ContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.defaultQuality +import ir.mahozad.cutcon.model.Quality + +@Preview +@Composable +private fun QualityInputPreview() { + QualityInput( + isApplicable = true, + isEnabled = true, + quality = defaultQuality, + min = 1, + max = 3, + onChange = {} + ) +} + +@Composable +fun QualityInput( + isApplicable: Boolean, + isEnabled: Boolean, + quality: Quality, + min: Int, + max: Int, + onChange: (Float) -> Unit +) { + if (isApplicable) { + // See the MediaPlayerProgress widget + var isSeeking by remember { mutableStateOf(false) } + var seek by remember { mutableFloatStateOf(0f) } + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(8.dp)) + // Makes it always LTR + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Slider( + value = if (isSeeking) seek else quality.value.toFloat(), + onValueChange = { + isSeeking = true + seek = it + }, + onValueChangeFinished = { + isSeeking = false + onChange(seek) + }, + valueRange = min.toFloat()..max.toFloat(), + steps = max - min - 1, + enabled = isEnabled, + modifier = Modifier.weight(1f) + ) + Text( + text = quality.label(LocalLanguage.current), + fontSize = defaultFontSize, + textAlign = TextAlign.Center, + modifier = Modifier.width(48.dp), + color = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + } + } + } else { + Row { + Spacer(Modifier.width(12.dp)) + Text( + text = LocalLanguage.current.messages.qualityNotApplicableToRawFormat, + fontSize = defaultFontSize, + modifier = Modifier + .height(48.dp) + .wrapContentHeight(align = Alignment.CenterVertically) + ) + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/RadioGroup.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/RadioGroup.kt new file mode 100644 index 0000000..91f4a8b --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/RadioGroup.kt @@ -0,0 +1,100 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.ContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.material.RadioButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.localization.Language +import ir.mahozad.cutcon.model.Labeled + +@Suppress("unused") +private enum class TestEnum(override val label: (Language) -> String) : Labeled { + TEST1({ "Test 1" }), + TEST2({ "Test 2" }), + TEST3({ "Test 3" }) +} + +@Preview +@Composable +private fun RadioGroupPreview() { + RadioGroup(value = TestEnum.TEST1, isEnabled = true, weights = { 1f }) +} + +@Composable +fun RadioGroup( + value: T, + isEnabled: Boolean, + modifier: Modifier = Modifier, + weights: (index: Int) -> Float?, + onChange: (T) -> Unit = {} +) where T : Enum, T : Labeled { + Row( + modifier.selectableGroup(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + for ((i, option) in value.declaringJavaClass.enumConstants.withIndex() /* OR enumValues() */) { + RadioOption( + text = option.label(LocalLanguage.current), + isEnabled = isEnabled, + modifier = Modifier.then(weights(i)?.let { Modifier.weight(it) } ?: Modifier), + isSelected = (value == option) + ) { + onChange(option) + } + } + } +} + +/** + * Implemented as described [here](https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary). + * + * For my previous approach see [this commit](3e4ae87bb615d8b8321ab9a3bb7c0f0c9549bbe8). + */ +@Composable +private fun RadioOption( + text: String, + isSelected: Boolean, + isEnabled: Boolean, + modifier: Modifier, + onClick: () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .selectable( + selected = isSelected, + enabled = isEnabled, + onClick = onClick, + role = Role.RadioButton + ) + .padding(horizontal = 14.dp, vertical = 8.dp) + ) { + RadioButton( + selected = isSelected, + enabled = isEnabled, + onClick = null // For accessibility with screen readers + ) + Text( + text = text, + fontSize = (defaultFontSize.value - 1).sp, + modifier = Modifier + .padding(start = 8.dp) + .alpha(if (isEnabled) LocalContentColor.current.alpha else ContentAlpha.disabled) + ) + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/SaveAsInput.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/SaveAsInput.kt new file mode 100644 index 0000000..a85f292 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/SaveAsInput.kt @@ -0,0 +1,133 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.* +import androidx.compose.material.Button +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.FrameWindowScope +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.localization.LanguageFa +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.model.Format +import ir.mahozad.cutcon.model.Source +import ir.mahozad.cutcon.toLtrString +import ir.mahozad.cutcon.trim +import ir.mahozad.cutcon.ui.dialog.showSaveFileDialog +import ir.mahozad.cutcon.ui.icon.Folder +import ir.mahozad.cutcon.ui.icon.Icons +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.awt.Desktop +import java.nio.file.Path +import kotlin.io.path.Path + +@Composable +@Preview +fun SaveAsInputPreview() { + val fakeWindowScope = object : FrameWindowScope { + // OR override val window get() = ComposeWindow() + override val window get() = TODO("Not needed") + } + with(fakeWindowScope) { + SaveAsInput( + isEnabled = true, + source = Source.Local(Path("1234.mp4")), + destination = Path("a.mp3"), + targetFormat = Format.MP3, + defaultNameProvider = { "a" }, + lastSaveDirectory = null, + onFileSpecified = {} + ) + } +} + +@Composable +fun FrameWindowScope.SaveAsInput( + isEnabled: Boolean, + source: Source, + destination: Path?, + targetFormat: Format, + lastSaveDirectory: Path?, + defaultNameProvider: (Path) -> String, + onFileSpecified: (Path) -> Unit +) { + val language = LocalLanguage.current + val scope = rememberCoroutineScope() + var isDialogDisplayed by remember { mutableStateOf(false) } + // A fix (workaround) to prevent opening multiple dialogs + // when the button is pressed multiple times in rapid succession and also + // to fix the bug which caused the dialog to open again when Enter was pressed in an open dialog + // (this problem was probably because the dialog accept button and the app open file button + // both received the key event) + // See https://github.com/JetBrains/compose-multiplatform/issues/3892 + // and https://al-e-shevelev.medium.com/how-to-prevent-multiple-clicks-in-android-jetpack-compose-8e62224c9c5e + // and https://stackoverflow.com/q/69901608 + var dialogClosedTime by remember { mutableLongStateOf(0) } + Row(verticalAlignment = Alignment.CenterVertically) { + if (lastSaveDirectory != null) { + Tooltip(language.messages.openSaveFolder) { + IconButton(onClick = { + scope.launch(Dispatchers.IO) { + // See https://stackoverflow.com/a/12340147 + Desktop.getDesktop().open(lastSaveDirectory.toFile()) + } + }) { + CustomIcon( + icon = Icons.Custom.Folder, + description = Messages.ICO_DSC_OPEN_FOLDER + ) + } + } + Spacer(Modifier.width(4.dp)) + } + Button( + enabled = isEnabled, + modifier = Modifier.weight(1f), + onClick = { + if ( + isDialogDisplayed || + System.currentTimeMillis() - dialogClosedTime < 300 + ) { + return@Button + } + dialogClosedTime = System.currentTimeMillis() + isDialogDisplayed = true + scope.launch(Dispatchers.IO) { + val result = window.showSaveFileDialog( + language = language, + title = language.messages.dlgTitSpecifySaveFile, + formatName = targetFormat.actualName(source), + formatExtension = targetFormat.extension(source), + defaultFileNameProvider = defaultNameProvider, + startingDirectory = lastSaveDirectory, + approveButtonLabel = language.messages.btnLblApproveSaveFile, + approveButtonTooltip = language.messages.btnTlpApproveSaveFolder + ) + dialogClosedTime = System.currentTimeMillis() + isDialogDisplayed = false + result?.let(onFileSpecified) + } + } + ) { + AnimatedVisibility(isDialogDisplayed) { + Row { + Spinner(modifier = Modifier.size(12.dp)) + Spacer(Modifier.width(4.dp)) + } + } + Text( + text = destination?.trim(maxLength = 35)?.toLtrString() ?: language.messages.btnLblSelectSaveFolder, + fontSize = if (destination == null) defaultFontSize else (defaultFontSize.value - 1).sp, + modifier = Modifier.padding(top = if (language is LanguageFa) 3.dp else 0.dp) + ) + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/ScreenshotButton.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/ScreenshotButton.kt new file mode 100644 index 0000000..8c3cfa3 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/ScreenshotButton.kt @@ -0,0 +1,133 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.* +import ir.mahozad.cutcon.* +import ir.mahozad.cutcon.localization.LanguageFa +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.ui.icon.CameraFill +import ir.mahozad.cutcon.ui.icon.CameraOutline +import ir.mahozad.cutcon.ui.icon.Icons +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.awt.Desktop +import kotlin.io.path.createDirectories +import kotlin.time.Duration.Companion.milliseconds + +@Preview +@Composable +private fun ScreenshotButtonPreview() { + ScreenshotButton(isEnabled = true, onClick = {}) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ScreenshotButton(isEnabled: Boolean, onClick: () -> Unit) { + val language = LocalLanguage.current + val scope = rememberCoroutineScope() + TooltipArea( + tooltip = { + CompositionLocalProvider(LocalLayoutDirection provides language.layoutDirection) { + Surface( + modifier = Modifier + .shadow(4.dp) + .width(if (language is LanguageFa) 214.dp else 196.dp) + .border( + width = Dp.Hairline, + color = Color.Gray.copy(alpha = 0.3f), + shape = RoundedCornerShape(4.dp) + ), + color = MaterialTheme.colors.surface, + shape = RoundedCornerShape(4.dp) + ) { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = language.messages.takeScreenshotAndSaveIn, + fontSize = (defaultFontSize.value - 2).sp + ) + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Text( + text = defaultScreenshotSaveDirectory.trim(maxLength = 70).toLtrString(), + color = MaterialTheme.colors.primary, + fontSize = (defaultFontSize.value - 2).sp + ) + } + if (isEnabled) { + Text( + text = language.messages.txtDscScreenshotHelp, + fontSize = (defaultFontSize.value - 2).sp + ) + } + } + } + } + }, + content = { + var isScreenshotSaved by remember { mutableStateOf(false) } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .combinedClickable( + enabled = isEnabled, + indication = rememberRipple(bounded = false), + interactionSource = remember(::MutableInteractionSource), + onLongClick = { + // Does it asynchronously because creating directories takes a little time + scope.launch(Dispatchers.IO) { + // Makes sure the directories exist. + // Calling this method where the variable is defined is not enough + // because, although it creates the directories on app start, + // user may have deleted the directories while the app is running + defaultScreenshotSaveDirectory.createDirectories() + Desktop.getDesktop().open(defaultScreenshotSaveDirectory.toFile()) + } + }, + onClick = { + onClick() + isScreenshotSaved = true + } + ) + ) { + CustomIcon( + icon = if (isScreenshotSaved) Icons.Custom.CameraFill else Icons.Custom.CameraOutline, + description = Messages.ICO_DSC_TAKE_SCREENSHOT, + tint = if (isEnabled) { + null + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) + } + LaunchedEffect(isScreenshotSaved) { + if (!isScreenshotSaved) return@LaunchedEffect + delay(400.milliseconds) + isScreenshotSaved = false + } + }, + delayMillis = defaultTooltipDelay.inWholeMilliseconds.toInt(), + tooltipPlacement = TooltipPlacement.ComponentRect( + alignment = Alignment.TopCenter, + offset = DpOffset(0.dp, (-52).dp) /* OR DpOffset.Zero */ + ) + ) +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/SourceInput.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/SourceInput.kt new file mode 100644 index 0000000..141b145 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/SourceInput.kt @@ -0,0 +1,231 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.* +import androidx.compose.ui.window.FrameWindowScope +import ir.mahozad.cutcon.* +import ir.mahozad.cutcon.localization.Language +import ir.mahozad.cutcon.localization.LanguageFa +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.model.LocalSourceSupportedFileType +import ir.mahozad.cutcon.model.Source +import ir.mahozad.cutcon.ui.dialog.showOpenFileDialog +import ir.mahozad.cutcon.ui.icon.Folder +import ir.mahozad.cutcon.ui.icon.Icons +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.awt.Desktop +import java.nio.file.Path + +@Preview +@Composable +fun SourceInputPreview() { + val fakeWindowScope = object : FrameWindowScope { + override val window get() = TODO("Not implemented") // OR ComposeWindow() + } +// fakeWindowScope.SourceInput() +} + +private val supportedFileExtensions = LocalSourceSupportedFileType + .entries + .flatMap { it.extensions.toList() } + .toTypedArray() + +/** + * For the previous implementation, see the v1.2.0 Git tag. + */ +@Composable +fun FrameWindowScope.SourceInput( + source: Source, + lastOpenDirectory: Path?, + onSetSourceToLocalRequest: (Path) -> Unit, +) { + val language = LocalLanguage.current + var isLocalFileSelectorDisplayed by remember { mutableStateOf(false) } + /** + * See [SaveAsInput] for more information about this + */ + var dialogClosedTime by remember { mutableLongStateOf(0) } + + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(2.dp)) + when (source) { + is Source.Local -> OpenLocalFolderButton((source as? Source.Local)?.path) + } + Spacer(Modifier.width(4.dp)) + when (source) { + is Source.Local -> { + ClickableInput( + text = source.path.trim(maxLength = 26).toLtrString(), + modifier = Modifier.weight(2.4f), + startRoundness = 24.dp, + endRoundness = 0.dp + ) { + isLocalFileSelectorDisplayed = true + } + } + } + Spacer(Modifier.width(8.dp)) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .heightIn(min = defaultInputHeight) + .clip( + RoundedCornerShape( + topStart = 0.dp, + bottomStart = 0.dp, + topEnd = 24.dp, + bottomEnd = 24.dp + ) + ) + .background(color = MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.BackgroundOpacity)) + .clickable(enabled = true, onClick = { isLocalFileSelectorDisplayed = true }) + ) { + DropDownLabel( + label = source.label(language), + isLoading = isLocalFileSelectorDisplayed + ) + } + LaunchedEffect(isLocalFileSelectorDisplayed) { + if ( + !isLocalFileSelectorDisplayed || + System.currentTimeMillis() - dialogClosedTime < 300 + ) { + return@LaunchedEffect + } + launch(Dispatchers.IO) { + showLocalFileSelector( + language = language, + startingDirectory = lastOpenDirectory, + onCancel = { + dialogClosedTime = System.currentTimeMillis() + isLocalFileSelectorDisplayed = false + }, + onSelected = { path -> + onSetSourceToLocalRequest(path) + dialogClosedTime = System.currentTimeMillis() + isLocalFileSelectorDisplayed = false + } + ) + } + } + } +} + +@Composable +private fun BoxScope.DropDownLabel(label: String, isLoading: Boolean) { + val language = LocalLanguage.current + if (isLoading) { + Spinner(modifier = Modifier.padding(end = 10.dp).size(12.dp)) + } else { + Text( + text = label, + maxLines = 1, + fontSize = defaultFontSize, + modifier = Modifier + .padding(end = 16.dp, bottom = if (language is LanguageFa) 1.dp else 0.dp) + .align(Alignment.Center) + ) + } +} + +private fun FrameWindowScope.showLocalFileSelector( + language: Language, + startingDirectory: Path?, + onCancel: () -> Unit, + onSelected: (Path) -> Unit +) { + window.showOpenFileDialog( + language = language, + title = language.messages.dlgTitSelectLocalFile, + startingDirectory = startingDirectory, + approveButtonLabel = language.messages.btnLblApproveSelectedFile, + approveButtonTooltip = language.messages.btnTlpApproveSelectedFile, + fileExtensionDescription = language.messages.txtLblLocalFileSupportedTypesDescription, + fileExtensions = supportedFileExtensions + ) + ?.let(onSelected) + ?: onCancel() +} + +@Composable +private fun SourceItemUi(text: String) { + Text( + text = text, + fontSize = defaultFontSize, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) +} + +// NOTE: Copied from code of Dropdown +@Composable +private fun ClickableInput( + text: String, + modifier: Modifier = Modifier, + startRoundness: Dp, + endRoundness: Dp, + onClick: () -> Unit +) { + var textFieldSize by remember { mutableStateOf(Size.Zero) } + Box( + modifier = modifier + .fillMaxWidth() + .heightIn(min = defaultInputHeight) + .clip( + RoundedCornerShape( + topStart = startRoundness, + bottomStart = startRoundness, + topEnd = endRoundness, + bottomEnd = endRoundness + ) + ) + .background(color = MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.BackgroundOpacity)) + .onGloballyPositioned { textFieldSize = it.size.toSize() } + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + maxLines = 1, + fontSize = defaultFontSize, + // https://stackoverflow.com/a/69896666 + modifier = Modifier + .alpha(ContentAlpha.high) + .padding( + top = if (LocalLanguage.current is LanguageFa) 3.dp else 0.dp, + end = if (endRoundness > 0.dp) 12.dp else 0.dp + ) + ) + } +} + +@Composable +private fun OpenLocalFolderButton(localFile: Path?) { + val scope = rememberCoroutineScope() + Tooltip(LocalLanguage.current.messages.openSourceFolder) { + IconButton(onClick = { + scope.launch(Dispatchers.IO) { + localFile?.parent?.toFile()?.let(Desktop.getDesktop()::open) + } + }) { + CustomIcon( + icon = Icons.Custom.Folder, + description = Messages.ICO_DSC_OPEN_FOLDER + ) + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/SpeedInput.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/SpeedInput.kt new file mode 100644 index 0000000..c037ea6 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/SpeedInput.kt @@ -0,0 +1,93 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.localization.Messages +import ir.mahozad.cutcon.model.Shortcut +import ir.mahozad.cutcon.model.Speed +import ir.mahozad.cutcon.ui.icon.* + +@Preview +@Composable +private fun SpeedInputPreview() { + SpeedInput(speed = Speed.NORMAL, onReset = {}, onChange = {}) +} + +/** + * For alternative designs see the comments of this function in git tag v1.10.0 + */ +@Composable +fun SpeedInput( + speed: Speed, + onReset: () -> Unit, + onChange: (Speed) -> Unit +) { + val language = LocalLanguage.current + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Tooltip( + if (speed == Speed.NORMAL) { + language.messages.restoreLastPlaybackSpeed + } else { + language.messages.resetPlaybackSpeedToNormal + }, + Shortcut.SPEED_RESET.symbol + ) { + IconButton(onClick = onReset) { + CustomIcon( + icon = if (speed.value < 1f) { + Icons.Custom.SpeedSlow + } else if (speed.value > 1f) { + Icons.Custom.SpeedFast + } else { + Icons.Custom.SpeedNormal + }, + description = Messages.ICO_DSC_RESET_SPEED + ) + } + } + Tooltip( + language.messages.decreasePlaybackSpeed, + Shortcut.SPEED_DECREASE.symbol + ) { + IconButton(onClick = { speed.dec().let(onChange) }) { + CustomIcon( + icon = Icons.Custom.Minus, + description = Messages.ICO_DSC_DECREASE_SPEED + ) + } + } + Text( + text = language.localizeNumber(speed.value), + fontSize = defaultFontSize, + textAlign = TextAlign.Center, + modifier = Modifier.width(48.dp) + ) + Tooltip( + language.messages.increasePlaybackSpeed, + Shortcut.SPEED_INCREASE.symbol + ) { + IconButton(onClick = { speed.inc().let(onChange) }) { + CustomIcon( + icon = Icons.Custom.Plus, + description = Messages.ICO_DSC_INCREASE_SPEED + ) + } + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Spinner.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Spinner.kt new file mode 100644 index 0000000..bd4e104 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Spinner.kt @@ -0,0 +1,16 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun Spinner(modifier: Modifier = Modifier) { + CircularProgressIndicator( + color = LocalContentColor.current.copy(alpha = 0.8f), + modifier = modifier, + strokeWidth = 2.dp + ) +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/StackTrace.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/StackTrace.kt new file mode 100644 index 0000000..b6c0298 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/StackTrace.kt @@ -0,0 +1,51 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection +import ir.mahozad.cutcon.defaultFontSize + +@Composable +fun StackTrace(throwable: Throwable?) { + // Shows the error always in LTR direction + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + val horizontalScrollState = rememberScrollState() + val verticalScrollState = rememberScrollState() + Box { + Text( + // FIXME: Tab characters in stacktrace are rendered as unknown (square) glyph + // See https://github.com/JetBrains/compose-multiplatform/issues/2626 + text = throwable?.stackTraceToString()?.replace("\t", " ") ?: "", + softWrap = false, + overflow = TextOverflow.Visible, + fontSize = defaultFontSize, + color = MaterialTheme.colors.error, + modifier = Modifier + .horizontalScroll(horizontalScrollState) + .verticalScroll(verticalScrollState) + ) + HorizontalScrollbar( + adapter = rememberScrollbarAdapter(scrollState = horizontalScrollState), + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + ) + VerticalScrollbar( + adapter = rememberScrollbarAdapter(scrollState = verticalScrollState), + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + ) + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/StatusLabel.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/StatusLabel.kt new file mode 100644 index 0000000..04b112c --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/StatusLabel.kt @@ -0,0 +1,139 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ir.mahozad.cutcon.* +import ir.mahozad.cutcon.localization.LanguageFa +import ir.mahozad.cutcon.model.Source +import ir.mahozad.cutcon.model.Status +import ir.mahozad.cutcon.model.Status.* +import kotlin.math.roundToInt + +@Composable +fun StatusLabel(status: Status) { + val language = LocalLanguage.current + val primaryColor = MaterialTheme.colors.primary + val primaryVariantColor = MaterialTheme.colors.primaryVariant + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(percent = 50)) + .border( + width = 1.dp, + shape = RoundedCornerShape(percent = 50), + color = when (status) { + is Error -> MaterialTheme.colors.error + is Finished.Failure -> MaterialTheme.colors.error + is Finished.Success -> MaterialTheme.colors.success + else -> MaterialTheme.colors.onSurface + } + ) + .padding(all = defaultChipHeight / 4) + .height(defaultChipHeight / 2) + // See https://stackoverflow.com/q/75941502 + .drawWithContent { + with(drawContext.canvas.nativeCanvas) { + val checkPoint = saveLayer(null, null) + // Destination + drawContent() + // Source + if (status is Initializing || status is InProgress) { + drawRoundRect( + size = Size(size.width * ((status as? InProgress)?.progress ?: 0f), size.height), + brush = Brush.horizontalGradient( + 0f to primaryVariantColor, + size.width to primaryColor, + ), + blendMode = BlendMode.SrcOut, + cornerRadius = CornerRadius(100f, 100f) + ) + } + restoreToCount(checkPoint) + } + } + ) { + if (status is InProgress) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.offset(y = if (language is LanguageFa) 1.dp else 0.dp) + ) { + Text( + text = language.localizeDigits((status.progress * 100).roundToInt().toString()), + fontSize = (defaultFontSize.value - 1).sp, + // Uses end alignment and fixed width so the progress description + // stays at a fixed position for all percentage labels + textAlign = TextAlign.End, + modifier = Modifier.width(if ((status.progress * 100).roundToInt() < 100) 18.dp else 26.dp) + ) + Text( + text = language.messages.txtLblPercentSign, + fontSize = if (language is LanguageFa) defaultFontSize else (defaultFontSize.value - 1).sp, + modifier = Modifier.alignByBaseline() + ) + Spacer(Modifier.width(4.dp)) + Text( + text = "${language.messages.txtLblProgressCreating} ", + fontSize = (defaultFontSize.value - 1).sp + ) + SourceText(status.source) + } + } else { + @Suppress("KotlinConstantConditions") + Text( + text = when (status) { + is Ready -> language.messages.txtLblReady + is Initializing -> language.messages.txtLblProgressInitializing + is Error.ClipFromImageNotSupported -> language.messages.txtLblErrorClipFromImageNotSupported + is Error.ClipFromFormatNotSupported -> language.messages.txtLblErrorClipFromFormatNotSupported + is Error.FileNotSet -> language.messages.txtLblErrorClipFileNotSet + is Error.ClipNotSet -> language.messages.txtLblErrorClipNotSet + is Error.ClipLengthZero -> language.messages.txtLblErrorClipLengthZero + is Error.ClipLengthNegative -> language.messages.txtLblErrorClipLengthNegative + is Error.ClipStartAfterMediaEnd -> language.messages.txtLblErrorClipStartAfterMediaEnd + is InProgress -> TODO("NOT REACHABLE; DOES NOT MATTER") + is Finished.Failure -> language.messages.txtLblClipCreationFailure + is Finished.Success -> language.messages.txtLblClipCreationSuccess + }, + color = when (status) { + is Error -> MaterialTheme.colors.error + is Finished.Failure -> MaterialTheme.colors.error + is Finished.Success -> MaterialTheme.colors.success + else -> Color.Unspecified + }, + fontSize = (defaultFontSize.value - 1).sp, + modifier = Modifier.offset(y = if (language is LanguageFa) 1.dp else 0.dp) + ) + } + } +} + +@Composable +fun SourceText(source: Source) { + val language = LocalLanguage.current + when (source) { + is Source.Local -> { + Text( + text = "${language.messages.fromFile} ${source.path.fileName.trim(maxLength = 24).toLtrString()}", + fontSize = (defaultFontSize.value - 1).sp + ) + } + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Timestamp.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Timestamp.kt new file mode 100644 index 0000000..64b0702 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Timestamp.kt @@ -0,0 +1,40 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultDurationConverter +import ir.mahozad.cutcon.defaultFontSize +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@Preview +@Composable +private fun TimestampPreview() { + Timestamp( + time = 17.minutes + 44.seconds, + textAlign = TextAlign.Start + ) +} + +@Composable +fun Timestamp( + time: Duration, + textAlign: TextAlign +) = Text( + text = LocalLanguage.current.localizeDigits( + defaultDurationConverter + .format(duration = time, numberOfParts = 2) + .removePrefix("-") // Because sometimes the 0 duration has negative sign + ), + fontSize = defaultFontSize, + textAlign = textAlign, + // See the MediaPlayerProgress for why a fixed width is used + modifier = Modifier.width(43.dp) +) diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/TimestampInput.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/TimestampInput.kt new file mode 100644 index 0000000..f05cfa7 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/TimestampInput.kt @@ -0,0 +1,206 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.* +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ir.mahozad.cutcon.* + +@Preview +@Composable +private fun TimestampInputPreview() { + TimestampInput( + minuteInput = TextFieldValue(text = "12", selection = TextRange.Two), + secondInput = TextFieldValue(text = "37", selection = TextRange.Two), + startRoundness = 24.dp, + onMinuteChange = { _, _ -> }, + onSecondChange = { _, _ -> }, + ) +} + +@Preview +@Composable +private fun CustomTimestampTextFieldPreview() { + CustomTimestampTextField( + value = TextFieldValue(text = "11", selection = TextRange.Zero), + placeholder = "00", + startRoundness = 24.dp, + endRoundness = 0.dp, + onValueChange = { _, _ -> }, + ) +} + +@Composable +fun TimestampInput( + minuteInput: TextFieldValue, + secondInput: TextFieldValue, + startRoundness: Dp = 0.dp, + endRoundness: Dp = 0.dp, + onMinuteChange: (String, TextRange) -> Unit, + onSecondChange: (String, TextRange) -> Unit, +) { + Row { + CustomTimestampTextField( + value = minuteInput, + placeholder = LocalLanguage.current.localizeDigits(defaultTimeStampString), + startRoundness = startRoundness, + endRoundness = 0.dp, + onValueChange = onMinuteChange + ) + Spacer(Modifier.width(1.dp)) + CustomTimestampTextField( + value = secondInput, + placeholder = LocalLanguage.current.localizeDigits(defaultTimeStampString), + startRoundness = 0.dp, + endRoundness = endRoundness, + onValueChange = onSecondChange + ) + } +} + +/** + * See https://stackoverflow.com/a/73147836 + * and https://stackoverflow.com/a/69267008 + * + * We used TextFieldValue instead of the simple string as the field text + * to be able to select all the text on mouse click (focus). + * Probably could use [SelectionContainer] instead. + * Could simply use ` BasicTextField(value = value,...` and let go of + * the TextFieldValue and all its ceremony and custom code. + * See the commit bf44d702edaa9de07295a24cab93f87f574dd05a. + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun CustomTimestampTextField( + value: TextFieldValue, + placeholder: String, + startRoundness: Dp, + endRoundness: Dp, + onValueChange: (String, TextRange) -> Unit +) { + var textFieldValue by remember(value) { mutableStateOf(value) } + val colors = TextFieldDefaults.textFieldColors() + val interactionSource = remember(::MutableInteractionSource) + val isFocused by interactionSource.collectIsFocusedAsState() + LaunchedEffect(isFocused) { + textFieldValue = textFieldValue.copy( + selection = if (isFocused) { + TextRange(start = 0, end = textFieldValue.text.length) + } else { + TextRange.Zero + } + ) + } + BasicTextField( + value = textFieldValue, + onValueChange = { onValueChange(it.text, it.selection) }, + cursorBrush = SolidColor(MaterialTheme.colors.onSurface), + textStyle = LocalTextStyle.current.copy( + fontSize = defaultFontSize, + textAlign = TextAlign.Center, + color = LocalContentColor.current + ), + interactionSource = interactionSource, + enabled = true, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + // To modify the width, also, modify the content padding start and end values in the TextFieldDecoration below + .width(if (startRoundness == endRoundness && startRoundness == 0.dp) 40.dp else 44.dp) + .height(defaultInputHeight) + .clip( + RoundedCornerShape( + topStart = startRoundness, + bottomStart = startRoundness, + topEnd = endRoundness, + bottomEnd = endRoundness + ) + ) + .background(colors.backgroundColor(true).value) + .onKeyEvent { event -> + handleKeyEvent(event, textFieldValue) + ?.let { textFieldValue = it } + // Should consume (true) (except for TAB) to prevent bugs with app custom shortcuts + // See https://github.com/JetBrains/compose-multiplatform/issues/1925 + event.key != Key.Tab + } + ) { + TextFieldDefaults.TextFieldDecorationBox( + value = value.text, + innerTextField = it, + singleLine = true, + enabled = true, + placeholder = { + Text( + text = placeholder, + style = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + fontSize = (defaultFontSize.value - 3).sp, + modifier = Modifier.fillMaxWidth() + ) + }, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + // Keeps horizontal paddings but changes the vertical + contentPadding = TextFieldDefaults.textFieldWithoutLabelPadding( + top = 0.dp, + bottom = 0.dp, + // To modify these, also, modify the parent BasicTextField Modifier.width above + start = if (endRoundness == startRoundness) 10.dp else if (endRoundness > 0.dp) 9.dp else 15.dp, + end = if (endRoundness == startRoundness) 10.dp else if (startRoundness > 0.dp) 9.dp else 15.dp + ) + ) + } +} + +private fun handleKeyEvent(event: KeyEvent, textFieldValue: TextFieldValue): TextFieldValue? { + return if (event.type != KeyEventType.KeyDown) { + null + } else if (event.isShiftPressed && event.key == Key.DirectionLeft) { + textFieldValue.copy( + selection = TextRange( + (textFieldValue.selection.start - 1).coerceAtLeast(0), + textFieldValue.selection.end + ) + ) + } else if (event.key == Key.DirectionLeft && textFieldValue.selection.length > 0) { + textFieldValue.copy(selection = TextRange(textFieldValue.selection.start)) + } else if (event.key == Key.DirectionLeft) { + textFieldValue.copy( + selection = TextRange((textFieldValue.selection.end - 1).coerceAtLeast(0)) + ) + } else if (event.isShiftPressed && event.key == Key.DirectionRight) { + textFieldValue.copy( + selection = TextRange( + textFieldValue.selection.start, + (textFieldValue.selection.end + 1).coerceAtMost(2) + ) + ) + } else if (event.key == Key.DirectionRight && textFieldValue.selection.length > 0) { + textFieldValue.copy(selection = TextRange(textFieldValue.selection.end)) + } else if (event.key == Key.DirectionRight) { + textFieldValue.copy( + selection = TextRange((textFieldValue.selection.start + 1).coerceAtMost(2)) + ) + } else { + null + } +} diff --git a/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Tooltip.kt b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Tooltip.kt new file mode 100644 index 0000000..5b70c12 --- /dev/null +++ b/src/main/kotlin/ir/mahozad/cutcon/ui/widget/Tooltip.kt @@ -0,0 +1,91 @@ +package ir.mahozad.cutcon.ui.widget + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ir.mahozad.cutcon.LocalLanguage +import ir.mahozad.cutcon.defaultFontSize +import ir.mahozad.cutcon.defaultTooltipDelay + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Tooltip( + tooltip: String, + vararg shortcuts: String, + content: @Composable () -> Unit +) { + TooltipArea( + tooltip = { TooltipText(tooltip, *shortcuts) }, + content = content, + delayMillis = defaultTooltipDelay.inWholeMilliseconds.toInt(), + tooltipPlacement = TooltipPlacement.ComponentRect( + alignment = Alignment.TopCenter, + offset = DpOffset(0.dp, (-52).dp) /* OR DpOffset.Zero */ + ) + ) +} + +@Composable +private fun TooltipText(text: String, vararg shortcuts: String) { + Surface( + color = MaterialTheme.colors.surface, + shape = RoundedCornerShape(4.dp), + modifier = Modifier + .shadow(4.dp) + .border( + width = Dp.Hairline, + color = Color.Gray.copy(alpha = 0.3f), + shape = RoundedCornerShape(4.dp) + ) + ) { + CompositionLocalProvider(LocalLayoutDirection provides LocalLanguage.current.layoutDirection) { + Column(modifier = Modifier.padding(8.dp)) { + Text( + text = text, + fontSize = (defaultFontSize.value - 2).sp + ) + if (shortcuts.isNotEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = LocalLanguage.current.messages.shortcut, + fontSize = (defaultFontSize.value - 2).sp + ) + Spacer(Modifier.width(4.dp)) + for ((i, shortcut) in shortcuts.withIndex()) { + Text( + text = shortcut, + color = MaterialTheme.colors.primary, + fontSize = (defaultFontSize.value).sp + ) + if (i < shortcuts.lastIndex) { + Spacer(Modifier.width(4.dp)) + Text( + text = LocalLanguage.current.messages.and, + fontSize = (defaultFontSize.value - 2).sp + ) + Spacer(Modifier.width(4.dp)) + } + } + } + } + } + } + } +} diff --git a/src/main/resources/font/roboto.ttf b/src/main/resources/font/roboto.ttf new file mode 100644 index 0000000..ddf4bfa Binary files /dev/null and b/src/main/resources/font/roboto.ttf differ diff --git a/src/main/resources/font/vazirmatn-ui-v33.003.ttf b/src/main/resources/font/vazirmatn-ui-v33.003.ttf new file mode 100644 index 0000000..0b68e81 Binary files /dev/null and b/src/main/resources/font/vazirmatn-ui-v33.003.ttf differ diff --git a/src/main/resources/icon/compose-logo.svg b/src/main/resources/icon/compose-logo.svg new file mode 100644 index 0000000..a61996a --- /dev/null +++ b/src/main/resources/icon/compose-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/main/resources/icon/compose-multiplatform-logo.svg b/src/main/resources/icon/compose-multiplatform-logo.svg new file mode 100644 index 0000000..8dd5d91 --- /dev/null +++ b/src/main/resources/icon/compose-multiplatform-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icon/ffmpeg-logo.svg b/src/main/resources/icon/ffmpeg-logo.svg new file mode 100644 index 0000000..4ab835f --- /dev/null +++ b/src/main/resources/icon/ffmpeg-logo.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icon/git-logo.svg b/src/main/resources/icon/git-logo.svg new file mode 100644 index 0000000..fff9833 --- /dev/null +++ b/src/main/resources/icon/git-logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main/resources/icon/github-logo.svg b/src/main/resources/icon/github-logo.svg new file mode 100644 index 0000000..119b22a --- /dev/null +++ b/src/main/resources/icon/github-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icon/gradle-logo.svg b/src/main/resources/icon/gradle-logo.svg new file mode 100644 index 0000000..74a1374 --- /dev/null +++ b/src/main/resources/icon/gradle-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/src/main/resources/icon/inkscape-logo.svg b/src/main/resources/icon/inkscape-logo.svg new file mode 100644 index 0000000..29def55 --- /dev/null +++ b/src/main/resources/icon/inkscape-logo.svg @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icon/intellij-logo.svg b/src/main/resources/icon/intellij-logo.svg new file mode 100644 index 0000000..9089466 --- /dev/null +++ b/src/main/resources/icon/intellij-logo.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icon/kotlin-logo.svg b/src/main/resources/icon/kotlin-logo.svg new file mode 100644 index 0000000..0ec5402 --- /dev/null +++ b/src/main/resources/icon/kotlin-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/icon/mahozad.svg b/src/main/resources/icon/mahozad.svg new file mode 100644 index 0000000..56484a6 --- /dev/null +++ b/src/main/resources/icon/mahozad.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/main/resources/icon/material-you-logo.svg b/src/main/resources/icon/material-you-logo.svg new file mode 100644 index 0000000..c7bc599 --- /dev/null +++ b/src/main/resources/icon/material-you-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icon/open-source.svg b/src/main/resources/icon/open-source.svg new file mode 100644 index 0000000..569f59f --- /dev/null +++ b/src/main/resources/icon/open-source.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icon/vazir-logo.svg b/src/main/resources/icon/vazir-logo.svg new file mode 100644 index 0000000..d253a9e --- /dev/null +++ b/src/main/resources/icon/vazir-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icon/vlc-logo.svg b/src/main/resources/icon/vlc-logo.svg new file mode 100644 index 0000000..a3c5cc4 --- /dev/null +++ b/src/main/resources/icon/vlc-logo.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..7551759 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + %date{HH:mm:ss.SSS, Asia/Tehran} %CustomHighlight(%-5level) [%thread] %logger{0} - %msg%n + + + + + + + + + + + + + + + + + + + ${AppData}/${APP_NAME}/${LOG_FILE_NAME}.log + + + ${AppData}/${APP_NAME}/${LOG_FILE_NAME}-%d{yyyy-MM-dd}.log.gz + + 10 + 100MB + + + %date{HH:mm:ss.SSS, Asia/Tehran} %-5level [%thread] %logger{20} - %msg%n + + + + + + + + + + diff --git a/src/main/resources/logo.svg b/src/main/resources/logo.svg new file mode 100644 index 0000000..5cfb4f6 --- /dev/null +++ b/src/main/resources/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/test/kotlin/ir/mahozad/cutcon/TestHelpers.kt b/src/test/kotlin/ir/mahozad/cutcon/TestHelpers.kt new file mode 100644 index 0000000..c6f4415 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/TestHelpers.kt @@ -0,0 +1,129 @@ +package ir.mahozad.cutcon + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toAwtImage +import io.mockk.spyk +import ir.mahozad.cutcon.component.DateTimeChecker +import ir.mahozad.cutcon.component.MediaPlayer +import ir.mahozad.cutcon.component.UrlMaker +import ir.mahozad.cutcon.converter.ConverterFactory +import ir.mahozad.cutcon.model.Clip +import ir.mahozad.cutcon.model.MediaInfo +import ir.mahozad.cutcon.model.Progress +import ir.mahozad.cutcon.model.Speed +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestDispatcher +import org.bytedeco.ffmpeg.ffmpeg +import org.bytedeco.javacpp.Loader +import java.io.File +import java.net.URL +import java.nio.file.Path +import java.util.prefs.Preferences +import kotlin.io.path.Path +import kotlin.io.path.absolutePathString +import kotlin.io.path.toPath +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +val testTimeout = 30.seconds + +private val resourceAccessor = object {} +private val ffmpegPath = Loader.load(ffmpeg::class.java) + +fun constructMainViewModel( + dispatcher: TestDispatcher, + converterFactory: ConverterFactory = spyk(), + dateTimeChecker: DateTimeChecker = spyk(), + mediaPlayer: MediaPlayer = spyk(), + settings: Preferences = spyk(), + urlMaker: UrlMaker = spyk(), +) = MainViewModel( + dispatcher = dispatcher, + converterFactory = converterFactory, + dateTimeChecker = dateTimeChecker, + mediaPlayer = mediaPlayer, + settings = settings, + urlMaker = urlMaker, + saveFileNameGenerator = spyk() +) + +fun constructMediaInfo( + speed: Speed = defaultSpeed, + progress: Progress = Progress(fraction = 0f, length = Duration.ZERO), + isResumed: Boolean = defaultIsResumed, + clipToLoop: Clip? = defaultClipToLoop, + audioVolume: Float = defaultAudioVolume, + isAudioMuted: Boolean = defaultIsAudioMuted +) = MediaInfo( + url = defaultMediaUrl, + speed = speed, + progress = progress, + isResumed = isResumed, + clipToLoop = clipToLoop, + audioVolume = audioVolume, + isAudioMuted = isAudioMuted +) + +class FakeMediaPlayer(private val mediaDuration: Duration) : MediaPlayer { + override val video = flowOf(null) + override val progress = MutableStateFlow(Progress(0f, mediaDuration)) + + override fun seek(value: Float) { + progress.value = Progress(value, mediaDuration) + } + + override fun play(url: URL) {} + override fun pause() {} + override fun resume() {} + override fun toggleResume() {} + override fun setSpeed(value: Float) {} + override fun setClipToLoop(clip: Clip?) {} + override fun setAudioVolume(value: Float) {} + override fun mute() {} + override fun unMute() {} + override fun terminate() {} + override fun takeScreenshot(saveDirectory: File) = true + override fun setFinishListener(listener: () -> Unit) {} +} + +fun ImageBitmap?.getPixels(): IntArray? { + if (this == null) return null + return toAwtImage().data.getPixels(0, 0, width - 2, height, null as IntArray?) +} + +fun getResourceAsPath(name: String): Path = resourceAccessor + .javaClass + .getResource("/$name")!! + .toURI() + .toPath() + +/** + * Could not use `val input = javaClass.getResource("/test.ts")` because, + * although the created URL is valid, FFmpeg throws error as + * it does not parse all variations of `file:...` URL syntax correctly. + * + * See https://trac.ffmpeg.org/ticket/2702 + * and https://trac.ffmpeg.org/ticket/9157 + */ +fun getResourceAsURL(name: String): URL { + val testResourcesDirectory = Path("src/test/resources").absolutePathString() + return URL("file:$testResourcesDirectory/$name") +} + +fun extractFrame(from: Path, time: String, into: Path) { + ProcessBuilder() + .command( + ffmpegPath, + "-i", from.toString(), + "-ss", time, + "-frames", "1", + "-filter:v", + "scale=100:-1", + into.toString() + ) + .start() + ?.errorStream + ?.reader() + ?.forEachLine { println("Test output: $it") } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/UtilitiesTest.kt b/src/test/kotlin/ir/mahozad/cutcon/UtilitiesTest.kt new file mode 100644 index 0000000..66ac5be --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/UtilitiesTest.kt @@ -0,0 +1,1093 @@ +package ir.mahozad.cutcon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import ir.mahozad.cutcon.model.* +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import java.nio.file.Path +import java.time.LocalDate +import java.util.* +import java.util.function.Consumer +import kotlin.io.path.Path +import kotlin.io.path.createTempFile +import kotlin.io.path.inputStream +import kotlin.time.Duration.Companion.seconds + +class UtilitiesTest { + + @Nested + @TestInstance(Lifecycle.PER_CLASS) + inner class DetectMimeTypeTest { + @ParameterizedTest + @MethodSource("generatePathsAndExpectedResults") + fun `Detecting various files should return proper mime type`( + argument: Pair + ) { + val (path, expectedResult) = argument + val result = path.detectMimeType() + assertThat(result).isEqualTo(expectedResult) + } + + private fun generatePathsAndExpectedResults() = listOf( + createTempFile(suffix = ".png") to "image/png", + createTempFile(suffix = ".mp3") to "audio/mpeg", + createTempFile(suffix = ".mp4") to "video/mp4", + createTempFile(suffix = ".ts") to "video/mp2t", + createTempFile(suffix = ".txt") to "text/plain", + createTempFile(suffix = ".abxzyc") to null, + createTempFile(suffix = ".png.mp3") to "audio/mpeg", // Multipart file extension + createTempFile(suffix = ".mp3.png") to "image/png", // Multipart file extension + createTempFile(suffix = "") to null, // No file extension + Path("non-existent-file.png") to null // Non-existent file + ) + } + + @Nested + @TestInstance(Lifecycle.PER_CLASS) + inner class NormalizeDigitsTest { + @ParameterizedTest + @MethodSource("generateStringsAndExpectedResults") + fun `Detecting various files should return proper mime type`( + argument: Pair + ) { + val (string, expectedResult) = argument + val result = string.normalizeDigits() + assertThat(result).isEqualTo(expectedResult) + } + + private fun generateStringsAndExpectedResults() = listOf( + "" to "", + " " to " ", + "a" to "a", + "آ" to "آ", + "1" to "1", + "۱" to "1", + "۱1۲۳4ab .آ 4 ـ" to "11234ab .آ 4 ـ" + ) + } + + @Nested + @TestInstance(Lifecycle.PER_CLASS) + inner class ToTwoDigitTest { + @ParameterizedTest + @MethodSource("generateNumbersAndExpectedResults") + fun `Detecting various files should return proper mime type`( + argument: Pair + ) { + val (number, expectedResult) = argument + val result = number.toTwoDigit() + assertThat(result).isEqualTo(expectedResult) + } + + private fun generateNumbersAndExpectedResults() = listOf( + 0 to "00", + 1 to "01", + 9 to "09", + 12 to "12", + 99 to "99" + ) + } + + @Nested + @TestInstance(Lifecycle.PER_CLASS) + inner class CompareVersionStringsTest { + @ParameterizedTest + @MethodSource("generateVersionsAndExpectedResults") + fun `Converting various versions should return proper numbers`( + argument: Triple + ) { + val (thisVersion, thatVersion, expectedResult) = argument + val result = compareVersionStrings(thisVersion, thatVersion) + assertThat(result).isEqualTo(expectedResult) + } + + private fun generateVersionsAndExpectedResults() = listOf( + Triple("1", null, VersionComparisonResult.NEWER), + Triple("1", "", VersionComparisonResult.NEWER), + Triple("1", "0", VersionComparisonResult.NEWER), + Triple("0", "0", VersionComparisonResult.SAME), + Triple("0", "1", VersionComparisonResult.OLDER), + Triple("1.2", "4.3", VersionComparisonResult.OLDER), + Triple("3.2", "1.4", VersionComparisonResult.NEWER), + Triple("0.0.1", "0.1.0", VersionComparisonResult.OLDER), + Triple("0.1.0", "0.0.1", VersionComparisonResult.NEWER), + Triple("1.2.3", "2.0.0", VersionComparisonResult.OLDER), + Triple("2.0.0", "1.2.3", VersionComparisonResult.NEWER), + Triple("2.3.0", "2.1.0", VersionComparisonResult.NEWER), + Triple("2.3.0", "2.1.7", VersionComparisonResult.NEWER), + Triple("2.1.7", "2.1.7", VersionComparisonResult.SAME), + Triple(" 2.1.7", "2.1.7 ", VersionComparisonResult.SAME), + Triple("2.1.7", null, VersionComparisonResult.NEWER), + Triple("2.1.7", "", VersionComparisonResult.NEWER), + Triple("2.1", "1.7.18", VersionComparisonResult.NEWER), + Triple("2.1.0", "1.7.18-rc01", VersionComparisonResult.NEWER), + Triple("2.1.7-alpha03", "2.1.7", VersionComparisonResult.OLDER), + Triple("2.1.7", "2.1.7-alpha03", VersionComparisonResult.NEWER), + Triple("2.1.7-alpha03", "2.1.7-rc", VersionComparisonResult.OLDER), + Triple("2.1.7-alpha03", "2.1.7-beta01", VersionComparisonResult.OLDER), + Triple("2.1.7-alpha03", "2.1.7-alpha04", VersionComparisonResult.OLDER), + Triple("2.1.7-alpha04", "2.1.7-alpha03", VersionComparisonResult.NEWER), + Triple("2.1.7-beta03", "2.1.7-rc01", VersionComparisonResult.OLDER), + Triple("2.1.7-beta03", "2.1.7-alpha01", VersionComparisonResult.NEWER), + Triple("2.1.7-beta.1", "2.1.7-alpha.3", VersionComparisonResult.NEWER), + Triple("2.1.7-9", "2.1.7-8", VersionComparisonResult.NEWER), + Triple("2.1.7-8", "2.1.7-9", VersionComparisonResult.OLDER) + ) + } + + @Nested + inner class CalculateMaxSizeInFrameTest { + @Test + fun `When desired ratio is 16_9 and frame ratio is greater than desired ratio`() { + val result = calculateMaxSizeInFrame( + frameWidth = 320.dp, + frameHeight = 90.dp, + desiredAspectRatio = 16f / 9f + ) + assertThat(result).isEqualTo(DpSize(160.dp, 90.dp)) + } + + @Test + fun `When desired ratio is 16_9 and frame ratio is less than desired ratio`() { + val result = calculateMaxSizeInFrame( + frameWidth = 45.dp, + frameHeight = 90.dp, + desiredAspectRatio = 16f / 9f + ) + assertThat(result).isEqualTo(DpSize(45.dp, (45f / AspectRatio.W16H9.ratio!!).dp)) + } + + @Test + fun `When desired ratio is 4_3 and frame ratio is greater than desired ratio`() { + val result = calculateMaxSizeInFrame( + frameWidth = 800.dp, + frameHeight = 300.dp, + desiredAspectRatio = 4f / 3f + ) + assertThat(result).isEqualTo(DpSize((300 * (4f / 3f)).dp, 300.dp)) + } + + @Test + fun `When desired ratio is 4_3 and frame ratio is less than desired ratio`() { + val result = calculateMaxSizeInFrame( + frameWidth = 200.dp, + frameHeight = 300.dp, + desiredAspectRatio = 4f / 3f + ) + assertThat(result).isEqualTo(DpSize(200.dp, (200 / (4f / 3f)).dp)) + } + } + + @Nested + inner class IsValidIpTest { + @Test + fun `An empty string should be invalid`() { + val result = "".isValidIp() + assertThat(result).isFalse() + } + + @Test + fun `A string with no IP in it should be invalid`() { + val result = "hello1234abc".isValidIp() + assertThat(result).isFalse() + } + + @Test + fun `A string with IP of all Latin digits should be valid`() { + val result = "192.168.1.50".isValidIp() + assertThat(result).isTrue() + } + + @Test + fun `A string with letters and IP of all Latin digits should be invalid`() { + val result = "hello192.168.1.50abc".isValidIp() + assertThat(result).isFalse() + } + + @Test + fun `A string with IP of all Farsi digits should be valid`() { + val result = "۱۹۲.۱۶۸.۱.۵۰".isValidIp() + assertThat(result).isTrue() + } + + @Test + fun `A string with letters and IP of all Farsi digits should be invalid 1`() { + val result = "hello۱۹۲.۱۶۸.۱.۵۰abc".isValidIp() + assertThat(result).isFalse() + } + + @Test + fun `A string with letters and IP of all Farsi digits should be invalid 2`() { + val result = "سلام۱۹۲.۱۶۸.۱.۵۰آب".isValidIp() + assertThat(result).isFalse() + } + + @Test + fun `A string with IP of mixed digits should be valid`() { + val result = "۱9۲.۱۶8.۱.۵۰".isValidIp() + assertThat(result).isTrue() + } + + @Test + fun `A string with letters and IP of mixed digits should be invalid`() { + val result = "hello۱9۲.۱۶8.۱.۵۰abc".isValidIp() + assertThat(result).isFalse() + } + + @ParameterizedTest + @ValueSource(strings = ["192.168.1.", "192.168.1", "۱192.168.1.50"]) + fun `A string with invalid IP should be invalid`(string: String) { + val result = string.isValidIp() + assertThat(result).isFalse() + } + } + + @Nested + @TestInstance(Lifecycle.PER_CLASS) + inner class PathTrimTest { + @ParameterizedTest + @MethodSource("generatePathsAndExpectedResults") + fun `Trimming various paths should produce proper result`( + argument: Pair + ) { + val (path, expectedResult) = argument + val result = path.trim(maxLength = 30) + assertThat(result).isEqualTo(expectedResult) + } + + @Suppress("SpellCheckingInspection") + private fun generatePathsAndExpectedResults() = listOf( + Path("a/b/c") to Path("a/b/c"), + Path("""C:/Users/User/Desktop/abc.mp4""") to Path("C:/Users/User/Desktop/abc.mp4"), + Path("""C:/Users/User/Desktop/abcde.mp4""") to Path(".../User/Desktop/abcde.mp4"), + Path("abcdefghiklmnopqrstuvwxyzABCDE") to Path("abcdefghiklmnopqrstuvwxyzABCDE"), + Path("abcdefghiklmnopqrstuvwxyzABCDEF") to Path("...efghiklmnopqrstuvwxyzABCDEF"), + Path("x/abcdefghiklmnopqrstuvwxyzABC") to Path("x/abcdefghiklmnopqrstuvwxyzABC"), + Path("x/abcdefghiklmnopqrstuvwxyzABCDE") to Path("abcdefghiklmnopqrstuvwxyzABCDE"), + Path(".../abcdefghiklmnopqrstuvwxyzA") to Path(".../abcdefghiklmnopqrstuvwxyzA"), // Has ... as a directory name + Path(".../abcdefghiklmnopqrstuvwxyzAB") to Path("abcdefghiklmnopqrstuvwxyzAB"), // Has ... as a directory name + Path(".../abcdefghiklmnopqrstuvwxyzABCDE") to Path("abcdefghiklmnopqrstuvwxyzABCDE"), // Has ... as a directory name + Path(".../abcdefghiklmnopqrstuvwxyzABCDEF") to Path("...efghiklmnopqrstuvwxyzABCDEF"), // Has ... as a directory name + Path("a/b/c/d/.../abcdefghiklmnopqrst") to Path(".../d/.../abcdefghiklmnopqrst"), // Has ... as a directory name + Path(".../a/b/c/d/abcdefghiklmnopqrst") to Path(".../b/c/d/abcdefghiklmnopqrst"), // Has ... as a directory name + Path("""C:\Users\User\Desktop\abcdefghiklmnopqrstuvwxyzABCDEF""") to Path("...efghiklmnopqrstuvwxyzABCDEF"), + Path("abcdefgh/qwertyiop/zxcvbnm") to Path("abcdefgh/qwertyiop/zxcvbnm"), + Path("abcdefghjkl/qwertyiop/zxcvbnm") to Path("abcdefghjkl/qwertyiop/zxcvbnm"), + Path("abcdefghjklop/qwertyiop/zxcvbnm") to Path(".../qwertyiop/zxcvbnm") + ) + + /** + * See https://youtrack.jetbrains.com/issue/KT-62225 for why. + */ + @EnabledOnOs(OS.WINDOWS) + @ParameterizedTest + @MethodSource("generateWindowPathsAndExpectedResults") + fun `Trimming paths with backslash separators should produce proper result`( + argument: Pair + ) { + val (path, expectedResult) = argument + val result = path.trim(maxLength = 30) + assertThat(result).isEqualTo(expectedResult) + } + + @Suppress("SpellCheckingInspection") + private fun generateWindowPathsAndExpectedResults() = listOf( + Path("""a\b\c""") to Path("a/b/c"), + Path("""C:\Users\User\Desktop\abc.mp4""") to Path("C:/Users/User/Desktop/abc.mp4"), + Path("""C:\Users\User\Desktop\abcde.mp4""") to Path(".../User/Desktop/abcde.mp4") + ) + } + + @Nested + @TestInstance(Lifecycle.PER_CLASS) + inner class PathToLtrString { + @Test + fun `Converting a Linux path to LTR string should produce proper result`() { + val path = Path(""".../موسیقی/abcdefghijklmnopq""") + val result = path.toLtrString() + assertThat(result).isEqualTo("\u202A.../\u202Aموسیقی/\u202Aabcdefghijklmnopq") + } + + /** + * See https://youtrack.jetbrains.com/issue/KT-62225 for why. + */ + @EnabledOnOs(OS.WINDOWS) + @Test + fun `Converting a Windows path to LTR string should produce proper result`() { + val path = Path("""...\موسیقی\abcdefghijklmnopq""") + val result = path.toLtrString() + assertThat(result).isEqualTo("\u202A.../\u202Aموسیقی/\u202Aabcdefghijklmnopq") + } + } + + @Nested + inner class ParseMarkdownAsChangelogTest { + @Test + fun `Parsing changelog that contains language-agnostic entries (@ language tag) should succeed`() { + val expected = ChangelogVersion( + name = "1.2.5", + date = LocalDate.of(2023, 6, 19), + categories = listOf( + ChangelogCategory( + type = CategoryType.INTERNAL, + entries = listOf( + ChangelogEntry(items = listOf("Add some changes")), + ChangelogEntry(items = listOf("Update some TODOs")), + ChangelogEntry(items = listOf("Add another change")), + ChangelogEntry(items = listOf("Refactor build code")), + ChangelogEntry(items = listOf("Rename some methods")) + ) + ) + ) + ) + val result = getResourceAsPath("test-2.md") + .inputStream() + .use { parseMarkdownAsChangelog(it, languageTag = /* Does NOT matter */ "En") } + .versions + .first() + assertThat(result).isEqualTo(expected) + } + + @Test + fun `For English language`() { + val expected = Changelog( + versions = listOf( + ChangelogVersion( + name = "1.2.0", + date = LocalDate.of(2023, 6, 15), + categories = listOf( + ChangelogCategory( + type = CategoryType.FEATURE, + entries = listOf( + ChangelogEntry(items = listOf("Add progress/seek bar and live button to mini player")), + ChangelogEntry(items = listOf("Add a new slow speed (0.75)")) + ) + ), + ChangelogCategory( + type = CategoryType.BUGFIX, + entries = listOf( + ChangelogEntry(items = listOf("Fix the bug with speed number being reset when toggling side panel or mini player")) + ) + ), + ChangelogCategory( + type = CategoryType.UPDATE, + entries = listOf( + ChangelogEntry(items = listOf("Redesign the speed input")), + ChangelogEntry(items = listOf("Update some of the icons")), + ChangelogEntry(items = listOf("Update the clip creation label")) + ) + ), + ChangelogCategory( + type = CategoryType.INTERNAL, + entries = listOf( + ChangelogEntry( + items = listOf( + "Update the inputs aesthetics", + "Also, change how they handle clicks" + ) + ) + ) + ) + ) + ), + ChangelogVersion( + name = "1.1.0", + date = LocalDate.of(2023, 6, 8), + categories = listOf( + ChangelogCategory( + type = CategoryType.FEATURE, entries = listOf( + ChangelogEntry(items = listOf("Add native splash screen")), + ChangelogEntry(items = listOf("Show success window with a notification sound when the clip creation is done")), + ChangelogEntry(items = listOf("Add mini player mode")) + ) + ), + ChangelogCategory( + type = CategoryType.BUGFIX, + entries = listOf( + ChangelogEntry( + items = listOf( + "Fix minor bugs", + "bug 1", + "bug 2", + "bug 3" + ) + ) + ) + ), + ChangelogCategory( + type = CategoryType.REMOVAL, + entries = listOf( + ChangelogEntry(items = listOf("Remove the screenshot button")) + ) + ) + ) + ), + ChangelogVersion( + name = "1.0.0", + date = LocalDate.of(2023, 6, 1), + categories = listOf( + ChangelogCategory( + type = CategoryType.INTERNAL, + entries = listOf( + ChangelogEntry(items = listOf("Release the first version of the app")) + ) + ) + ) + ) + ) + ) + val result = getResourceAsPath("test-1.md") + .inputStream() + .use { parseMarkdownAsChangelog(it, languageTag = "En") } + assertThat(result).isEqualTo(expected) + } + + @Test + fun `For Farsi (Persian) language`() { + val expected = Changelog( + versions = listOf( + ChangelogVersion( + name = "1.2.0", + date = LocalDate.of(2023, 6, 15), + categories = listOf( + ChangelogCategory( + type = CategoryType.FEATURE, + entries = listOf( + ChangelogEntry(items = listOf("اضافه شدن نوار پیشرفت و جلو/عقب و دکمه پخش زنده به پخش\u200Cکننده مینی")), + ChangelogEntry(items = listOf("اضافه شدن یک سرعت جدید (۰٫۷۵)")) + ) + ), + ChangelogCategory( + type = CategoryType.BUGFIX, + entries = listOf( + ChangelogEntry(items = listOf("رفع مشکل ریست شدن عدد ورودی سرعت هنگام فعال یا غیر فعال کردن پخش\u200Cکننده مینی")) + ) + ), + ChangelogCategory( + type = CategoryType.UPDATE, + entries = listOf( + ChangelogEntry(items = listOf("بازطراحی ورودی سرعت")), + ChangelogEntry(items = listOf("بروزرسانی بعضی از آیکون\u200Cها")), + ChangelogEntry(items = listOf("بروزرسانی برچسب ایجاد کلیپ")) + ) + ), + ChangelogCategory( + type = CategoryType.INTERNAL, + entries = listOf( + ChangelogEntry( + items = listOf( + "بروزرسانی ظاهر ورودی\u200Cها", + "همچنین، تغییر نحوه\u200Cی انجام کلیک بر روی آن\u200Cها" + ) + ) + ) + ) + ) + ), + ChangelogVersion( + name = "1.1.0", + date = LocalDate.of(2023, 6, 8), + categories = listOf( + ChangelogCategory( + type = CategoryType.FEATURE, entries = listOf( + ChangelogEntry(items = listOf("اضافه شدن صفحه اسپلش (تصویر شروع)")), + ChangelogEntry(items = listOf("نمایش پنجره موفقیت همراه با یک افکت صوتی هنگام تکمیل ایجاد کلیپ")), + ChangelogEntry(items = listOf("اضافه شدن پخش\u200Cکننده مینی")) + ) + ), + ChangelogCategory( + type = CategoryType.BUGFIX, + entries = listOf( + ChangelogEntry( + items = listOf( + "رفع برخی باگ\u200Cهای جزئی", + "باگ ۱", + "باگ ۲", + "باگ ۳" + ) + ) + ) + ), + ChangelogCategory( + type = CategoryType.REMOVAL, + entries = listOf( + ChangelogEntry(items = listOf("حذف دکمه اسکرین\u200Cشات")) + ) + ) + ) + ), + ChangelogVersion( + name = "1.0.0", + date = LocalDate.of(2023, 6, 1), + categories = listOf( + ChangelogCategory( + type = CategoryType.INTERNAL, + entries = listOf( + ChangelogEntry(items = listOf("انتشار نخستین ویرایش پایدار برنامه")) + ) + ) + ) + ) + ) + ) + val result = getResourceAsPath("test-1.md") + .inputStream() + .use { parseMarkdownAsChangelog(it, languageTag = "Fa") } + assertThat(result).isEqualTo(expected) + } + } + + @Nested + inner class ParseStringToDurationTest { + @Test + fun `Parsing an empty string should return null`() { + val duration = "".toDuration() + assertThat(duration).isNull() + } + + @Test + fun `Parsing a valid string with all non-zero components should return valid duration`() { + val duration = "1:24:17".toDuration()!! + assertThat(duration).isEqualTo(5057.seconds) + } + + @Test + fun `Parsing a valid string with zero hour should return valid duration`() { + val duration = "0:24:17".toDuration()!! + assertThat(duration).isEqualTo(1457.seconds) + } + + @Test + fun `Parsing a valid string with no hour should return valid duration`() { + val duration = "24:17".toDuration()!! + assertThat(duration).isEqualTo(1457.seconds) + } + + @Test + fun `Parsing a valid string with no hour and zero minute should return valid duration`() { + val duration = "0:17".toDuration()!! + assertThat(duration).isEqualTo(17.seconds) + } + } + + @Nested + inner class ParseDateTest { + @Test + fun `parseDate should return null when input is empty`() { + val date = "".parseAsDate() + assertThat(date).isNull() + } + + @Test + fun `parseDate should return null when day is zero`() { + val date = "1401-06-0".parseAsDate() + assertThat(date).isNull() + } + + @Test + fun `parseDate should return null when month is zero`() { + val date = "1401-0-03".parseAsDate() + assertThat(date).isNull() + } + + @Test + fun `parseDate should return null when month is greater than 12`() { + val date = "1401-13-03".parseAsDate() + assertThat(date).isNull() + } + + @Test + fun `parseDate should return null when day is greater than 31`() { + val date = "1401-06-32".parseAsDate() + assertThat(date).isNull() + } + + @Test + fun `parseDate should not convert to another calendar system when year is greater than 1800`() { + val date = "2001-06-03".parseAsDate()!! + assertThat(date.year).isEqualTo(2001) + assertThat(date.monthValue).isEqualTo(6) + assertThat(date.dayOfMonth).isEqualTo(3) + } + + @Test + fun `parseDate should convert to Gregorian calendar system when year is less than 1800`() { + val date = "1391-06-03".parseAsDate()!! + assertThat(date.year).isEqualTo(2012) + assertThat(date.monthValue).isEqualTo(8) + assertThat(date.dayOfMonth).isEqualTo(24) + } + + @Test + fun `parseDate should return a gregorian date when input is well-formed jalali date`() { + val date = "1401-06-03".parseAsDate()!! + assertThat(date.year).isEqualTo(2022) + assertThat(date.monthValue).isEqualTo(8) + assertThat(date.dayOfMonth).isEqualTo(25) + } + + @Test + fun `parseDate should return a date when date has redundant delimiters`() { + val date = "1401..06,,,03".parseAsDate()!! + assertThat(date.year).isEqualTo(2022) + assertThat(date.monthValue).isEqualTo(8) + assertThat(date.dayOfMonth).isEqualTo(25) + } + + @Test + fun `parseDate should return null when date has more than 3 components`() { + val date = "1401/06/03/04".parseAsDate() + assertThat(date).isNull() + } + + @Test + fun `parseDate should return a date when date has surrounding spaces`() { + val date = " 1401/06/03 ".parseAsDate()!! + assertThat(date.year).isEqualTo(2022) + assertThat(date.monthValue).isEqualTo(8) + assertThat(date.dayOfMonth).isEqualTo(25) + } + + @Test + fun `parseDate should return a date when date has eastern arabic digits`() { + val date = "۱۴۰۱/۰۶/۰۳".parseAsDate()!! + assertThat(date.year).isEqualTo(2022) + assertThat(date.monthValue).isEqualTo(8) + assertThat(date.dayOfMonth).isEqualTo(25) + } + + @Test + fun `parseDate should return a date when date has mixed western and eastern arabic digits`() { + val date = "۱۴۰۱/۰۶/03".parseAsDate()!! + assertThat(date.year).isEqualTo(2022) + assertThat(date.monthValue).isEqualTo(8) + assertThat(date.dayOfMonth).isEqualTo(25) + } + + @Test + fun `parseDate should return a date when date has a single digit component`() { + val date = "1401/6/03".parseAsDate()!! + assertThat(date.year).isEqualTo(2022) + assertThat(date.monthValue).isEqualTo(8) + assertThat(date.dayOfMonth).isEqualTo(25) + } + + @ParameterizedTest + @ValueSource(chars = [' ', '\t', '-', '_', '.', ',', '،', ':', ';', '؛', '|', '/', '\\', 'و']) + fun `parseDate should return date when date is delimited with these characters`(character: Char) { + val date = "1401${character}06${character}03".parseAsDate()!! + assertThat(date.year).isEqualTo(2022) + assertThat(date.monthValue).isEqualTo(8) + assertThat(date.dayOfMonth).isEqualTo(25) + } + } + + @Nested + inner class HandleInputForTimeMinuteTest { + @ParameterizedTest + @ValueSource(strings = ["a", ".", "-", "س"]) + fun `When user enters any non-digit character, new value should be current (En)`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("3", "3$argument", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("3") + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @ParameterizedTest + @ValueSource(strings = ["a", ".", "-", "س"]) + fun `When user enters any non-digit character, new value should be current (Fa)`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("۳", "۳$argument", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("۳") + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is empty and user enters any digit, new value should be exactly that digit`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("", argument, TextRange.One) + assertThat(textFieldValue.text).isEqualTo(argument) + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @ParameterizedTest + @ValueSource(strings = ["3", "۳"]) + fun `When current value is a digit and user clears input, new value should be empty`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute(argument, "", TextRange.Zero) + assertThat(textFieldValue.text).isEqualTo("") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Zero) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is a digit less than '6' (3) and cursor is before it and user enters any digit, new value should be 'digit3'`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("3", "${argument}3", TextRange.One) + assertThat(textFieldValue.text).isEqualTo("${argument}3") + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is a digit less than '6' (3) and cursor is after it and user enters any digit, new value should be '3digit'`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("3", "3$argument", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("3$argument") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is a digit greater than '5' (6) and cursor is before it and user enters any digit, new value should be 'digit6'`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("6", "${argument}6", TextRange.One) + assertThat(textFieldValue.text).isEqualTo("${argument}6") + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is a digit greater than '5' (6) and cursor is after it and user enters any digit, new value should be '6digit'`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("6", "6$argument", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("6$argument") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is a zero (0) and cursor is after it and user enters any digit, new value should be '0digit'`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("0", "0$argument", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("0$argument") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is two digit (13) and cursor is at start and user enters any digit, new value should be to 'digit3'`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("13", "${argument}13", TextRange.One) + assertThat(textFieldValue.text).isEqualTo("${argument}3") + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is two zero (00) and cursor is in between and user enters any digit, new value should be '0digit'`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("00", "0${argument}0", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("0$argument") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is two digit (13) and cursor is at end and user enters any digit, new value should be current '13'`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("13", "13$argument", TextRange.Three) + assertThat(textFieldValue.text).isEqualTo("13") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is two digit (13) and cursor is in between and user enters any digit, new value should be '1digit'`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("13", "1${argument}3", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("1$argument") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @ParameterizedTest + @ValueSource(strings = ["5", "۵"]) + fun `When current value is two digit (50) and cursor is in between and user enters a digit that is equal to current first digit (5), new value should be equal to '55'`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("50", "5${argument}0", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("5$argument") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @ParameterizedTest + @ValueSource(strings = ["5", "۵"]) + fun `When current value is two digit (50) and cursor is at start and user enters a digit that is equal to current first digit (5), new value should be equal to '50'`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("50", "${argument}50", TextRange.One) + assertThat(textFieldValue.text).isEqualTo("${argument}0") + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @ParameterizedTest + @ValueSource(strings = ["1234", "5912", "6031", "8545"]) + fun `When input value has 3 or more digits (because it was entered very fast), new value should be equal to current`( + argument: String + ) { + val textFieldValue = handleInputForTimeMinute("41", argument, TextRange.Three) + assertThat(textFieldValue.text).isEqualTo("41") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + } + + @Nested + inner class HandleInputForTimeSecondTest { + @ParameterizedTest + @ValueSource(strings = ["a", ".", "-", "س"]) + fun `When user enters any non-digit character, new value should be current`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("3", "3$argument", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("3") + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is empty and user enters any digit, new value should be exactly that digit`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("", argument, TextRange.One) + assertThat(textFieldValue.text).isEqualTo(argument) + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @ParameterizedTest + @ValueSource(strings = ["3", "۳"]) + fun `When current value is a digit and user clears input, new value should be empty`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond(argument, "", TextRange.Zero) + assertThat(textFieldValue.text).isEqualTo("") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Zero) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "1", "4", "5", "۰", "۱", "۴", "۵"]) + fun `When current value is a digit less than '6' (3) and cursor is before it and user enters a digit less than '6', new value should be 'digit3'`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("3", "${argument}3", TextRange.One) + assertThat(textFieldValue.text).isEqualTo("${argument}3") + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @ParameterizedTest + @ValueSource(strings = ["6", "7", "9", "۶", "۷", "۹"]) + fun `When current value is a digit less than '6' (3) and cursor is before it and user enters a digit greater than '5', new value should be current '3'`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("3", "${argument}3", TextRange.One) + assertThat(textFieldValue.text).isEqualTo("3") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Zero) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is a digit less than '6' (3) and cursor is after it and user enters any digit, new value should be '3digit' (En)`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("3", "3$argument", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("3$argument") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is a digit less than '6' (3) and cursor is after it and user enters any digit, new value should be '3digit' (Fa)`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("۳", "۳$argument", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("۳$argument") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is a digit greater than '5' (6) and cursor is after it and user enters any digit, new value should be current`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("6", "6$argument", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("6") + assertThat(textFieldValue.selection).satisfiesAnyOf( + Consumer { assertThat(it!!).isEqualTo(TextRange.One) }, + Consumer { assertThat(it!!).isEqualTo(TextRange.Two) } + ) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "1", "4", "5", "۰", "۱", "۴", "۵"]) + fun `When current value is a digit greater than '5' (6) and cursor is before it and user enters a digit less than '6', new value should be 'digit6'`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("6", "${argument}6", TextRange.One) + assertThat(textFieldValue.text).isEqualTo("${argument}6") + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @Test + fun `When current value is a digit greater than '5' (6) and cursor is before it and user enters that same digit (6), new value should be '6'`() { + val textFieldValue = handleInputForTimeSecond("6", "66", TextRange.One) + assertThat(textFieldValue.text).isEqualTo("6") + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @ParameterizedTest + @ValueSource(strings = ["6", "7", "9", "۶", "۷", "۹"]) + fun `When current value is a digit greater than '5' (6) and cursor is before it and user enters a digit greater than '5', new value should be current '6'`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("6", "${argument}6", TextRange.One) + assertThat(textFieldValue.text).isEqualTo("6") + assertThat(textFieldValue.selection).satisfiesAnyOf( + Consumer { assertThat(it!!).isEqualTo(TextRange.Zero) }, + Consumer { assertThat(it!!).isEqualTo(TextRange.One) } + ) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "1", "4", "5", "۰", "۱", "۴", "۵"]) + fun `When current value is two digit (23) and cursor is at start and user enters a digit less than '6', new value should be 'digit3'`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("23", "${argument}23", TextRange.One) + assertThat(textFieldValue.text).isEqualTo("${argument}3") + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "1", "4", "5", "۰", "۱", "۴", "۵"]) + fun `When current value is two zero (00) and cursor is in between and user enters a digit less than '6', new value should be '0digit'`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("00", "0${argument}0", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("0$argument") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is a zero (0) and cursor is after it and user enters any digit, new value should be '0digit'`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("0", "0$argument", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("0$argument") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @ParameterizedTest + @ValueSource(strings = ["6", "7", "9", "۶", "۷", "۹"]) + fun `When current value is two digit (13) and cursor is at start and user enters a digit greater than '5', new value should be current '13'`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("13", "${argument}13", TextRange.One) + assertThat(textFieldValue.text).isEqualTo("13") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Zero) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is two digit (13) and cursor is at end and user enters any digit, new value should be current '13'`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("13", "13$argument", TextRange.Three) + assertThat(textFieldValue.text).isEqualTo("13") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "5", "6", "7", "9", "۰", "۵", "۶", "۷", "۹"]) + fun `When current value is two digit (13) and cursor is in between and user enters any digit, new value should be '1digit'`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("13", "1${argument}3", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("1$argument") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @Test + fun `When current value is two digit (50) and cursor is in between and user enters a digit that is equal to current first digit (5), new value should be equal to '55'`() { + val textFieldValue = handleInputForTimeSecond("50", "550", TextRange.Two) + assertThat(textFieldValue.text).isEqualTo("55") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + + @Test + fun `When current value is two digit (50) and cursor is at start and user enters a digit that is equal to current first digit (5), new value should be equal to '50'`() { + val textFieldValue = handleInputForTimeSecond("50", "550", TextRange.One) + assertThat(textFieldValue.text).isEqualTo("50") + assertThat(textFieldValue.selection).isEqualTo(TextRange.One) + } + + @ParameterizedTest + @ValueSource(strings = ["1234", "5912", "6031", "8545"]) + fun `When input value has 3 or more digits (because it was entered very fast), new value should be equal to current`( + argument: String + ) { + val textFieldValue = handleInputForTimeSecond("41", argument, TextRange.Three) + assertThat(textFieldValue.text).isEqualTo("41") + assertThat(textFieldValue.selection).isEqualTo(TextRange.Two) + } + } + + @Test + fun `EmitLatestEvery should emit the latest value periodically`() = runTest { + val results = mutableListOf() + flowOf(1, 2, 3) + .emitLatestEvery(7.seconds) + .take(5) + .collect(results::add) + assertThat(results).containsExactly(1, 2, 3, 3, 3) + } + + @Nested + @TestInstance(Lifecycle.PER_CLASS) + inner class ColorToHexTest { + @ParameterizedTest + @MethodSource("generateColorsAndExpectedResults") + fun `Formatting color to hex should produce correct result`( + argument: Pair + ) { + val (color, expectedResult) = argument + val result = color.toHex() + assertThat(result).isEqualTo(expectedResult) + } + + private fun generateColorsAndExpectedResults() = listOf( + Color(0xffffff) to "#ffffff", + Color(0x00ffff) to "#00ffff", + Color(0xff00ff) to "#ff00ff", + Color(0xffff00) to "#ffff00", + Color(0x000000) to "#000000" + ) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/component/DateTimeCheckerTest.kt b/src/test/kotlin/ir/mahozad/cutcon/component/DateTimeCheckerTest.kt new file mode 100644 index 0000000..384dbf0 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/component/DateTimeCheckerTest.kt @@ -0,0 +1,193 @@ +package ir.mahozad.cutcon.component + +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkAll +import ir.mahozad.cutcon.defaultDateTimeCheckingPeriod +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalTime +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +class DateTimeCheckerTest { + + companion object { + @JvmStatic + @BeforeAll + fun initialize() { + mockkObject(SystemDateTime) + } + + @JvmStatic + @AfterAll + fun terminate() { + unmockkAll() + } + } + + @Test + fun `DateTimeChecker should emit new time value if day of month changes`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val dateTimeChecker = DefaultDateTimeChecker(dispatcher) + every { SystemDateTime.nowDate() } returnsMany listOf( + LocalDate.of(1725, 6, 7), + LocalDate.of(1725, 6, 8), + ) + every { SystemDateTime.nowTime() } returns LocalTime.of(3, 4, 5, 6) + val results = mutableListOf>() + backgroundScope.launch(dispatcher) { dateTimeChecker.dateTimeFlow().toList(results) } + delay(defaultDateTimeCheckingPeriod + 50.milliseconds) + assertThat(results).containsExactly( + LocalDate.of(1725, 6, 7) to LocalTime.of(3, 4), + LocalDate.of(1725, 6, 8) to LocalTime.of(3, 4) + ) + } + + @Test + fun `DateTimeChecker should emit new time value if month of year changes`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val dateTimeChecker = DefaultDateTimeChecker(dispatcher) + every { SystemDateTime.nowDate() } returnsMany listOf( + LocalDate.of(1725, 6, 7), + LocalDate.of(1725, 8, 7), + ) + every { SystemDateTime.nowTime() } returns LocalTime.of(3, 4, 5, 6) + val results = mutableListOf>() + backgroundScope.launch(dispatcher) { dateTimeChecker.dateTimeFlow().toList(results) } + delay(defaultDateTimeCheckingPeriod + 50.milliseconds) + assertThat(results).containsExactly( + LocalDate.of(1725, 6, 7) to LocalTime.of(3, 4), + LocalDate.of(1725, 8, 7) to LocalTime.of(3, 4) + ) + } + + @Test + fun `DateTimeChecker should emit new time value if year changes`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val dateTimeChecker = DefaultDateTimeChecker(dispatcher) + every { SystemDateTime.nowDate() } returnsMany listOf( + LocalDate.of(1725, 6, 7), + LocalDate.of(1728, 6, 7), + ) + every { SystemDateTime.nowTime() } returns LocalTime.of(3, 4, 5, 6) + val results = mutableListOf>() + backgroundScope.launch(dispatcher) { dateTimeChecker.dateTimeFlow().toList(results) } + delay(defaultDateTimeCheckingPeriod + 50.milliseconds) + assertThat(results).containsExactly( + LocalDate.of(1725, 6, 7) to LocalTime.of(3, 4), + LocalDate.of(1728, 6, 7) to LocalTime.of(3, 4) + ) + } + + @Test + fun `DateTimeChecker should not emit new time value if nanosecond changes`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val dateTimeChecker = DefaultDateTimeChecker(dispatcher) + every { SystemDateTime.nowDate() } returns LocalDate.of(1725, 6, 7) + every { SystemDateTime.nowTime() } returnsMany listOf( + LocalTime.of(3, 4, 5, 6), + LocalTime.of(3, 4, 5, 7) + ) + val results = mutableListOf>() + backgroundScope.launch(dispatcher) { dateTimeChecker.dateTimeFlow().toList(results) } + delay(defaultDateTimeCheckingPeriod + 50.milliseconds) + assertThat(results).containsExactly( + LocalDate.of(1725, 6, 7) to LocalTime.of(3, 4) + ) + } + + @Test + fun `DateTimeChecker should not emit new time value if second changes`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val dateTimeChecker = DefaultDateTimeChecker(dispatcher) + every { SystemDateTime.nowDate() } returns LocalDate.of(1725, 6, 7) + every { SystemDateTime.nowTime() } returnsMany listOf( + LocalTime.of(3, 4, 5, 0), + LocalTime.of(3, 4, 6, 0) + ) + val results = mutableListOf>() + backgroundScope.launch(dispatcher) { dateTimeChecker.dateTimeFlow().toList(results) } + delay(defaultDateTimeCheckingPeriod + 50.milliseconds) + assertThat(results).containsExactly( + LocalDate.of(1725, 6, 7) to LocalTime.of(3, 4) + ) + } + + @Test + fun `DateTimeChecker should emit new time value if minute changes`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val dateTimeChecker = DefaultDateTimeChecker(dispatcher) + every { SystemDateTime.nowDate() } returns LocalDate.of(1725, 6, 7) + every { SystemDateTime.nowTime() } returnsMany listOf( + LocalTime.of(3, 4, 5, 6), + LocalTime.of(3, 5, 5, 6) + ) + val results = mutableListOf>() + backgroundScope.launch(dispatcher) { dateTimeChecker.dateTimeFlow().toList(results) } + delay(defaultDateTimeCheckingPeriod + 50.milliseconds) + assertThat(results).containsExactly( + LocalDate.of(1725, 6, 7) to LocalTime.of(3, 4), + LocalDate.of(1725, 6, 7) to LocalTime.of(3, 5) + ) + } + + @Test + fun `DateTimeChecker should emit new time value if hour changes`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val dateTimeChecker = DefaultDateTimeChecker(dispatcher) + every { SystemDateTime.nowDate() } returns LocalDate.of(1725, 6, 7) + every { SystemDateTime.nowTime() } returnsMany listOf( + LocalTime.of(3, 5, 6, 7), + LocalTime.of(4, 5, 6, 7) + ) + val results = mutableListOf>() + backgroundScope.launch(dispatcher) { dateTimeChecker.dateTimeFlow().toList(results) } + delay(defaultDateTimeCheckingPeriod + 50.milliseconds) + assertThat(results).containsExactly( + LocalDate.of(1725, 6, 7) to LocalTime.of(3, 5), + LocalDate.of(1725, 6, 7) to LocalTime.of(4, 5) + ) + } + + @Test + fun `DateTimeChecker should emit new time value if hour and minute change`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val dateTimeChecker = DefaultDateTimeChecker(dispatcher) + every { SystemDateTime.nowDate() } returns LocalDate.of(1725, 6, 7) + every { SystemDateTime.nowTime() } returnsMany listOf( + LocalTime.of(3, 5, 6, 7), + LocalTime.of(4, 6, 6, 7) + ) + val results = mutableListOf>() + backgroundScope.launch(dispatcher) { dateTimeChecker.dateTimeFlow().toList(results) } + delay(defaultDateTimeCheckingPeriod + 50.milliseconds) + assertThat(results).containsExactly( + LocalDate.of(1725, 6, 7) to LocalTime.of(3, 5), + LocalDate.of(1725, 6, 7) to LocalTime.of(4, 6) + ) + } + + @Test + fun `DateTimeChecker should not emit same value as the last one`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val dateTimeChecker = DefaultDateTimeChecker(dispatcher) + every { SystemDateTime.nowDate() } returns LocalDate.of(1725, 6, 7) + every { SystemDateTime.nowTime() } returns LocalTime.of(2, 3, 4) + val results = mutableListOf>() + backgroundScope.launch(dispatcher) { dateTimeChecker.dateTimeFlow().toList(results) } + delay(defaultDateTimeCheckingPeriod * 3 + 50.milliseconds) + assertThat(results).containsExactly( + LocalDate.of(1725, 6, 7) to LocalTime.of(2, 3) + ) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/component/DurationConverterTest.kt b/src/test/kotlin/ir/mahozad/cutcon/component/DurationConverterTest.kt new file mode 100644 index 0000000..4c58728 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/component/DurationConverterTest.kt @@ -0,0 +1,167 @@ +package ir.mahozad.cutcon.component + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +class DurationConverterTest { + @ParameterizedTest + @MethodSource("generateDurationsAndExpectedResults") + fun `Formatting various durations should produce proper result`( + argument: Triple + ) { + val (duration, desiredParts, expectedResult) = argument + val converter = DefaultDurationConverter + val result = converter.format(duration, numberOfParts = desiredParts) + assertThat(result).isEqualTo(expectedResult) + } + + @ParameterizedTest + @MethodSource("generateDurationStringsAndExpectedResults") + fun `Parsing various duration strings should produce proper result`( + argument: Pair + ) { + val (durationString, expectedResult) = argument + val converter = DefaultDurationConverter + val result = converter.parse(durationString) + assertThat(result).isEqualTo(expectedResult) + } + + companion object { + @JvmStatic + fun generateDurationsAndExpectedResults() = listOf( + Triple(Duration.ZERO, 1, "00"), + Triple(700.milliseconds, 1, "00"), + Triple(1.seconds, 1, "01"), + Triple(59.seconds, 1, "59"), + Triple(60.seconds, 1, "60"), + Triple(61.seconds, 1, "61"), + Triple(69.seconds, 1, "69"), + Triple(70.seconds, 1, "70"), + Triple(119.seconds, 1, "119"), + Triple(120.seconds, 1, "120"), + Triple(121.seconds, 1, "121"), + Triple(59.minutes + 59.seconds, 1, "3599"), + Triple(60.minutes, 2, "60:00"), + Triple(60.minutes + 1.seconds, 1, "3601"), + Triple(70.minutes, 2, "70:00"), + Triple(119.minutes + 59.seconds, 1, "7199"), + Triple(120.minutes, 2, "120:00"), + Triple(121.minutes + 59.seconds, 1, "7319"), + ////////// + Triple(Duration.ZERO, 2, "00:00"), + Triple(700.milliseconds, 2, "00:00"), + Triple(1.seconds, 2, "00:01"), + Triple(59.seconds, 2, "00:59"), + Triple(60.seconds, 2, "01:00"), + Triple(61.seconds, 2, "01:01"), + Triple(69.seconds, 2, "01:09"), + Triple(70.seconds, 2, "01:10"), + Triple(119.seconds, 2, "01:59"), + Triple(120.seconds, 2, "02:00"), + Triple(121.seconds, 2, "02:01"), + Triple(59.minutes + 59.seconds, 2, "59:59"), + Triple(60.minutes, 2, "60:00"), + Triple(60.minutes + 1.seconds, 2, "60:01"), + Triple(70.minutes, 2, "70:00"), + Triple(119.minutes + 59.seconds, 2, "119:59"), + Triple(120.minutes, 2, "120:00"), + Triple(121.minutes + 59.seconds, 2, "121:59"), + ////////// + Triple(Duration.ZERO, 3, "00:00:00"), + Triple(700.milliseconds, 3, "00:00:00"), + Triple(1.seconds, 3, "00:00:01"), + Triple(59.seconds, 3, "00:00:59"), + Triple(60.seconds, 3, "00:01:00"), + Triple(61.seconds, 3, "00:01:01"), + Triple(69.seconds, 3, "00:01:09"), + Triple(70.seconds, 3, "00:01:10"), + Triple(119.seconds, 3, "00:01:59"), + Triple(120.seconds, 3, "00:02:00"), + Triple(121.seconds, 3, "00:02:01"), + Triple(59.minutes + 59.seconds, 3, "00:59:59"), + Triple(60.minutes, 3, "01:00:00"), + Triple(60.minutes + 1.seconds, 3, "01:00:01"), + Triple(70.minutes, 3, "01:10:00"), + Triple(119.minutes + 59.seconds, 3, "01:59:59"), + Triple(120.minutes, 3, "02:00:00"), + Triple(121.minutes + 59.seconds, 3, "02:01:59"), + ////////// + Triple(-(59.seconds), 1, "-59"), + Triple(-(12.minutes + 59.seconds), 2, "-12:59"), + Triple(-(7.hours + 12.minutes + 59.seconds), 3, "-07:12:59"), + ////////// Very big durations + Triple(100.hours + 12.minutes + 59.seconds, 1, "--"), + Triple(100.hours + 12.minutes + 59.seconds, 2, "--:--"), + Triple(100.hours + 12.minutes + 59.seconds, 3, "--:--:--"), + Triple((-100).hours + (-12).minutes + (-59).seconds, 3, "--:--:--"), + ////////// Very small durations + Triple(9.milliseconds, 1, "00"), + Triple((-9).milliseconds, 1, "00"), + Triple(9.milliseconds, 2, "00:00"), + Triple((-9).milliseconds, 2, "00:00"), + Triple(9.milliseconds, 3, "00:00:00"), + Triple((-9).milliseconds, 3, "00:00:00") + ) + + @JvmStatic + fun generateDurationStringsAndExpectedResults() = listOf( + "00:00:00" to Duration.ZERO, + "00:00:01" to 1.seconds, + "00:00:59" to 59.seconds, + "00:01:00" to 60.seconds, + "00:01:01" to 61.seconds, + "00:00:61" to 61.seconds, + "00:00:1543" to 1543.seconds, + "00:78:89" to 78.minutes + 89.seconds, + "00:01:09" to 69.seconds, + "00:01:10" to 70.seconds, + "00:01:59" to 119.seconds, + "00:02:00" to 120.seconds, + "00:02:01" to 121.seconds, + "00:59:59" to 59.minutes + 59.seconds, + "01:00:00" to 60.minutes, + "01:00:01" to 60.minutes + 1.seconds, + "01:10:00" to 70.minutes, + "01:59:59" to 119.minutes + 59.seconds, + "02:00:00" to 120.minutes, + "02:01:59" to 121.minutes + 59.seconds, + "12:34" to 12.minutes + 34.seconds, + "00:12:34" to 12.minutes + 34.seconds, + "0:12:34" to 12.minutes + 34.seconds, + "0:00:34" to 34.seconds, + "00:0:34" to 34.seconds, + "00:00:34" to 34.seconds, + "04:00:4" to 4.hours + 4.seconds, + "01:3:4" to 1.hours + 3.minutes + 4.seconds, + "۰۰:۰۰:۰۰" to Duration.ZERO, + "۰0:5۹:۵9" to 59.minutes + 59.seconds, + ":00:34" to null, + ":01:34" to null, + ":1:34" to null, + "" to null, + " " to null, + " : : " to null, + "..:..:.." to null, + "::::::::" to null, + ":" to null, + "::" to null, + ":::" to null, + "a" to null, + "ab:cd:ef" to null, + "12:a3:45" to null, + "12::3:45" to null, + "12::03:45" to null, + "12:.03:45" to null, + "12:0.3:45" to null, + "12:1.3:45" to null, + "12.34.56" to null, + "12@34a56" to null + ) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/component/MediaPlayerTest.kt b/src/test/kotlin/ir/mahozad/cutcon/component/MediaPlayerTest.kt new file mode 100644 index 0000000..513d626 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/component/MediaPlayerTest.kt @@ -0,0 +1,45 @@ +package ir.mahozad.cutcon.component + +import androidx.compose.ui.graphics.ImageBitmap +import ir.mahozad.cutcon.getResourceAsPath +import ir.mahozad.cutcon.model.Source +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class MediaPlayerTest { + + @Test + fun `Initially, the video should be null`() = runTest { + val player = DefaultMediaPlayer() + val result = player.video.first() + assertThat(result).isNull() + } + + @Disabled("Because when running all tests, they do not run completely") + @Test + fun `When playing a new media, the video should update`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val player = DefaultMediaPlayer() + val source = getResourceAsPath("test.ts") + .let(Source::Local) + .let(DefaultUrlMaker::makeUrl) + val results = mutableListOf() + val job = backgroundScope.launch(dispatcher) { + player.video.take(10).toList(results) + } + player.play(source) + job.join() + assertThat(results.first()).isNull() + assertThat(results[1]).isNotNull() + assertThat(results.last()).isNotNull() + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/component/SaveFileNameGeneratorTest.kt b/src/test/kotlin/ir/mahozad/cutcon/component/SaveFileNameGeneratorTest.kt new file mode 100644 index 0000000..2be0cce --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/component/SaveFileNameGeneratorTest.kt @@ -0,0 +1,93 @@ +package ir.mahozad.cutcon.component + +import com.github.mfathi91.time.PersianDate +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.CleanupMode +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import java.time.LocalDate +import java.time.LocalTime +import kotlin.io.path.Path +import kotlin.io.path.createFile +import kotlin.io.path.div + +class SaveFileNameGeneratorTest { + + // See the README + @TempDir(cleanup = CleanupMode.ON_SUCCESS) + lateinit var tempDirectory: Path + + @Nested + inner class GregorianCalendarTest { + @Test + fun `When directory does not contain a file with name, default name should be the provided date and time numbered 1`() { + val result = DefaultSaveFileNameGenerator.generate( + Path("DOES_NOT_MATTER"), + LocalDate.of(2020, 6, 17), + LocalTime.of(21, 17, 3) + ) + assertThat(result).isEqualTo("2020-06-17_21-00_1") + } + + @Test + fun `When directory contains a file with same name, default name should be provided date and time numbered 2`() { + (tempDirectory / "2020-06-17_21-00_1.any").createFile() + val result = DefaultSaveFileNameGenerator.generate( + tempDirectory, + LocalDate.of(2020, 6, 17), + LocalTime.of(21, 17, 3) + ) + assertThat(result).isEqualTo("2020-06-17_21-00_2") + } + + @Test + fun `When directory contains two numbered files with same name, default name should be provided date and time numbered 3`() { + (tempDirectory / "2020-06-17_21-00_1.any").createFile() + (tempDirectory / "2020-06-17_21-00_2.any").createFile() + val result = DefaultSaveFileNameGenerator.generate( + tempDirectory, + LocalDate.of(2020, 6, 17), + LocalTime.of(21, 17, 3) + ) + assertThat(result).isEqualTo("2020-06-17_21-00_3") + } + } + + @Nested + inner class SolarHijriCalendarTest { + @Test + fun `When directory does not contain a file with name, default name should be the provided date and time numbered 1`() { + val result = DefaultSaveFileNameGenerator.generate( + Path("DOES_NOT_MATTER"), + PersianDate.of(1399, 3, 28), + LocalTime.of(21, 17, 3) + ) + assertThat(result).isEqualTo("1399-03-28_21-00_1") + } + + @Test + fun `When directory contains a file with same name, default name should be provided date and time numbered 2`() { + (tempDirectory / "1399-03-28_21-00_1.any").createFile() + val result = DefaultSaveFileNameGenerator.generate( + tempDirectory, + PersianDate.of(1399, 3, 28), + LocalTime.of(21, 17, 3) + ) + assertThat(result).isEqualTo("1399-03-28_21-00_2") + } + + @Test + fun `When directory contains two numbered files with same name, default name should be provided date and time numbered 3`() { + (tempDirectory / "1399-03-28_21-00_1.any").createFile() + (tempDirectory / "1399-03-28_21-00_2.any").createFile() + val result = DefaultSaveFileNameGenerator.generate( + tempDirectory, + PersianDate.of(1399, 3, 28), + LocalTime.of(21, 17, 3) + ) + assertThat(result).isEqualTo("1399-03-28_21-00_3") + } + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/component/SystemDateTimeTest.kt b/src/test/kotlin/ir/mahozad/cutcon/component/SystemDateTimeTest.kt new file mode 100644 index 0000000..95b8c07 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/component/SystemDateTimeTest.kt @@ -0,0 +1,29 @@ +package ir.mahozad.cutcon.component + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.within +import org.assertj.core.data.Offset.offset +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalTime +import java.time.temporal.ChronoUnit + +class SystemDateTimeTest { + @Test + fun `Getting nowTime should return current time`() { + val result = SystemDateTime.nowTime() + assertThat(result).isCloseTo(LocalTime.now(), within(3, ChronoUnit.SECONDS)) + } + + @Test + fun `Getting nowDate should return current date`() { + val result = SystemDateTime.nowDate() + assertThat(result).isEqualTo(LocalDate.now()) + } + + @Test + fun `Getting nowMillis should return current milliseconds`() { + val result = SystemDateTime.nowMillis() + assertThat(result).isCloseTo(System.currentTimeMillis(), offset(500)) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/component/UrlMakerTest.kt b/src/test/kotlin/ir/mahozad/cutcon/component/UrlMakerTest.kt new file mode 100644 index 0000000..cebb70e --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/component/UrlMakerTest.kt @@ -0,0 +1,31 @@ +package ir.mahozad.cutcon.component + +import ir.mahozad.cutcon.model.Source +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.net.URL +import kotlin.io.path.Path +import kotlin.io.path.absolutePathString + +class UrlMakerTest { + + @Nested + inner class LocalSourceTest { + @Test + fun `Make URL for relative path containing only file name`() { + val source = Source.Local(Path("1.mp4")) + val url = DefaultUrlMaker.makeUrl(source) + val expected = URL("file://localhost/${source.path.absolutePathString()}") + assertThat(url).isEqualTo(expected) + } + + @Test + fun `Make URL for relative path containing directory`() { + val source = Source.Local(Path("a/1.mp4")) + val url = DefaultUrlMaker.makeUrl(source) + val expected = URL("file://localhost/${source.path.absolutePathString()}") + assertThat(url).isEqualTo(expected) + } + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/converter/ConvertersTest.kt b/src/test/kotlin/ir/mahozad/cutcon/converter/ConvertersTest.kt new file mode 100644 index 0000000..3759850 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/converter/ConvertersTest.kt @@ -0,0 +1,553 @@ +package ir.mahozad.cutcon.converter + +import androidx.compose.ui.graphics.Color +import com.github.romankh3.image.comparison.ImageComparison +import com.github.romankh3.image.comparison.ImageComparisonUtil +import io.mockk.spyk +import io.mockk.verifyOrder +import ir.mahozad.cutcon.* +import ir.mahozad.cutcon.model.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.jaudiotagger.audio.AudioFileIO +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.io.CleanupMode.ON_SUCCESS +import org.junit.jupiter.api.io.TempDir +import java.net.URL +import java.nio.file.Path +import javax.imageio.ImageIO +import kotlin.coroutines.CoroutineContext +import kotlin.io.path.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class ConvertersTest { + + // See the README + @TempDir(cleanup = ON_SUCCESS) + lateinit var tempDirectory: Path + + @Nested + inner class ConverterFactoryTest { + @Test + fun `StandardFactory should create correct converter given MP3 type`() { + val format = Format.MP3 + val factory = DefaultConverterFactory(Dispatchers.Main) + val converter = factory.createFor(format) + assertThat(converter).isInstanceOf(Mp3Converter::class.java) + } + + @Test + fun `StandardFactory should create correct converter given MP4 type`() { + val format = Format.MP4 + val factory = DefaultConverterFactory(Dispatchers.Main) + val converter = factory.createFor(format) + assertThat(converter).isInstanceOf(Mp4Converter::class.java) + } + + @Test + fun `StandardFactory should create correct converter given RAW aka ORIGINAL aka SOURCE aka COPY type`() { + val format = Format.RAW + val factory = DefaultConverterFactory(Dispatchers.Main) + val converter = factory.createFor(format) + assertThat(converter).isInstanceOf(RawConverter::class.java) + } + } + + @Nested + inner class VideoInputTest { + @Test + fun `Converting to MP4 with no intro and no watermark should succeed`() = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test.ts") + val output = tempDirectory / "result.mp4" + Mp4Converter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 8.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions, + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = {} + ) + extractFrame(from = output, time = "00:00:00", into = tempDirectory / "frame.png") + val reference = ImageComparisonUtil.readImageFromResources("reference/1.png") + val actual = (tempDirectory / "frame.png").inputStream().use(ImageIO::read) + val comparisonResult = ImageComparison(reference, actual).compareImages() + assertThat(comparisonResult.differencePercent).isLessThan(1f) + } + + @Test + fun `Converting to MP4 with no intro and a watermark (with options) should succeed`() = + runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test.ts") + val image = getResourceAsPath("test.png") + val output = tempDirectory / "result.mp4" + Mp4Converter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 8.seconds), + intro = defaultIntroOptions, + cover = CoverOptions( + path = image, + scale = 2.3f, + opacity = 0.4f, + position = WatermarkPosition.BOTTOM_MIDDLE + ), + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = {} + ) + extractFrame(from = output, time = "00:00:00", into = tempDirectory / "frame.png") + val reference = ImageComparisonUtil.readImageFromResources("reference/2.png") + val actual = (tempDirectory / "frame.png").inputStream().use(ImageIO::read) + val comparisonResult = ImageComparison(reference, actual).compareImages() + assertThat(comparisonResult.differencePercent).isLessThan(1f) + } + + @Test + fun `Converting to MP4 with an intro (with options) and no watermark should succeed`() = + runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test.ts") + val image = getResourceAsPath("test-wide.png") + val output = tempDirectory / "result.mp4" + Mp4Converter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 8.seconds), + intro = IntroOptions( + path = image, + duration = 3.seconds, + backgroundColor = Color.Yellow + ), + cover = defaultCoverOptions, + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = {} + ) + extractFrame(from = output, time = "00:00:02", into = tempDirectory / "frame1.png") + extractFrame(from = output, time = "00:00:04", into = tempDirectory / "frame2.png") + val reference1 = ImageComparisonUtil.readImageFromResources("reference/3.png") + val reference2 = ImageComparisonUtil.readImageFromResources("reference/4.png") + val actual1 = (tempDirectory / "frame1.png").inputStream().use(ImageIO::read) + val actual2 = (tempDirectory / "frame2.png").inputStream().use(ImageIO::read) + val comparisonResult1 = ImageComparison(reference1, actual1).compareImages() + val comparisonResult2 = ImageComparison(reference2, actual2).compareImages() + assertThat(comparisonResult1.differencePercent).isLessThan(1f) + assertThat(comparisonResult2.differencePercent).isLessThan(1f) + } + + @Test + fun `Converting to MP4 with an intro (with options) and a watermark (with options) should succeed`() = + runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test.ts") + val intro = getResourceAsPath("test-wide.png") + val watermark = getResourceAsPath("test.png") + val output = tempDirectory / "result.mp4" + Mp4Converter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 8.seconds), + intro = IntroOptions( + path = intro, + duration = 3.seconds, + backgroundColor = Color.Yellow + ), + cover = CoverOptions( + path = watermark, + scale = 2.3f, + opacity = 0.4f, + position = WatermarkPosition.BOTTOM_MIDDLE + ), + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = {} + ) + extractFrame(from = output, time = "00:00:02", into = tempDirectory / "frame1.png") + extractFrame(from = output, time = "00:00:04", into = tempDirectory / "frame2.png") + val reference1 = ImageComparisonUtil.readImageFromResources("reference/5.png") + val reference2 = ImageComparisonUtil.readImageFromResources("reference/6.png") + val actual1 = (tempDirectory / "frame1.png").inputStream().use(ImageIO::read) + val actual2 = (tempDirectory / "frame2.png").inputStream().use(ImageIO::read) + val comparisonResult1 = ImageComparison(reference1, actual1).compareImages() + val comparisonResult2 = ImageComparison(reference2, actual2).compareImages() + assertThat(comparisonResult1.differencePercent).isLessThan(1f) + assertThat(comparisonResult2.differencePercent).isLessThan(1f) + } + + @Test + fun `Converting to MP4 with an intro (with options) that is larger than the video resolution and a watermark (with options) that is larger than the video resolution should succeed`() = + runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test.ts") + val intro = getResourceAsPath("test.svg").convertSvgToPng(desiredPngSize = 1000f) + val watermark = getResourceAsPath("test.svg").convertSvgToPng(desiredPngSize = 1000f) + val output = tempDirectory / "result.mp4" + Mp4Converter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 8.seconds), + intro = IntroOptions( + path = intro, + duration = 3.seconds, + backgroundColor = Color.Yellow + ), + cover = CoverOptions( + path = watermark, + scale = 2.1f, + opacity = 0.4f, + position = WatermarkPosition.BOTTOM_MIDDLE + ), + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = {} + ) + extractFrame(from = output, time = "00:00:02", into = tempDirectory / "frame1.png") + extractFrame(from = output, time = "00:00:04", into = tempDirectory / "frame2.png") + val reference1 = ImageComparisonUtil.readImageFromResources("reference/8.png") + val reference2 = ImageComparisonUtil.readImageFromResources("reference/9.png") + val actual1 = (tempDirectory / "frame1.png").inputStream().use(ImageIO::read) + val actual2 = (tempDirectory / "frame2.png").inputStream().use(ImageIO::read) + val comparisonResult1 = ImageComparison(reference1, actual1).compareImages() + val comparisonResult2 = ImageComparison(reference2, actual2).compareImages() + assertThat(comparisonResult1.differencePercent).isLessThan(1f) + assertThat(comparisonResult2.differencePercent).isLessThan(1f) + } + + @Test + fun `Converting to MP3 with no album art should succeed`() = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test.ts") + val output = tempDirectory / "result.mp3" + Mp3Converter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 8.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions, + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = {} + ) + val albumArt = output + .toFile() + .runCatching(AudioFileIO::read) + .getOrNull() + ?.tag + ?.firstArtwork + ?.binaryData + assertThat(albumArt).isEqualTo(Mp3Converter.defaultAlbumArtPath?.readBytes()) + assertThat(output.fileSize()).isBetween(50_000, 100_000) + } + + @Test + fun `Converting to MP3 with an album art should succeed`() = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test.ts") + val image = getResourceAsPath("test.png") + val output = tempDirectory / "result.mp3" + Mp3Converter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 8.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions.copy(path = image), + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = {} + ) + val albumArt = output + .toFile() + .runCatching(AudioFileIO::read) + .getOrNull() + ?.tag + ?.firstArtwork + ?.binaryData + assertThat(albumArt).isEqualTo(image.readBytes()) + assertThat(output.fileSize()).isGreaterThan(50_000) + } + + @Test + fun `Converting to RAW should succeed`() = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test.ts") + val output = tempDirectory / "result.ts" + RawConverter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 8.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions, + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = {} + ) + extractFrame(from = output, time = "00:00:00", into = tempDirectory / "frame.png") + val reference = getResourceAsPath("reference/7.png") + assertThat((tempDirectory / "frame.png").readBytes()).isEqualTo(reference.readBytes()) + } + } + + @Nested + inner class AudioInputTest { + @Test + fun `Converting a TS audio file to MP4 should succeed`() = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test-no-video.ts") + val output = tempDirectory / "result.mp4" + Mp4Converter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 10.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions, + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false, isVideoAvailableInInput = false), + output = output, + listener = {} + ) + assertThat(output.fileSize()).isBetween(20_000, 30_000) + } + + @Disabled("FIXME") + @Test + fun `Converting an MP3 audio file to MP4 should succeed`() = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test.mp3") + val output = tempDirectory / "result.mp4" + Mp4Converter(dispatcher).convert( + input = input, + clip = Clip(1.seconds, 3.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions, + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false, isVideoAvailableInInput = false), + output = output, + listener = {} + ) + assertThat(output.fileSize()).isBetween(50_000, 100_000) + } + + @Test + fun `Converting to MP3 with no album art should succeed`() = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test-no-video.ts") + val output = tempDirectory / "result.mp3" + Mp3Converter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 10.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions, + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false, isVideoAvailableInInput = false), + output = output, + listener = {} + ) + val albumArt = output + .toFile() + .runCatching(AudioFileIO::read) + .getOrNull() + ?.tag + ?.firstArtwork + ?.binaryData + assertThat(albumArt).isEqualTo(Mp3Converter.defaultAlbumArtPath?.readBytes()) + assertThat(output.fileSize()).isBetween(50_000, 70_000) + } + + @Test + fun `Converting to MP3 with an album art should succeed`() = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test-no-video.ts") + val image = getResourceAsPath("test.png") + val output = tempDirectory / "result.mp3" + Mp3Converter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 10.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions.copy(path = image), + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false, isVideoAvailableInInput = false), + output = output, + listener = {} + ) + val albumArt = output + .toFile() + .runCatching(AudioFileIO::read) + .getOrNull() + ?.tag + ?.firstArtwork + ?.binaryData + assertThat(albumArt).isEqualTo(image.readBytes()) + assertThat(output.fileSize()).isGreaterThan(50_000) + } + + @Test + fun `Converting to RAW should succeed`() = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test-no-video.ts") + val output = tempDirectory / "result.ts" + RawConverter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 8.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions, + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = {} + ) + assertThat(output.fileSize()).isBetween(20_000, 30_000) + } + } + + @Test + fun `When conversion is not successful, convert function should throw exception`() = + runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("non-existent-file.ts") + val output = createTempDirectory() / "result.mp4" + assertThrows { + Mp4Converter(dispatcher).convert( + input = input, + clip = Clip(Duration.ZERO, 14.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions, + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = {} + ) + } + } + + @Test + fun `Converting a local file to MP4 should succeed`() = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val path = getResourceAsPath("test.ts") + val input = URL("file://localhost/${path.absolutePathString()}") + val output = tempDirectory / "result.mp4" + Mp4Converter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 8.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions, + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = {} + ) + assertThat(output.fileSize()).isGreaterThan(100_000) + } + + @Test + fun `When converting, the progress listener should be called`() = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test.ts") + val output = tempDirectory / "result.mp4" + val listener = spyk() + Mp4Converter(dispatcher).convert( + input = input, + clip = Clip(6.seconds, 8.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions, + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = listener + ) + verifyOrder { + listener.onProgressUpdate(range(0f, 0.99f)) + listener.onProgressUpdate(1f) + } + } + + /** + * Could also have used FFprobe to inspect the result (for example, its duraiton). + * See https://superuser.com/a/945604 + */ + @Test + fun `Converter should be cancelable while in the process of converting`() = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test.ts") + val output = createTempDirectory() / "result.mp4" + var hasProgressed = false + val job1 = launch(Dispatchers.Default) { + Mp4Converter(dispatcher).convert( + input = input, + clip = Clip(Duration.ZERO, 14.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions, + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = { hasProgressed = true } + ) + } + val job2 = launch { + while (!hasProgressed) delay(50.milliseconds) + job1.cancel() + } + job1.join() + job2.join() + assertThat(output.fileSize()).isLessThan(500_000) + } + + /** + * The cancellation of coroutines in Kotlin is by cooperation, meaning, + * the coroutine should be cooperative for proper cancellation to work. + * See https://kotlinlang.org/docs/cancellation-and-timeouts.html#cancellation-is-cooperative + * + * In the current implementation, the [Converter.convert] function eventually + * throws [CancellationException] if canceled, no matter what, + * but if we do not make the process cancellable either with calling [yield] periodically or + * checking for [CoroutineContext.isActive] periodically or, for example, + * calling [cancellable] on the internal [Flow] if there is any, + * the exception is thrown only after the conversion is complete. + */ + @Test + fun `When conversion is canceled (in other words, the coroutine executing the convert function is canceled), convert function should throw an exception of type CancellationException (and it should throw it immediately)`() = + runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val input = getResourceAsURL("test.ts") + val output = createTempDirectory() / "result.mp4" + var exception: Throwable? = null + var progress = 0f + val job = launch(Dispatchers.Default) { + Mp4Converter(dispatcher) + .runCatching { + convert( + input = input, + clip = Clip(Duration.ZERO, 14.seconds), + intro = defaultIntroOptions, + cover = defaultCoverOptions, + quality = Quality.MEDIUM, + flags = ConverterFlags(isInterlacingFixEnabled = false), + output = output, + listener = { progress = it } + ) + } + .onFailure { exception = it } + } + launch(Dispatchers.Default) { + while (progress < 0.01f) delay(50.milliseconds) + job.cancel() + }.join() + while (exception == null) { + delay(50.milliseconds) + } + assertThat(progress).isLessThan(0.3f) + assertThat(exception).isInstanceOf(CancellationException::class.java) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/converter/convesion-states.xlsx b/src/test/kotlin/ir/mahozad/cutcon/converter/convesion-states.xlsx new file mode 100644 index 0000000..70d646a Binary files /dev/null and b/src/test/kotlin/ir/mahozad/cutcon/converter/convesion-states.xlsx differ diff --git a/src/test/kotlin/ir/mahozad/cutcon/localization/LanguageTest.kt b/src/test/kotlin/ir/mahozad/cutcon/localization/LanguageTest.kt new file mode 100644 index 0000000..67ddcbb --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/localization/LanguageTest.kt @@ -0,0 +1,54 @@ +package ir.mahozad.cutcon.localization + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@Nested +class LocalizeDigitsTest { + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class FaLocaleTest { + @ParameterizedTest + @MethodSource("generateStringsAndExpectedResults") + fun `Result should be as expected`( + argument: Pair + ) { + val (string, expectedResult) = argument + val result = LanguageFa.localizeDigits(string) + assertThat(result).isEqualTo(expectedResult) + } + + private fun generateStringsAndExpectedResults() = listOf( + "a.b.c" to "a.b.c", + "a.1.2" to "a.۱.۲", + "3.۲.c" to "۳.۲.c", + "3.۲.c ک ?" to "۳.۲.c ک ?", + "۱.۲.۳" to "۱.۲.۳" + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class EnLocaleTest { + @ParameterizedTest + @MethodSource("generateStringsAndExpectedResults") + fun `Result should be as expected`( + argument: Pair + ) { + val (string, expectedResult) = argument + val result = LanguageEn.localizeDigits(string) + assertThat(result).isEqualTo(expectedResult) + } + + private fun generateStringsAndExpectedResults() = listOf( + "a.b.c" to "a.b.c", + "a.۱.۲" to "a.1.2", + "۳.2.c" to "3.2.c", + "۳.2.c ک ?" to "3.2.c ک ?", + "1.2.3" to "1.2.3" + ) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/localization/MessagesTest.kt b/src/test/kotlin/ir/mahozad/cutcon/localization/MessagesTest.kt new file mode 100644 index 0000000..8e794e7 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/localization/MessagesTest.kt @@ -0,0 +1,77 @@ +package ir.mahozad.cutcon.localization + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +class MessagesTest { + @ParameterizedTest + @MethodSource("generateDurationsAndExpectedResultsFa") + fun `Generating total time string for various durations should produce proper result (Fa)`( + argument: Pair + ) { + val (duration, expectedResult) = argument + val result = MessagesFa.totalClipCreationTime(duration) + assertThat(result).isEqualTo(expectedResult) + } + + @ParameterizedTest + @MethodSource("generateDurationsAndExpectedResultsEn") + fun `Generating total time string for various durations should produce proper result (En)`( + argument: Pair + ) { + val (duration, expectedResult) = argument + val result = MessagesEn.totalClipCreationTime(duration) + assertThat(result).isEqualTo(expectedResult) + } + + companion object { + @JvmStatic + fun generateDurationsAndExpectedResultsFa() = listOf( + Duration.ZERO to "کمتر از یک ثانیه طول کشید", + 1.seconds to "۱ ثانیه طول کشید", + 2.seconds + 700.milliseconds to "۲ ثانیه طول کشید", + 59.seconds to "۵۹ ثانیه طول کشید", + 59.seconds + 700.milliseconds to "۵۹ ثانیه طول کشید", + 1.minutes to "۱ دقیقه طول کشید", + 1.minutes + 300.milliseconds to "۱ دقیقه طول کشید", + 1.minutes + 700.milliseconds to "۱ دقیقه طول کشید", + 1.minutes + 1.seconds + 700.milliseconds to "۱ دقیقه و ۱ ثانیه طول کشید", + 59.minutes + 57.seconds + 700.milliseconds to "۵۹ دقیقه و ۵۷ ثانیه طول کشید", + 1.hours to "۱ ساعت طول کشید", + 1.hours + 700.milliseconds to "۱ ساعت طول کشید", + 1.hours + 1.seconds + 700.milliseconds to "۱ ساعت طول کشید", + 1.hours + 1.minutes + 1.seconds + 700.milliseconds to "۱ ساعت و ۱ دقیقه طول کشید", + 2.hours + 3.minutes + 1.seconds + 700.milliseconds to "۲ ساعت و ۳ دقیقه طول کشید", + 1.days + 3.minutes + 1.seconds + 700.milliseconds to "۲۴ ساعت و ۳ دقیقه طول کشید", + 2.days + 3.hours + 1.minutes + 1.seconds + 700.milliseconds to "۵۱ ساعت و ۱ دقیقه طول کشید" + ) + + @JvmStatic + fun generateDurationsAndExpectedResultsEn() = listOf( + Duration.ZERO to "Took less than one second", + 1.seconds to "Took 1 second", + 2.seconds + 700.milliseconds to "Took 2 seconds", + 59.seconds to "Took 59 seconds", + 59.seconds + 700.milliseconds to "Took 59 seconds", + 1.minutes to "Took 1 minute", + 1.minutes + 300.milliseconds to "Took 1 minute", + 1.minutes + 700.milliseconds to "Took 1 minute", + 1.minutes + 1.seconds + 700.milliseconds to "Took 1 minute 1 second", + 59.minutes + 57.seconds + 700.milliseconds to "Took 59 minutes 57 seconds", + 1.hours to "Took 1 hour", + 1.hours + 700.milliseconds to "Took 1 hour", + 1.hours + 1.seconds + 700.milliseconds to "Took 1 hour", + 1.hours + 1.minutes + 1.seconds + 700.milliseconds to "Took 1 hour 1 minute", + 2.hours + 3.minutes + 1.seconds + 700.milliseconds to "Took 2 hours 3 minutes", + 1.days + 3.minutes + 1.seconds + 700.milliseconds to "Took 24 hours 3 minutes", + 2.days + 3.hours + 1.minutes + 1.seconds + 700.milliseconds to "Took 51 hours 1 minute" + ) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/model/CalendarTest.kt b/src/test/kotlin/ir/mahozad/cutcon/model/CalendarTest.kt new file mode 100644 index 0000000..bd5b6f6 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/model/CalendarTest.kt @@ -0,0 +1,58 @@ +package ir.mahozad.cutcon.model + +import ir.mahozad.cutcon.localization.LanguageEn +import ir.mahozad.cutcon.localization.LanguageFa +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDate + +/** + * The [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) standard + * defines the *yyyy-mm-dd* format (with `-` as separator) + * only for Gregorian (میلادی) calendar system and does not talk about + * any other calendar systems (such as Persian/Solar/Jalali). + * + * Microsoft Windows 11 uses `/` separator when the region/locale + * is "fa" for both Gregorian and Solar calendars (in taskbar). + * + * JavaScript uses `/` separator when the locale is "fa" for + * both Gregorian and Solar calendars: `new Date().toLocaleString("fa")`. + * + * So, the calendars of the app are expected to format like below: + * - If the language is Persian/Farsi, use *yyyy/mm/dd* format + * - Otherwise, use *yyyy-mm-dd* format + */ +class CalendarTest { + + @Test + fun `When calendar is Solar and locale is Persian, formatting should return proper result`() { + val calendar = Calendar.SOLAR_HIJRI + val date = LocalDate.of(1789, 5, 6) + val result = calendar.format(date, LanguageFa) + assertThat(result).isEqualTo("۱۱۶۸/۰۲/۱۷") // See the comments above test class for more information + } + + @Test + fun `When calendar is Solar and locale is English, formatting should return proper result`() { + val calendar = Calendar.SOLAR_HIJRI + val date = LocalDate.of(1789, 5, 6) + val result = calendar.format(date, LanguageEn) + assertThat(result).isEqualTo("1168-02-17") // See the comments above test class for more information + } + + @Test + fun `When calendar is Gregorian and locale is Persian, formatting should return proper result`() { + val calendar = Calendar.GREGORIAN + val date = LocalDate.of(1789, 5, 6) + val result = calendar.format(date, LanguageFa) + assertThat(result).isEqualTo("۱۷۸۹/۰۵/۰۶") // See the comments above test class for more information + } + + @Test + fun `When calendar is Gregorian and locale is English, formatting should return proper result`() { + val calendar = Calendar.GREGORIAN + val date = LocalDate.of(1789, 5, 6) + val result = calendar.format(date, LanguageEn) + assertThat(result).isEqualTo("1789-05-06") // See the comments above test class for more information + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/model/FormatTest.kt b/src/test/kotlin/ir/mahozad/cutcon/model/FormatTest.kt new file mode 100644 index 0000000..045ebd3 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/model/FormatTest.kt @@ -0,0 +1,54 @@ +package ir.mahozad.cutcon.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import kotlin.io.path.Path + +class FormatTest { + + @Nested + inner class LocalSourceTest { + @Test + fun `When source is a local file and getting MP4 format name, it should return the proper result`() { + val source = Source.Local(Path("a/b.xYz")) + val result = Format.MP4.actualName(source) + assertThat(result).isEqualTo("MP4") + } + + @Test + fun `When source is a local file and getting MP3 format name, it should return the proper result`() { + val source = Source.Local(Path("a/b.xYz")) + val result = Format.MP3.actualName(source) + assertThat(result).isEqualTo("MP3") + } + + @Test + fun `When source is a local file and getting RAW format name, it should return the local file extension name`() { + val source = Source.Local(Path("a/b.xYz")) + val result = Format.RAW.actualName(source) + assertThat(result).isEqualTo("XYZ") + } + + @Test + fun `When source is a local file and getting MP4 extension, it should return the proper result`() { + val source = Source.Local(Path("a/b.xYz")) + val result = Format.MP4.extension(source) + assertThat(result).isEqualTo("mp4") + } + + @Test + fun `When source is a local file and getting MP3 extension, it should return the proper result`() { + val source = Source.Local(Path("a/b.xYz")) + val result = Format.MP3.extension(source) + assertThat(result).isEqualTo("mp3") + } + + @Test + fun `When source is a local file and getting RAW extension, it should return the local file extension`() { + val source = Source.Local(Path("a/b.xYz")) + val result = Format.RAW.extension(source) + assertThat(result).isEqualTo("xyz") + } + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/AppExitTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/AppExitTest.kt new file mode 100644 index 0000000..1459036 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/AppExitTest.kt @@ -0,0 +1,192 @@ +package ir.mahozad.cutcon.viewmodel + +import androidx.compose.ui.text.TextRange +import io.mockk.called +import io.mockk.coEvery +import io.mockk.spyk +import io.mockk.verify +import ir.mahozad.cutcon.FakeMediaPlayer +import ir.mahozad.cutcon.component.UrlMaker +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.converter.Converter +import ir.mahozad.cutcon.converter.ConverterFactory +import ir.mahozad.cutcon.converter.FFmpegOption +import ir.mahozad.cutcon.model.ConverterFlags +import ir.mahozad.cutcon.model.CoverOptions +import ir.mahozad.cutcon.model.IntroOptions +import ir.mahozad.cutcon.model.Quality +import ir.mahozad.cutcon.testTimeout +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.net.URL +import kotlin.io.path.Path +import kotlin.io.path.div +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class AppExitTest { + @Test + fun `Initially, isAppExitConfirmDialogDisplayed should be false`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + assertThat(viewModel.isAppExitConfirmDialogDisplayed.first()).isEqualTo(false) + } + + @Test + fun `When isAppExitConfirmDialogDisplayed is false and requesting exit dialog dismiss, isAppExitConfirmDialogDisplayed should stay false`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val results = mutableListOf() + val viewModel = constructMainViewModel(dispatcher) + backgroundScope.launch(dispatcher) { viewModel.isAppExitConfirmDialogDisplayed.toList(results) } + viewModel.onAppExitConfirmDialogDismissRequest() + assertThat(results).containsExactly(false) + } + + @Test + fun `When isAppExitConfirmDialogDisplayed is true and requesting exit dialog dismiss, isAppExitConfirmDialogDisplayed should update to false`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + var progressed = false + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + println("I'm a fake converter and I'm running an infinite loop pretending to convert...") + while (isActive) { + progressed = true + delay(15.milliseconds) + } + } + val converterFactory = ConverterFactory { converter } + val urlMaker = UrlMaker { URL("file://") } + val results = mutableListOf() + val saveFile = Path("xyz") / "1.mp4" + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + mediaPlayer = FakeMediaPlayer(10.seconds), + converterFactory = converterFactory + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("8", TextRange.Zero) + setSaveFile(saveFile) + } + launch(Dispatchers.Default) { viewModel.startProcess() } + launch(Dispatchers.Default) { while (!progressed) delay(50.milliseconds) }.join() + backgroundScope.launch(dispatcher) { viewModel.isAppExitConfirmDialogDisplayed.toList(results) } + viewModel.onAppExitRequest(forceExit = false, exit = {}) + viewModel.onAppExitConfirmDialogDismissRequest() + assertThat(results).containsExactly(false, true, false) + } + + @Test + fun `While no clip is being created and requesting app exit, isAppExitConfirmDialogDisplayed should stay false and passed function be called`() = + runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val exit: () -> Unit = spyk() + val results = mutableListOf() + val viewModel = constructMainViewModel(dispatcher) + backgroundScope.launch(dispatcher) { viewModel.isAppExitConfirmDialogDisplayed.toList(results) } + viewModel.onAppExitRequest(forceExit = false, exit = exit) + verify(exactly = 1) { exit() } + assertThat(results).containsExactly(false) + } + + @Test + fun `While a clip is being created and requesting app exit with forceExit = false, isAppExitConfirmDialogDisplayed should change to true and passed function not called`() = + runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + var progressed = false + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + println("I'm a fake converter and I'm running an infinite loop pretending to convert...") + while (isActive) { + progressed = true + delay(15.milliseconds) + } + } + val exit: () -> Unit = spyk() + val converterFactory = ConverterFactory { converter } + val urlMaker = UrlMaker { URL("file://") } + val results = mutableListOf() + val saveFile = Path("xyz") / "1.mp4" + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + mediaPlayer = FakeMediaPlayer(10.seconds), + converterFactory = converterFactory + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("8", TextRange.Zero) + setSaveFile(saveFile) + } + backgroundScope.launch(dispatcher) { viewModel.isAppExitConfirmDialogDisplayed.toList(results) } + launch(Dispatchers.Default) { viewModel.startProcess() } + launch(Dispatchers.Default) { while (!progressed) delay(50.milliseconds) }.join() + viewModel.onAppExitRequest(forceExit = false, exit = exit) + verify { exit wasNot called } + assertThat(results).containsExactly(false, true) + } + + @Test + fun `While a clip is being created and requesting app exit with forceExit = true, isAppExitConfirmDialogDisplayed should stay false and passed function be called`() = + runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + var progressed = false + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + println("I'm a fake converter and I'm running an infinite loop pretending to convert...") + while (isActive) { + progressed = true + delay(15.milliseconds) + } + } + val exit: () -> Unit = spyk() + val converterFactory = ConverterFactory { converter } + val urlMaker = UrlMaker { URL("file://") } + val results = mutableListOf() + val saveFile = Path("xyz") / "1.mp4" + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + mediaPlayer = FakeMediaPlayer(10.seconds), + converterFactory = converterFactory + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("8", TextRange.Zero) + setSaveFile(saveFile) + } + backgroundScope.launch(dispatcher) { viewModel.isAppExitConfirmDialogDisplayed.toList(results) } + launch(Dispatchers.Default) { viewModel.startProcess() } + launch(Dispatchers.Default) { while (!progressed) delay(50.milliseconds) }.join() + viewModel.onAppExitRequest(forceExit = true, exit = exit) + verify(exactly = 1) { exit() } + assertThat(results).containsExactly(false) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/AspectRatioTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/AspectRatioTest.kt new file mode 100644 index 0000000..0ec9947 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/AspectRatioTest.kt @@ -0,0 +1,119 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.defaultAspectRatio +import ir.mahozad.cutcon.getResourceAsPath +import ir.mahozad.cutcon.model.AspectRatio +import ir.mahozad.cutcon.model.PreferenceKeys +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.prefs.Preferences + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class AspectRatioTest { + + private val settings = Preferences + .userRoot() + .node("/${BuildConfig.APP_NAME}/test")!! + + @BeforeEach + fun setUp() { + settings.clear() + } + + @Test + fun `For first time app launch, the aspect ratio override should be default`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.aspectRatio.first() + assertThat(result).isEqualTo(defaultAspectRatio) + } + + @Test + fun `When settings has aspect ratio override, the aspect ratio override should be initialized to it`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + settings.put(PreferenceKeys.PREF_ASPECT_RATIO, AspectRatio.SOURCE.name) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.aspectRatio.toList(results) } + assertThat(results).containsExactly(AspectRatio.SOURCE) + } + + @Test + fun `After changing aspect ratio override, the aspect ratio override should be updated`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setAspectRatio(AspectRatio.W16H9) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.aspectRatio.toList(results) } + viewModel.setAspectRatio(AspectRatio.SOURCE) + assertThat(results).containsExactly(AspectRatio.W16H9, AspectRatio.SOURCE) + } + + @Test + fun `After changing aspect ratio override, the aspect ratio override should be persisted`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ).apply { + setAspectRatio(AspectRatio.W16H9) + } + viewModel.setAspectRatio(AspectRatio.SOURCE) + val result = settings[PreferenceKeys.PREF_ASPECT_RATIO, null]?.let(AspectRatio::valueOf) + assertThat(result).isEqualTo(AspectRatio.SOURCE) + } + + @Test + fun `When aspect ratio is not source and media is set to non-video, the aspect ratio override should be updated to source`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setAspectRatio(AspectRatio.W16H9) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.aspectRatio.toList(results) } + viewModel.setSourceToLocal(getResourceAsPath("test.png")) + assertThat(results).containsExactly(AspectRatio.W16H9, AspectRatio.SOURCE) + } + + @Test + fun `When the aspect ratio is not source and the media is set to non-video and then to video, the aspect ratio override should be updated to the last aspect ratio override`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setAspectRatio(AspectRatio.W16H9) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.aspectRatio.toList(results) } + viewModel.setSourceToLocal(getResourceAsPath("test.png")) + viewModel.setSourceToLocal(getResourceAsPath("test.ts")) + assertThat(results).containsExactly(AspectRatio.W16H9, AspectRatio.SOURCE, AspectRatio.W16H9) + } + + @Test + fun `When the aspect ratio is source and the media is set to non-video and then to video, the aspect ratio override should be updated to the last aspect ratio override`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setAspectRatio(AspectRatio.SOURCE) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.aspectRatio.toList(results) } + viewModel.setSourceToLocal(getResourceAsPath("test.png")) + viewModel.setSourceToLocal(getResourceAsPath("test.ts")) + assertThat(results).containsExactly(AspectRatio.SOURCE) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/CalendarTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/CalendarTest.kt new file mode 100644 index 0000000..ca0cfd5 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/CalendarTest.kt @@ -0,0 +1,75 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.defaultCalendar +import ir.mahozad.cutcon.model.Calendar +import ir.mahozad.cutcon.model.PreferenceKeys +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable +import java.util.prefs.Preferences + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class CalendarTest { + + private val settings = Preferences + .userRoot() + .node("/${BuildConfig.APP_NAME}/test")!! + + @BeforeEach + fun setUp() { + settings.clear() + } + + @Test + fun `For first time app launch, the calendar should be default`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.calendar.first() + assertThat(result).isEqualTo(defaultCalendar) + } + + @Test + fun `When settings has calendar, the calendar should be initialized to it`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + settings.put(PreferenceKeys.PREF_CALENDAR, Calendar.GREGORIAN.name) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.calendar.toList(results) } + assertThat(results).containsExactly(Calendar.GREGORIAN) + } + + @DisabledIfEnvironmentVariable(named = "CI", matches = "true", disabledReason = "Fails on CI; FIXME") + @Test + fun `After changing calendar, the calendar should be updated`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.calendar.toList(results) } + viewModel.setCalendar(Calendar.GREGORIAN) + assertThat(results).containsExactly(defaultCalendar, Calendar.GREGORIAN) + } + + @Test + fun `After changing calendar, the calendar should be persisted`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + viewModel.setCalendar(Calendar.GREGORIAN) + val calendar = settings[PreferenceKeys.PREF_CALENDAR, null]?.let(Calendar::valueOf) + assertThat(calendar).isEqualTo(Calendar.GREGORIAN) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/ClipInfoTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/ClipInfoTest.kt new file mode 100644 index 0000000..3943520 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/ClipInfoTest.kt @@ -0,0 +1,171 @@ +package ir.mahozad.cutcon.viewmodel + +import androidx.compose.ui.text.TextRange +import io.mockk.every +import io.mockk.spyk +import ir.mahozad.cutcon.* +import ir.mahozad.cutcon.component.MediaPlayer +import ir.mahozad.cutcon.model.Clip +import ir.mahozad.cutcon.model.Progress +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import kotlin.io.path.Path +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class ClipInfoTest { + @Test + fun `Clip info should initially be the default`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.clip.first() + assertThat(result).isEqualTo(defaultClip) + } + + @Test + fun `When setting clip start to a new value, it should reflect in clip info`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.clip.toList(results) } + viewModel.onClipStartSecondChanged("3", TextRange.Zero) + assertThat(results.last()).isEqualTo(Clip(3.seconds, Duration.ZERO)) + } + + @Test + fun `When setting clip start to null, it should update to default timestamp in clip info`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.clip.toList(results) } + viewModel.onClipStartSecondChanged("", TextRange.Zero) + assertThat(results.last()).isEqualTo(Clip(defaultTimeStamp, Duration.ZERO)) + } + + @Test + fun `When setting clip end to a new value, it should reflect in clip info`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(7.seconds) + ).apply { + startMediaProgressListener() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.clip.toList(results) } + viewModel.onClipEndSecondChanged("3", TextRange.Zero) + assertThat(results.last()).isEqualTo(Clip(Duration.ZERO, 3.seconds)) + } + + @Test + fun `When setting clip end to null, it should update to default timestamp in clip info`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(7.seconds) + ).apply { + startMediaProgressListener() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.clip.toList(results) } + viewModel.onClipEndSecondChanged("", TextRange.Zero) + assertThat(results.last()).isEqualTo(Clip(Duration.ZERO, defaultTimeStamp)) + } + + @Test + fun `When setting clip end to a value after the source end, it should update to the end of source`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(7.seconds) + ).apply { + startMediaProgressListener() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.clip.toList(results) } + viewModel.onClipEndSecondChanged("20", TextRange.Zero) + assertThat(results.last()).isEqualTo(Clip(Duration.ZERO, 7.seconds)) + } + + @Test + fun `When clip end input is after media end and the source is changed with its end greater than or equal to end input, clip end should update to end input`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val mockMediaPlayer = spyk() + val progressFlow = MutableStateFlow(Progress(0f, 5.seconds)) + every { mockMediaPlayer.progress } returns progressFlow + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = mockMediaPlayer + ).apply { + startMediaProgressListener() + setSourceToLocal(Path("abc")) + onClipEndSecondChanged(string = "30", selection = TextRange.Zero) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.clip.toList(results) } + progressFlow.value = Progress(0f, 100.seconds) + viewModel.setSourceToLocal(Path("xyz")) + assertThat(results).containsExactly( + Clip(Duration.ZERO, 5.seconds), + Clip(Duration.ZERO, 30.seconds) + ) + } + + @Test + fun `When clip end input is before media end and the source is changed with its end less than end input, clip end should update to media length`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val mockMediaPlayer = spyk() + val progressFlow = MutableStateFlow(Progress(0f, 30.seconds)) + every { mockMediaPlayer.progress } returns progressFlow + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = mockMediaPlayer + ).apply { + startMediaProgressListener() + setSourceToLocal(Path("abc")) + onClipEndSecondChanged(string = "20", selection = TextRange.Zero) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.clip.toList(results) } + progressFlow.value = Progress(0f, 5.seconds) + viewModel.setSourceToLocal(Path("xyz")) + assertThat(results).containsExactly( + Clip(Duration.ZERO, 20.seconds), + Clip(Duration.ZERO, 5.seconds) + ) + } + + @Test + fun `When clip end input is before media end and the source is changed to an image, clip end should update to zero`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val mockMediaPlayer = spyk() + val progressFlow = MutableStateFlow(Progress(0f, 30.seconds)) + every { mockMediaPlayer.progress } returns progressFlow + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = mockMediaPlayer + ).apply { + startMediaProgressListener() + onClipEndSecondChanged(string = "20", selection = TextRange.Zero) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.clip.toList(results) } + progressFlow.value = Progress(0f, Duration.ZERO) + viewModel.setSourceToLocal(getResourceAsPath("test.png")) + assertThat(results).containsExactly( + Clip(Duration.ZERO, 20.seconds), + Clip(Duration.ZERO, Duration.ZERO) + ) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/ClipLoopTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/ClipLoopTest.kt new file mode 100644 index 0000000..19d8f0d --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/ClipLoopTest.kt @@ -0,0 +1,92 @@ +package ir.mahozad.cutcon.viewmodel + +import androidx.compose.ui.text.TextRange +import ir.mahozad.cutcon.FakeMediaPlayer +import ir.mahozad.cutcon.constructMainViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class ClipLoopTest { + @Test + fun `Clip looping should initially not be toggleable`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.isLoopToggleable.first() + assertThat(result).isEqualTo(false) + } + + @Test + fun `When setting clip start to a new value and clip duration is not positive, clip looping should become un-toggleable`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(7.seconds) + ).apply { + startMediaProgressListener() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isLoopToggleable.toList(results) } + viewModel.onClipEndSecondChanged("3", TextRange.Zero) + viewModel.onClipStartSecondChanged("4", TextRange.Zero) + assertThat(results).containsExactly(false, true, false) + } + + @Test + fun `When setting clip start to a new value and clip duration is positive, clip looping should become toggleable`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(7.seconds) + ).apply { + startMediaProgressListener() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isLoopToggleable.toList(results) } + viewModel.onClipEndSecondChanged("4", TextRange.Zero) + viewModel.onClipStartSecondChanged("3", TextRange.Zero) + assertThat(results).containsExactly(false, true) + } + + @Test + fun `When setting clip end to a new value and clip duration is not positive, clip looping should become un-toggleable`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(7.seconds) + ).apply { + startMediaProgressListener() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isLoopToggleable.toList(results) } + viewModel.onClipEndSecondChanged("3", TextRange.Zero) + viewModel.onClipEndSecondChanged("0", TextRange.Zero) + assertThat(results).containsExactly(false, true, false) + } + + @Test + fun `When setting clip end to a new value and clip duration is positive, clip looping should become toggleable`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(7.seconds) + ).apply { + startMediaProgressListener() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isLoopToggleable.toList(results) } + viewModel.onClipEndSecondChanged("3", TextRange.Zero) + assertThat(results).containsExactly(false, true) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/CoverTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/CoverTest.kt new file mode 100644 index 0000000..bb8d81b --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/CoverTest.kt @@ -0,0 +1,100 @@ +package ir.mahozad.cutcon.viewmodel + +import androidx.compose.ui.graphics.ImageBitmap +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.defaultCoverOptions +import ir.mahozad.cutcon.getResourceAsPath +import ir.mahozad.cutcon.model.CoverOptions +import ir.mahozad.cutcon.model.WatermarkPosition +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class CoverTest { + @Test + fun `Cover should initially have proper values`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.coverOptions.first() + assertThat(result).isEqualTo(defaultCoverOptions) + } + + @Test + fun `When setting opacity to a new value, it should reflect in cover properties`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.coverOptions.toList(results) } + viewModel.setWaterMarkOpacity(0.63f) + assertThat(results.last()).isEqualTo( + defaultCoverOptions.copy(opacity = 0.63f) + ) + } + + @Test + fun `When setting scale to a new value, it should reflect in cover properties`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.coverOptions.toList(results) } + viewModel.setWaterMarkScale(0.91f) + assertThat(results.last()).isEqualTo( + defaultCoverOptions.copy(scale = 0.91f) + ) + } + + @Test + fun `When setting position to a new value, it should reflect in cover properties`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.coverOptions.toList(results) } + viewModel.setWatermarkPosition(WatermarkPosition.BOTTOM_LEFT) + assertThat(results.last()).isEqualTo( + defaultCoverOptions.copy(position = WatermarkPosition.BOTTOM_LEFT) + ) + } + + @Test + fun `When setting cover to a new file, it should reflect in cover properties`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val cover = getResourceAsPath("test.png") + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.coverOptions.toList(results) } + viewModel.setCoverFile(cover) + assertThat(results.last()).isEqualTo( + defaultCoverOptions.copy(path = cover) + ) + } + + @Test + fun `When setting cover to a new file, the cover image bitmap should update`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val cover = getResourceAsPath("test.png") + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.coverBitmap.toList(results) } + viewModel.setCoverFile(cover) + assertThat(results.last()).isNotNull() + } + + @Test + fun `When setting cover to an unsupported file, cover should stay (or update to) null`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val cover = getResourceAsPath("test.mp3") + val viewModel = constructMainViewModel(dispatcher).apply { + setCoverFile(cover) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.coverOptions.toList(results) } + viewModel.setCoverFile(cover) + assertThat(results.last().path).isNull() + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/FormatTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/FormatTest.kt new file mode 100644 index 0000000..7d94251 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/FormatTest.kt @@ -0,0 +1,36 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.defaultFormat +import ir.mahozad.cutcon.model.Format +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class FormatTest { + @Test + fun `Format should initially be the default`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.format.first() + assertThat(result).isEqualTo(defaultFormat) + } + + @Test + fun `When setting format to new value, it should update to that`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setFormat(Format.RAW) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.format.toList(results) } + viewModel.setFormat(Format.MP3) + assertThat(results).containsExactly(Format.RAW, Format.MP3) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IntroTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IntroTest.kt new file mode 100644 index 0000000..faa1d46 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IntroTest.kt @@ -0,0 +1,89 @@ +package ir.mahozad.cutcon.viewmodel + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.defaultIntroOptions +import ir.mahozad.cutcon.getResourceAsPath +import ir.mahozad.cutcon.model.IntroOptions +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class IntroTest { + @Test + fun `Intro should initially have proper values`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.introOptions.first() + assertThat(result).isEqualTo(defaultIntroOptions) + } + + @Test + fun `When setting background color to a new value, it should reflect in intro properties`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.introOptions.toList(results) } + viewModel.setIntroBackgroundColor(Color.Red) + assertThat(results.last()).isEqualTo( + defaultIntroOptions.copy(backgroundColor = Color.Red) + ) + } + + @Test + fun `When setting duration to a new value, it should reflect in intro properties`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.introOptions.toList(results) } + viewModel.setIntroDuration(374.milliseconds) + assertThat(results.last()).isEqualTo( + defaultIntroOptions.copy(duration = 374.milliseconds) + ) + } + + @Test + fun `When setting intro to a new file, it should reflect in intro properties`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val image = getResourceAsPath("test.png") + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.introOptions.toList(results) } + viewModel.setIntroFile(image) + assertThat(results.last()).isEqualTo( + defaultIntroOptions.copy(path = image) + ) + } + + @Test + fun `When setting intro to a new file, the intro image bitmap should update`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val cover = getResourceAsPath("test.png") + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.introBitmap.toList(results) } + viewModel.setIntroFile(cover) + assertThat(results.last()).isNotNull() + } + + @Test + fun `When setting intro to an unsupported file, intro should stay (or update to) null`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val cover = getResourceAsPath("test.mp3") + val viewModel = constructMainViewModel(dispatcher).apply { + setIntroFile(cover) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.introOptions.toList(results) } + viewModel.setIntroFile(cover) + assertThat(results.last().path).isNull() + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsAlwaysOnTopTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsAlwaysOnTopTest.kt new file mode 100644 index 0000000..87f5ac8 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsAlwaysOnTopTest.kt @@ -0,0 +1,33 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.defaultIsAlwaysOnTop +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class IsAlwaysOnTopTest { + @Test + fun `IsAlwaysOnTop should initially be the default`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.isAlwaysOnTop.first() + assertThat(result).isEqualTo(defaultIsAlwaysOnTop) + } + + @Test + fun `When setting isAlwaysOnTop to new value, it should update to that`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isAlwaysOnTop.toList(results) } + viewModel.toggleIsAlwaysOnTop() + assertThat(results).containsExactly(defaultIsAlwaysOnTop, !defaultIsAlwaysOnTop) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsChangelogDialogDisplayedTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsChangelogDialogDisplayedTest.kt new file mode 100644 index 0000000..3238199 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsChangelogDialogDisplayedTest.kt @@ -0,0 +1,99 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.model.PreferenceKeys +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.prefs.Preferences + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class IsChangelogDialogDisplayedTest { + + private val settings = Preferences + .userRoot() + .node("/${BuildConfig.APP_NAME}/test")!! + + @BeforeEach + fun setUp() { + settings.clear() + } + + @Test + fun `When settings has no app changelog version, isChangelogDialogDisplayed should be true`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + assertThat(viewModel.isChangelogDialogDisplayed.first()).isEqualTo(true) + } + + @Test + fun `When settings has changelog version that is less than current app version, isChangelogDialogDisplayed should be true`() = + runTest { + settings.put(PreferenceKeys.PREF_LAST_SHOWN_CHANGELOG_VERSION, "0.0.0") + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + assertThat(viewModel.isChangelogDialogDisplayed.first()).isEqualTo(true) + } + + @Test + fun `When settings has changelog version that is equal to current app version, isChangelogDialogDisplayed should be false`() = + runTest { + settings.put(PreferenceKeys.PREF_LAST_SHOWN_CHANGELOG_VERSION, BuildConfig.APP_VERSION) + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + assertThat(viewModel.isChangelogDialogDisplayed.first()).isEqualTo(false) + } + + @Test + fun `When settings has changelog version that is greater than current app version, isChangelogDialogDisplayed should be false`() = + runTest { + settings.put( + PreferenceKeys.PREF_LAST_SHOWN_CHANGELOG_VERSION, + BuildConfig.APP_VERSION.replaceBefore('.', "9${BuildConfig.APP_VERSION.substringBefore('.')}") + ) + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + assertThat(viewModel.isChangelogDialogDisplayed.first()).isEqualTo(false) + } + + @Test + fun `After showing changelog dialog, isChangelogDialogDisplayed should change to true`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isChangelogDialogDisplayed.toList(results) } + viewModel.showChangelogDialog() + assertThat(results).containsExactly(true) + } + + @Test + fun `After dismissing changelog dialog, isChangelogDialogDisplayed should change to false`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + showChangelogDialog() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isChangelogDialogDisplayed.toList(results) } + viewModel.onChangelogDialogDismissRequest() + assertThat(results).containsExactly(true, false) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsFinishSoundEnabledTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsFinishSoundEnabledTest.kt new file mode 100644 index 0000000..aabb0ee --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsFinishSoundEnabledTest.kt @@ -0,0 +1,72 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.defaultIsFinishSoundEnabled +import ir.mahozad.cutcon.model.PreferenceKeys +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.prefs.Preferences + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class IsFinishSoundEnabledTest { + + private val settings = Preferences + .userRoot() + .node("/${BuildConfig.APP_NAME}/test")!! + + @BeforeEach + fun setUp() { + settings.clear() + } + + @Test + fun `For first time app launch, the isFinishSoundEnabled should be default`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.isFinishSoundEnabled.first() + assertThat(result).isEqualTo(defaultIsFinishSoundEnabled) + } + + @Test + fun `When settings has isFinishSoundEnabled, the isFinishSoundEnabled should be initialized to it`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + settings.put(PreferenceKeys.PREF_FINISH_SOUND, false.toString()) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isFinishSoundEnabled.toList(results) } + assertThat(results).containsExactly(false) + } + + @Test + fun `After changing isFinishSoundEnabled, the isFinishSoundEnabled should be updated`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isFinishSoundEnabled.toList(results) } + viewModel.setIsFinishSoundEnabled(false) + assertThat(results).containsExactly(defaultIsFinishSoundEnabled, false) + } + + @Test + fun `After changing isFinishSoundEnabled, the isFinishSoundEnabled should be persisted`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + viewModel.setIsFinishSoundEnabled(false) + val isFinishSoundEnabled = settings[PreferenceKeys.PREF_FINISH_SOUND, null]?.let(String::toBoolean) + assertThat(isFinishSoundEnabled).isEqualTo(false) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsFullscreenTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsFullscreenTest.kt new file mode 100644 index 0000000..4bdae13 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsFullscreenTest.kt @@ -0,0 +1,233 @@ +package ir.mahozad.cutcon.viewmodel + +import io.mockk.coEvery +import io.mockk.spyk +import ir.mahozad.cutcon.component.UrlMaker +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.converter.Converter +import ir.mahozad.cutcon.converter.ConverterFactory +import ir.mahozad.cutcon.converter.FFmpegOption +import ir.mahozad.cutcon.model.ConverterFlags +import ir.mahozad.cutcon.model.CoverOptions +import ir.mahozad.cutcon.model.IntroOptions +import ir.mahozad.cutcon.model.Quality +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import java.net.URL +import kotlin.io.path.Path +import kotlin.io.path.div + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class IsFullscreenTest { + @Test + fun `IsFullScreen should initially be false`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.isFullscreen.first() + assertThat(result).isFalse() + } + + @Test + fun `When entering fullscreen, isFullscreen should update to true`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isFullscreen.toList(results) } + viewModel.enterFullscreen() + assertThat(results.last()).isTrue() + } + + @Test + fun `When entering fullscreen, side panel should get hidden`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isSidePanelDisplayed.toList(results) } + viewModel.enterFullscreen() + assertThat(results.last()).isFalse() + } + + @Test + fun `When exiting fullscreen, isFullscreen should update to false`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isFullscreen.toList(results) } + viewModel.enterFullscreen() + viewModel.exitFullscreen() + assertThat(results.last()).isFalse() + } + + @Test + fun `When side panel is off and then entering full screen and then restoring to regular screen, side panel should be off`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + toggleSidePanel() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isSidePanelDisplayed.toList(results) } + viewModel.enterFullscreen() + viewModel.exitFullscreen() + assertThat(results).containsExactly(false) + } + + @Test + fun `When side panel is on and then entering full screen and then restoring to regular screen, side panel should be on`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isSidePanelDisplayed.toList(results) } + viewModel.enterFullscreen() + viewModel.exitFullscreen() + assertThat(results).containsExactly(true, false, true) + } + + /** + * Because when the dialog is displayed, the fullscreen automatically exits + * and the whole app layout shows the display image. + */ + @Disabled( + """ + With the new common Dialog element, this is not required anymore. + See https://github.com/JetBrains/compose-multiplatform/issues/3438 + """ + ) + @Test + fun `When isFullscreen is true and isSuccessDialogDisplayed becomes true, isFullscreen should update to false`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + println("I'm a fake converter and I'm pretending to convert...") + } + val converterFactory = ConverterFactory { converter } + val urlMaker = UrlMaker { URL("file://") } + val saveFile = Path("xyz") / "1.mp4" + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + converterFactory = converterFactory + ).apply { + setSaveFile(saveFile) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isFullscreen.toList(results) } + viewModel.enterFullscreen() + viewModel.startProcess() + assertThat(results).containsExactly(false, true, false) + } + + @Test + fun `When hiding side panel then entering fullscreen then exiting fullscreen then showing side panel then the conversion completes, the side panel should not get hidden`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + println("I'm a fake converter and I'm pretending to convert...") + } + val converterFactory = ConverterFactory { converter } + val urlMaker = UrlMaker { URL("file://") } + val saveFile = Path("xyz") / "1.mp4" + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + converterFactory = converterFactory + ).apply { + setSaveFile(saveFile) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isSidePanelDisplayed.toList(results) } + viewModel.toggleSidePanel() + viewModel.enterFullscreen() + viewModel.exitFullscreen() + viewModel.toggleSidePanel() + viewModel.startProcess() + assertThat(results).containsExactly(true, false, true) + } + + /** + * When toggling side panel with keyboard shortcut. + */ + @Test + fun `When fullscreen is on, toggling the side panel should have no effect`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + enterFullscreen() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isSidePanelDisplayed.toList(results) } + viewModel.toggleSidePanel() + assertThat(results).containsExactly(false) + } + + /** + * When toggling mini screen with keyboard shortcut. + */ + @Test + fun `When fullscreen is on, toggling the mini screen should have no effect`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + enterFullscreen() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isMiniScreen.toList(results) } + viewModel.toggleMiniScreen() + assertThat(results).containsExactly(false) + } + + @Disabled("Not needed anymore. See the disabled unit test above for why.") + @Test + fun `When side panel is on then entering fullscreen then the conversion completes, the side panel should update to on`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + println("I'm a fake converter and I'm pretending to convert...") + } + val converterFactory = ConverterFactory { converter } + val urlMaker = UrlMaker { URL("file://") } + val saveFile = Path("xyz") / "1.mp4" + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + converterFactory = converterFactory + ).apply { + setSaveFile(saveFile) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isSidePanelDisplayed.toList(results) } + viewModel.enterFullscreen() + viewModel.startProcess() + assertThat(results).containsExactly(true, false, true) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsInterlacedFixEnabledTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsInterlacedFixEnabledTest.kt new file mode 100644 index 0000000..671a23a --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsInterlacedFixEnabledTest.kt @@ -0,0 +1,72 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.defaultIsInterlacedFixEnabled +import ir.mahozad.cutcon.model.PreferenceKeys +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.prefs.Preferences + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class IsInterlacedFixEnabledTest { + + private val settings = Preferences + .userRoot() + .node("/${BuildConfig.APP_NAME}/test")!! + + @BeforeEach + fun setUp() { + settings.clear() + } + + @Test + fun `For first time app launch, the isInterlacedFixEnabled should be default`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.isInterlacedFixEnabled.first() + assertThat(result).isEqualTo(defaultIsInterlacedFixEnabled) + } + + @Test + fun `When settings has isInterlacedFixEnabled, the isInterlacedFixEnabled should be initialized to it`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + settings.put(PreferenceKeys.PREF_INTERLACED_FIX, false.toString()) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isInterlacedFixEnabled.toList(results) } + assertThat(results).containsExactly(false) + } + + @Test + fun `After changing isInterlacedFixEnabled, the isInterlacedFixEnabled should be updated`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isInterlacedFixEnabled.toList(results) } + viewModel.setIsInterlacedFixEnabled(false) + assertThat(results).containsExactly(defaultIsInterlacedFixEnabled, false) + } + + @Test + fun `After changing isInterlacedFixEnabled, the isInterlacedFixEnabled should be persisted`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + viewModel.setIsInterlacedFixEnabled(false) + val isInterlacedFixEnabled = settings[PreferenceKeys.PREF_INTERLACED_FIX, null]?.let(String::toBoolean) + assertThat(isInterlacedFixEnabled).isEqualTo(false) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsMiniScreenTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsMiniScreenTest.kt new file mode 100644 index 0000000..991c560 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsMiniScreenTest.kt @@ -0,0 +1,83 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.constructMainViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class IsMiniScreenTest { + @Test + fun `IsMiniScreen should initially be false`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.isMiniScreen.first() + assertThat(result).isFalse() + } + + @Test + fun `When toggling mini screen, isMini should update`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isMiniScreen.toList(results) } + viewModel.toggleMiniScreen() + assertThat(results.last()).isTrue() + } + + @Test + fun `When entering mini screen, side panel should get hidden`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isSidePanelDisplayed.toList(results) } + viewModel.toggleMiniScreen() + assertThat(results.last()).isFalse() + } + + @Test + fun `When side panel is off and then entering mini screen and then restoring to regular screen, side panel should be off`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + toggleSidePanel() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isSidePanelDisplayed.toList(results) } + viewModel.toggleMiniScreen() + viewModel.toggleMiniScreen() + assertThat(results).containsExactly(false) + } + + /** + * This happens, for example, when using the keyboard shortcut. + */ + @Test + fun `When mini screen is on, toggling the side panel should have no effect`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + toggleMiniScreen() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isSidePanelDisplayed.toList(results) } + viewModel.toggleSidePanel() + assertThat(results).containsExactly(false) + } + + @Test + fun `When side panel is on and then entering mini screen and then restoring to regular screen, side panel should be on`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isSidePanelDisplayed.toList(results) } + viewModel.toggleMiniScreen() + viewModel.toggleMiniScreen() + assertThat(results).containsExactly(true, false, true) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsQualityInputApplicableTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsQualityInputApplicableTest.kt new file mode 100644 index 0000000..493ec2c --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsQualityInputApplicableTest.kt @@ -0,0 +1,49 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.model.Format +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class IsQualityInputApplicableTest { + + @Test + fun `Initially, the isQualityInputApplicable should be true`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.isQualityInputApplicable.first() + assertThat(result).isEqualTo(true) + } + + @Test + fun `When format is not RAW and setting format to RAW, isQualityInputApplicable should update to false`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setFormat(Format.MP4) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isQualityInputApplicable.toList(results) } + viewModel.setFormat(Format.RAW) + assertThat(results).containsExactly(true, false) + } + + @Test + fun `When format is RAW and setting format to not RAW, isQualityInputApplicable should update to true`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setFormat(Format.RAW) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isQualityInputApplicable.toList(results) } + viewModel.setFormat(Format.MP3) + assertThat(results).containsExactly(false, true) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsScreenshotInputEnabledTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsScreenshotInputEnabledTest.kt new file mode 100644 index 0000000..de43220 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsScreenshotInputEnabledTest.kt @@ -0,0 +1,54 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.getResourceAsPath +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class IsScreenshotInputEnabledTest { + @Nested + inner class LocalSourceTest { + @Test + fun `When setting file to a video, IsScreenshotInputEnabled should update to true`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setSourceToLocal(getResourceAsPath("test.png")) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isScreenshotInputEnabled.toList(results) } + viewModel.setSourceToLocal(getResourceAsPath("test.ts")) + assertThat(results).containsExactly(false, true) + } + + @Test + fun `When setting file to an image, IsScreenshotInputEnabled should update to false`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setSourceToLocal(getResourceAsPath("test.ts")) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isScreenshotInputEnabled.toList(results) } + viewModel.setSourceToLocal(getResourceAsPath("test.png")) + assertThat(results).containsExactly(true, false) + } + + @Test + fun `When setting file to an audio, IsScreenshotInputEnabled should update to false`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setSourceToLocal(getResourceAsPath("test.ts")) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isScreenshotInputEnabled.toList(results) } + viewModel.setSourceToLocal(getResourceAsPath("test.mp3")) + assertThat(results).containsExactly(true, false) + } + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsScreenshotSoundEnabledTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsScreenshotSoundEnabledTest.kt new file mode 100644 index 0000000..ca4ea39 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/IsScreenshotSoundEnabledTest.kt @@ -0,0 +1,73 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.defaultIsScreenshotSoundEnabled +import ir.mahozad.cutcon.model.PreferenceKeys +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.prefs.Preferences + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class IsScreenshotSoundEnabledTest { + + private val settings = Preferences + .userRoot() + .node("/${BuildConfig.APP_NAME}/test")!! + + @BeforeEach + fun setUp() { + settings.clear() + } + + @Test + fun `For first time app launch, the isScreenshotSoundEnabled should be default`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.isScreenshotSoundEnabled.first() + assertThat(result).isEqualTo(defaultIsScreenshotSoundEnabled) + } + + @Test + fun `When settings has isScreenshotSoundEnabled, the isScreenshotSoundEnabled should be initialized to it`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + settings.put(PreferenceKeys.PREF_SCREENSHOT_SOUND, false.toString()) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isScreenshotSoundEnabled.toList(results) } + assertThat(results).containsExactly(false) + } + + @Test + fun `After changing isScreenshotSoundEnabled, the isScreenshotSoundEnabled should be updated`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isScreenshotSoundEnabled.toList(results) } + viewModel.setIsScreenshotSoundEnabled(false) + assertThat(results).containsExactly(defaultIsScreenshotSoundEnabled, false) + } + + @Test + fun `After changing isScreenshotSoundEnabled, it should be persisted`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + viewModel.setIsScreenshotSoundEnabled(false) + val isScreenshotSoundEnabled = settings[PreferenceKeys.PREF_SCREENSHOT_SOUND, null]?.let(String::toBoolean) + assertThat(isScreenshotSoundEnabled).isEqualTo(false) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/KeyEventTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/KeyEventTest.kt new file mode 100644 index 0000000..8136d8e --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/KeyEventTest.kt @@ -0,0 +1,99 @@ +package ir.mahozad.cutcon.viewmodel + +import androidx.compose.ui.awt.ComposeWindow +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.model.MediaInfo +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.awt.event.KeyEvent + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class KeyEventTest { + @Test + fun `When media is resumed and hitting Space bar, the media should pause`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + val keyEvent = androidx.compose.ui.input.key.KeyEvent( + nativeKeyEvent = KeyEvent( + ComposeWindow(), KeyEvent.KEY_PRESSED, 0, 0, ' '.code, ' ', KeyEvent.KEY_LOCATION_STANDARD + ) + ) + viewModel.onKeyboardEvent(keyEvent) + assertThat(results.map(MediaInfo::isResumed)).containsExactly(true, false) + } + + @Test + fun `When media is paused and hitting Space bar, the media should resume`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + toggleResume() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + val keyEvent = androidx.compose.ui.input.key.KeyEvent( + nativeKeyEvent = KeyEvent( + ComposeWindow(), KeyEvent.KEY_PRESSED, 0, 0, ' '.code, ' ', KeyEvent.KEY_LOCATION_STANDARD + ) + ) + viewModel.onKeyboardEvent(keyEvent) + assertThat(results.map(MediaInfo::isResumed)).containsExactly(false, true) + } + + @Test + fun `When isFullscreen is false and hitting F, isFullscreen should update to true`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isFullscreen.toList(results) } + val keyEvent = androidx.compose.ui.input.key.KeyEvent( + nativeKeyEvent = KeyEvent( + ComposeWindow(), KeyEvent.KEY_PRESSED, 0, 0, 'F'.code, 'F', KeyEvent.KEY_LOCATION_STANDARD + ) + ) + viewModel.onKeyboardEvent(keyEvent) + assertThat(results).containsExactly(false, true) + } + + @Test + fun `When isFullscreen is true and hitting F, isFullscreen should update to false`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + enterFullscreen() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isFullscreen.toList(results) } + val keyEvent = androidx.compose.ui.input.key.KeyEvent( + nativeKeyEvent = KeyEvent( + ComposeWindow(), KeyEvent.KEY_PRESSED, 0, 0, 'F'.code, 'F', KeyEvent.KEY_LOCATION_STANDARD + ) + ) + viewModel.onKeyboardEvent(keyEvent) + assertThat(results).containsExactly(true, false) + } + + @Test + fun `When isFullscreen is true and hitting Escape, isFullscreen should update to false`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + enterFullscreen() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.isFullscreen.toList(results) } + val keyEvent = androidx.compose.ui.input.key.KeyEvent( + nativeKeyEvent = KeyEvent( + ComposeWindow(), KeyEvent.KEY_PRESSED, 0, 0, 27, 27.toChar(), KeyEvent.KEY_LOCATION_STANDARD + ) + ) + viewModel.onKeyboardEvent(keyEvent) + assertThat(results).containsExactly(true, false) + } + + // etc. +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/LanguageTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/LanguageTest.kt new file mode 100644 index 0000000..8e9233c --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/LanguageTest.kt @@ -0,0 +1,76 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.defaultLanguage +import ir.mahozad.cutcon.localization.Language +import ir.mahozad.cutcon.localization.LanguageEn +import ir.mahozad.cutcon.model.PreferenceKeys +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable +import java.util.prefs.Preferences + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class LanguageTest { + + private val settings = Preferences + .userRoot() + .node("/${BuildConfig.APP_NAME}/test")!! + + @BeforeEach + fun setUp() { + settings.clear() + } + + @Test + fun `For first time app launch, the language should be default`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.language.first() + assertThat(result).isEqualTo(defaultLanguage) + } + + @Test + fun `When settings has language, the language should be initialized to it`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + settings.put(PreferenceKeys.PREF_LANGUAGE, LanguageEn.tag) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.language.toList(results) } + assertThat(results).containsExactly(LanguageEn) + } + + @DisabledIfEnvironmentVariable(named = "CI", matches = "true", disabledReason = "Fails on CI; FIXME") + @Test + fun `After changing language, the language should be updated`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.language.toList(results) } + viewModel.setLanguage(LanguageEn) + assertThat(results).containsExactly(defaultLanguage, LanguageEn) + } + + @Test + fun `After changing language, the language should be persisted`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + viewModel.setLanguage(LanguageEn) + val language = settings[PreferenceKeys.PREF_LANGUAGE, null]?.let(Language::fromTag) + assertThat(language).isEqualTo(LanguageEn) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/LastOpenDirectoryTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/LastOpenDirectoryTest.kt new file mode 100644 index 0000000..d4a1df3 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/LastOpenDirectoryTest.kt @@ -0,0 +1,166 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.model.PreferenceKeys +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.nio.file.Path +import java.util.prefs.Preferences +import kotlin.io.path.Path +import kotlin.io.path.absolute +import kotlin.io.path.absolutePathString +import kotlin.io.path.createTempDirectory + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class LastOpenDirectoryTest { + + private val settings = Preferences + .userRoot() + .node("/${BuildConfig.APP_NAME}/test")!! + + @BeforeEach + fun setUp() { + settings.clear() + } + + @Test + fun `For first time app launch, lastOpenDirectory should be null`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.lastOpenDirectory.first() + assertThat(result).isEqualTo(null) + } + + @Test + fun `When settings has last open directory but the directory does not exist on filesystem, lastOpenDirectory should be initialized to null`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val directory = Path("a/bc/d") + settings.put(PreferenceKeys.PREF_LAST_OPEN_DIRECTORY, directory.absolutePathString()) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.lastOpenDirectory.toList(results) } + assertThat(results).containsExactly(null) + } + + @Test + fun `When settings has last open directory and the directory exists on filesystem, lastOpenDirectory should be initialized to it`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val directory = createTempDirectory() + settings.put(PreferenceKeys.PREF_LAST_OPEN_DIRECTORY, directory.absolutePathString()) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.lastOpenDirectory.toList(results) } + assertThat(results).containsExactly(directory.absolute()) + } + + @Test + fun `After setting local source to a new file, lastOpenDirectory should be updated to its directory`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.lastOpenDirectory.toList(results) } + viewModel.setSourceToLocal(Path("a/bc/d/1.mp4")) + assertThat(results).containsExactly(null, Path("a/bc/d")) + } + + @Test + fun `After setting intro to a new file, lastOpenDirectory should be updated to its directory`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.lastOpenDirectory.toList(results) } + viewModel.setIntroFile(Path("a/bc/d/1.png")) + assertThat(results).containsExactly(null, Path("a/bc/d")) + } + + @Test + fun `After setting cover to a new file, lastOpenDirectory should be updated to its directory`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.lastOpenDirectory.toList(results) } + viewModel.setCoverFile(Path("a/bc/d/2.jpg")) + assertThat(results).containsExactly(null, Path("a/bc/d")) + } + + @Test + fun `After setting new local source file, new lastOpenDirectory should be persisted in settings`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val file = Path("w/xy/z/1.mp4") + viewModel.setSourceToLocal(file) + val directory = settings[PreferenceKeys.PREF_LAST_OPEN_DIRECTORY, null]?.let(::Path) + assertThat(directory).isEqualTo(file.parent.absolute()) + } + + @Test + fun `After setting new intro file, new lastOpenDirectory should be persisted in settings`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val file = Path("w/xy/z/1.png") + viewModel.setIntroFile(file) + val directory = settings[PreferenceKeys.PREF_LAST_OPEN_DIRECTORY, null]?.let(::Path) + assertThat(directory).isEqualTo(file.parent.absolute()) + } + + @Test + fun `After setting new cover file, new lastOpenDirectory should be persisted in settings`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val file = Path("w/xy/z/2.jpg") + viewModel.setCoverFile(file) + val directory = settings[PreferenceKeys.PREF_LAST_OPEN_DIRECTORY, null]?.let(::Path) + assertThat(directory).isEqualTo(file.parent.absolute()) + } + + @Test + fun `After setting intro to null, lastOpenDirectory should not change`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val file = Path("w/xy/z/1.png") + val viewModel = constructMainViewModel(dispatcher).apply { + setIntroFile(file) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.lastOpenDirectory.toList(results) } + viewModel.setIntroFile(null) + assertThat(results).containsExactly(Path("w/xy/z")) + } + + @Test + fun `After setting cover to null, lastOpenDirectory should not change`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val file = Path("w/xy/z/2.jpg") + val viewModel = constructMainViewModel(dispatcher).apply { + setIntroFile(file) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.lastOpenDirectory.toList(results) } + viewModel.setCoverFile(null) + assertThat(results).containsExactly(Path("w/xy/z")) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/LastSaveDirectoryTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/LastSaveDirectoryTest.kt new file mode 100644 index 0000000..83b8390 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/LastSaveDirectoryTest.kt @@ -0,0 +1,94 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.model.PreferenceKeys +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.nio.file.Path +import java.util.prefs.Preferences +import kotlin.io.path.Path +import kotlin.io.path.absolute +import kotlin.io.path.absolutePathString +import kotlin.io.path.createTempDirectory + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class LastSaveDirectoryTest { + + private val settings = Preferences + .userRoot() + .node("/${BuildConfig.APP_NAME}/test")!! + + @BeforeEach + fun setUp() { + settings.clear() + } + + @Test + fun `For first time app launch, lastSaveDirectory should be null`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.lastSaveDirectory.first() + assertThat(result).isEqualTo(null) + } + + @Test + fun `When settings has last save directory but the directory does not exist on filesystem, lastSaveDirectory should be initialized to null`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val directory = Path("a/bc/d") + settings.put(PreferenceKeys.PREF_LAST_SAVE_DIRECTORY, directory.absolutePathString()) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.lastSaveDirectory.toList(results) } + assertThat(results).containsExactly(null) + } + + @Test + fun `When settings has last save directory and the directory exists on filesystem, lastSaveDirectory should be initialized to it`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val directory = createTempDirectory() + settings.put(PreferenceKeys.PREF_LAST_SAVE_DIRECTORY, directory.absolutePathString()) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.lastSaveDirectory.toList(results) } + assertThat(results).containsExactly(directory.absolute()) + } + + @Test + fun `After setting a save file, lastSaveDirectory should be updated to its directory`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.lastSaveDirectory.toList(results) } + viewModel.setSaveFile(Path("a/bc/d/1.mp4")) + assertThat(results).containsExactly(null, Path("a/bc/d")) + } + + @Test + fun `After setting new save file, new lastSaveDirectory should be persisted in settings`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val saveFile = Path("a/bc/d/1.mp4") + viewModel.setSaveFile(saveFile) + val directory = settings[PreferenceKeys.PREF_LAST_SAVE_DIRECTORY, null]?.let(::Path) + assertThat(directory).isEqualTo(saveFile.parent.absolute()) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/MainViewModelTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/MainViewModelTest.kt new file mode 100644 index 0000000..73270d4 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/MainViewModelTest.kt @@ -0,0 +1,292 @@ +package ir.mahozad.cutcon.viewmodel + +import androidx.compose.ui.text.TextRange +import io.mockk.* +import ir.mahozad.cutcon.* +import ir.mahozad.cutcon.component.DefaultUrlMaker +import ir.mahozad.cutcon.component.MediaPlayerTest +import ir.mahozad.cutcon.component.SystemDateTime +import ir.mahozad.cutcon.component.UrlMaker +import ir.mahozad.cutcon.converter.Converter +import ir.mahozad.cutcon.converter.DefaultConverterFactory +import ir.mahozad.cutcon.converter.FFmpegOption +import ir.mahozad.cutcon.converter.ProgressListener +import ir.mahozad.cutcon.model.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.CleanupMode +import org.junit.jupiter.api.io.TempDir +import java.net.URL +import java.nio.file.Path +import java.time.LocalTime +import kotlin.io.path.Path +import kotlin.io.path.div +import kotlin.io.path.fileSize +import kotlin.io.path.notExists +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class MainViewModelTest { + // See the README + @Nested inner class ThemeTests : ThemeTest() + @Nested inner class IntroTests : IntroTest() + @Nested inner class CoverTests : CoverTest() + @Nested inner class StatusTests : StatusTest() + @Nested inner class SourceTests : SourceTest() + @Nested inner class FormatTests : FormatTest() + @Nested inner class QualityTests : QualityTest() + @Nested inner class AppExitTests : AppExitTest() + @Nested inner class SaveFilTests : SaveFileTest() + @Nested inner class ClipLoopTests : ClipLoopTest() + @Nested inner class KeyEventTests : KeyEventTest() + @Nested inner class LanguageTests : LanguageTest() + @Nested inner class CalendarTests : CalendarTest() + @Nested inner class ClipInfoTests : ClipInfoTest() + @Nested inner class MediaInfoTests : MediaInfoTest() + @Nested inner class MediaPlayerTests : MediaPlayerTest() + @Nested inner class AspectRatioTests : AspectRatioTest() + @Nested inner class IsMiniScreenTests : IsMiniScreenTest() + @Nested inner class IsFullscreenTests : IsFullscreenTest() + @Nested inner class IsAlwaysOnTopTests : IsAlwaysOnTopTest() + @Nested inner class WindowPositionTests : WindowPositionTest() + @Nested inner class LastOpenDirectoryTests : LastOpenDirectoryTest() + @Nested inner class LastSaveDirectoryTests : LastSaveDirectoryTest() + @Nested inner class IsFinishSoundEnabledTests : IsFinishSoundEnabledTest() + @Nested inner class IsInterlacedFixEnabledTests : IsInterlacedFixEnabledTest() + @Nested inner class IsQualityInputApplicableTests : IsQualityInputApplicableTest() + @Nested inner class IsScreenshotInputEnabledTests : IsScreenshotInputEnabledTest() + @Nested inner class IsScreenshotSoundEnabledTests : IsScreenshotSoundEnabledTest() + @Nested inner class IsChangelogDialogDisplayedTests : IsChangelogDialogDisplayedTest() + + @Test + fun `When the current time minutes and seconds is zero, live seek fraction should be zero, not infinity`() = + runTest { + mockkObject(SystemDateTime) + every { SystemDateTime.nowTime() } returns LocalTime.of(10, 0, 0) + assertThat(liveSeekFraction).isEqualTo(0f) + unmockkAll() + } + + @Test + fun `Converting a TV clip to MP3 format should succeed`( + @TempDir(cleanup = CleanupMode.ON_SUCCESS) tempOutputDirectory: Path + ) = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val cover = getResourceAsPath("test.png") + val urlMaker = UrlMaker { getResourceAsURL("test.ts") } + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + mediaPlayer = FakeMediaPlayer(100.seconds), + converterFactory = DefaultConverterFactory(dispatcher) + ).apply { + startMediaProgressListener() + setFormat(Format.MP3) + setSaveFile(tempOutputDirectory / "1.mp3") + onClipStartSecondChanged("0", TextRange.Zero) + onClipEndSecondChanged("1", TextRange.Zero) + setCoverFile(cover) + } + viewModel.startProcess() + assertThat((tempOutputDirectory / "1.mp3").fileSize()).isGreaterThan(15_000) + } + + @Disabled( + """ + Fails because we detect files with .ts extension to have video/* mime type. + More generally, currently converting any video container format that contains only audio fails. + See the main README file for the related FIXME. + """ + ) + @Test + fun `Converting an audio file with TS extension to MP4 format should succeed`( + @TempDir(cleanup = CleanupMode.ON_SUCCESS) tempOutputDirectory: Path + ) = runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = DefaultUrlMaker, + mediaPlayer = FakeMediaPlayer(100.seconds), + converterFactory = DefaultConverterFactory(dispatcher) + ).apply { + startMediaProgressListener() + setSourceToLocal(getResourceAsPath("test-no-video.ts")) + setFormat(Format.MP4) + setSaveFile(tempOutputDirectory / "1.mp4") + onClipStartSecondChanged("0", TextRange.Zero) + onClipEndSecondChanged("1", TextRange.Zero) + } + viewModel.startProcess() + assertThat((tempOutputDirectory / "1.mp4").fileSize()).isGreaterThan(10_000) + } + + @Test + fun `After cancelling the conversion process, converter should be canceled and state be reset`() = + runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + println("I'm a fake converter and I'm running an infinite loop pretending to convert...") + while (isActive) { + delay(15.milliseconds) + println("Fake conversion in work...") + } + } + val urlMaker = UrlMaker { URL("file://") } + val saveFileValues = mutableListOf() + val statusValues = mutableListOf() + val saveFile = Path("xyz") / "1.mp4" + val sourcePath = getResourceAsPath("test.ts") + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + mediaPlayer = FakeMediaPlayer(100.seconds), + converterFactory = { converter } + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("8", TextRange.Zero) + setSaveFile(saveFile) + setSourceToLocal(sourcePath) + } + backgroundScope.launch(dispatcher) { viewModel.saveFile.toList(saveFileValues) } + backgroundScope.launch(dispatcher) { viewModel.status.toList(statusValues) } + + viewModel.startProcess() + delay(50.milliseconds) + viewModel.cancelProcess() + + assertThat(statusValues).contains( + Status.Ready, + Status.InProgress(source = Source.Local(sourcePath), progress = 0f) + ) + assertThat(saveFileValues).containsExactly(saveFile, null) + assertThat(saveFile).satisfiesAnyOf( + { assertThat(it.notExists()).isTrue() }, + { assertThat(it.fileSize()).isLessThan(1_000_000) } + ) + } + + @Test + fun `If the source is changed and then clip creation is started, progress source should be the new source`() = + runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + var progress = 0f + println("I'm a fake converter and I'm running an infinite loop pretending to convert...") + while (isActive) { + delay(15.milliseconds) + (args[7] as ProgressListener).onProgressUpdate(progress) + println("Fake conversion emitted progress $progress") + progress += 0.01f + } + } + val urlMaker = UrlMaker { URL("file://") } + val statusValues = mutableListOf() + val saveFile = Path("xyz") / "1.mp4" + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + mediaPlayer = FakeMediaPlayer(100.seconds), + converterFactory = { converter } + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("8", TextRange.Zero) + setSaveFile(saveFile) + } + backgroundScope.launch(dispatcher) { viewModel.status.toList(statusValues) } + + viewModel.setSourceToLocal(Path("x/y/z.mp4")) + viewModel.startProcess() + delay(50.milliseconds) + viewModel.setSourceToLocal(Path("abc.mp4")) + delay(50.milliseconds) + viewModel.cancelProcess() + + assertThat( + statusValues + .filterIsInstance() + .map(Status.InProgress::source) + .toSet() + ).containsExactly( + Source.Local(Path("x/y/z.mp4")) + ) + } + + @Test + fun `If the source is changed while the clip is being created, progress source should be the original one from which the clip is being created`() = + runTest(timeout = testTimeout) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + var progress = 0f + println("I'm a fake converter and I'm running an infinite loop pretending to convert...") + while (isActive) { + delay(15.milliseconds) + (args[7] as ProgressListener).onProgressUpdate(progress) + println("Fake conversion emitted progress $progress") + progress += 0.01f + } + } + val urlMaker = UrlMaker { URL("file://") } + val statusValues = mutableListOf() + val saveFile = Path("xyz") / "1.mp4" + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + mediaPlayer = FakeMediaPlayer(100.seconds), + converterFactory = { converter } + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("8", TextRange.Zero) + setSaveFile(saveFile) + } + backgroundScope.launch(dispatcher) { viewModel.status.toList(statusValues) } + + viewModel.startProcess() + delay(50.milliseconds) + viewModel.setSourceToLocal(Path("abc.mp4")) + delay(50.milliseconds) + viewModel.cancelProcess() + + assertThat( + statusValues + .filterIsInstance() + .map(Status.InProgress::source) + .toSet() + ).containsExactly( + defaultSource + ) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/MediaInfoTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/MediaInfoTest.kt new file mode 100644 index 0000000..126b9ff --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/MediaInfoTest.kt @@ -0,0 +1,591 @@ +package ir.mahozad.cutcon.viewmodel + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.res.loadImageBitmap +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.spyk +import io.mockk.unmockkAll +import ir.mahozad.cutcon.* +import ir.mahozad.cutcon.component.* +import ir.mahozad.cutcon.model.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.jaudiotagger.audio.AudioFileIO +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import kotlin.io.path.Path +import kotlin.io.path.absolute +import kotlin.io.path.invariantSeparatorsPathString +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class MediaInfoTest { + + companion object { + @JvmStatic + @BeforeAll + fun initialize() { + mockkObject(SystemDateTime) + } + + @JvmStatic + @AfterAll + fun terminate() { + unmockkAll() + } + } + + @Test + fun `Media info should initially have proper value`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.mediaInfo.first() + assertThat(result).isEqualTo(constructMediaInfo()) + } + + @Test + fun `When media is paused and media url changes, isResumed should update to true because VLC automatically starts playing the new media when a new URL is set`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = DefaultUrlMaker + ).apply { + startUrlMaker() + toggleResume() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.setSourceToLocal(Path("abc.xyz")) + assertThat(results) + .last() + .extracting(MediaInfo::isResumed) + .isEqualTo(true) + } + + @Test + fun `When local file source is set, media url should update`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = DefaultUrlMaker + ).apply { + startUrlMaker() + } + val results = mutableListOf() + val path = Path("a/1.mp4") + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.setSourceToLocal(path) + assertThat(results.last().url.toString()) + .isEqualTo("file://localhost/${path.absolute().invariantSeparatorsPathString}") + } + + @Test + fun `When toggling resume, media info should update`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.toggleResume() + assertThat(results[1]).isEqualTo( + constructMediaInfo(isResumed = false) + ) + } + + @Test + fun `When toggling audio mute, media info should update`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.toggleAudioMute() + assertThat(results[1]).isEqualTo( + constructMediaInfo(isAudioMuted = !defaultIsAudioMuted) + ) + } + + @Test + fun `When setting seek to new value, media info progress should update`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.setSeek(0.42f) + assertThat(results[1].progress.fraction).isEqualTo(0.42f) + } + + @Test + fun `When clip loop was on and now is off, setting seek to a value after clip end, media info progress should update to the seek`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("56", TextRange.Zero) + toggleClipLoop() + toggleClipLoop() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.setSeek(0.88f) + assertThat(results.last().progress.fraction).isEqualTo(0.88f) + } + + @Test + fun `When setting audio volume to new value, media info should update`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.setVolume(0.83f) + assertThat(results[1]).isEqualTo( + constructMediaInfo(audioVolume = 0.83f) + ) + } + + @Test + fun `When setting speed to new value, media info speed should update`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.setSpeed(Speed.FAST2_5) + assertThat(results[1]).isEqualTo( + constructMediaInfo(speed = Speed.FAST2_5) + ) + } + + @Test + fun `When no previous speed was set and resetting speed, media info speed should reset to 'default fast'`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.resetSpeed() + assertThat(results[1]).isEqualTo( + constructMediaInfo(speed = defaultFastSpeed) + ) + } + + @Test + fun `When speed is not the default and resetting speed, media info speed should reset to default`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setSpeed(Speed.FAST2_5) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.resetSpeed() + assertThat(results[1]).isEqualTo( + constructMediaInfo() + ) + } + + @Test + fun `When speed is set to non-default and then set to default and then resetting speed, media info speed should become the last non-default speed`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setSpeed(Speed.FAST3_0) + setSpeed(defaultSpeed) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.resetSpeed() + assertThat(results[1]).isEqualTo( + constructMediaInfo(speed = Speed.FAST3_0) + ) + } + + @Test + fun `When no previous speed was set and resetting speed for two times, media info speed should become the default speed`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.resetSpeed() + viewModel.resetSpeed() + assertThat(results[2]).isEqualTo( + constructMediaInfo() + ) + } + + @Test + fun `When clip looping is on and setting clip start to a new value, clip loop should update to null`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("2", TextRange.Zero) + toggleClipLoop() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.onClipStartSecondChanged("1", TextRange.Zero) + assertThat(results.last()).isEqualTo( + constructMediaInfo( + progress = Progress(fraction = 0f, 100.seconds), + clipToLoop = null + ) + ) + } + + @Test + fun `When clip looping is on and setting clip end to a new value, clip loop should update to null`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("2", TextRange.Zero) + toggleClipLoop() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.onClipEndSecondChanged("6", TextRange.Zero) + assertThat(results.last()).isEqualTo( + constructMediaInfo( + progress = Progress(fraction = 0f, 100.seconds), + clipToLoop = null + ) + ) + } + + @Test + fun `After toggling on clip loop, progress should update to clip start time`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + onClipStartSecondChanged("7", TextRange.Zero) + onClipEndSecondChanged("13", TextRange.Zero) + setSeek(0.5f) // Also triggers media progress update + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.toggleClipLoop() + assertThat(results.last()).isEqualTo( + constructMediaInfo( + progress = Progress(0.07f, 100.seconds), + clipToLoop = Clip(7.seconds, 13.seconds) + ) + ) + } + + @Test + fun `After toggling off clip loop, progress should not change`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + onClipStartSecondChanged("7", TextRange.Zero) + onClipEndSecondChanged("13", TextRange.Zero) + toggleClipLoop() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.setSeek(0.1f) + viewModel.toggleClipLoop() + assertThat(results.last()).isEqualTo( + constructMediaInfo( + progress = Progress(0.1f, 100.seconds), + clipToLoop = null + ) + ) + } + + @Test + fun `When clip loop is on and seeking to a time before clip start, progress should update to clip start`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + onClipStartSecondChanged("24", TextRange.Zero) + onClipEndSecondChanged("56", TextRange.Zero) + setSeek(0.5f) + toggleClipLoop() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.setSeek(0.1f) + assertThat(results.last()).isEqualTo( + constructMediaInfo( + progress = Progress(0.24f, 100.seconds), + clipToLoop = Clip(24.seconds, 56.seconds) + ) + ) + } + + @Test + fun `When clip loop is on and seeking to a time in clip bounds, seek should update to that`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + onClipStartSecondChanged("24", TextRange.Zero) + onClipEndSecondChanged("56", TextRange.Zero) + setSeek(0.5f) + toggleClipLoop() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.setSeek(0.39f) + assertThat(results.last()).isEqualTo( + constructMediaInfo( + progress = Progress(0.39f, 100.seconds), + clipToLoop = Clip(24.seconds, 56.seconds) + ) + ) + } + + @Test + fun `When clip loop is on and seeking to a time after clip end, seek should update to clip end`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + onClipStartSecondChanged("24", TextRange.Zero) + onClipEndSecondChanged("56", TextRange.Zero) + setSeek(0.5f) + toggleClipLoop() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.mediaInfo.toList(results) } + viewModel.setSeek(0.9f) + assertThat(results.last()).isEqualTo( + constructMediaInfo( + progress = Progress(0.56f, 100.seconds), + clipToLoop = Clip(24.seconds, 56.seconds) + ) + ) + } + + @Test + fun `When source is set to a local file containing album art, display image should update to the local file album art`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val mockMediaPlayer = spyk() + val fakeMediaImage = decodeImage(getResourceAsPath("test.png")) + every { mockMediaPlayer.video } returns flowOf(fakeMediaImage) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = mockMediaPlayer + ).apply { + setSourceToLocal(Path("test.ts")) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.displayImage.toList(results) } + val localFile = getResourceAsPath("test.mp3") + viewModel.setSourceToLocal(localFile) + // FIXME: Duplicate of code in MainViewModel; extract both to Utilities file + val localFileAlbumArt = localFile + .toFile() + .runCatching(AudioFileIO::read) + .getOrNull() + ?.tag + ?.firstArtwork + ?.binaryData + ?.inputStream() + ?.use(::loadImageBitmap) + advanceTimeBy(1.minutes) + assertThat(results.map(ImageBitmap?::getPixels)).containsExactly( + null, + fakeMediaImage.getPixels(), + null, + localFileAlbumArt.getPixels() + ) + } + + @Test + fun `When source is local containing album art and it is set to a local audio file with no album art, display image should update to default display image`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val mockMediaPlayer = spyk() + val fakeMediaImage = decodeImage(getResourceAsPath("test.png")) + every { mockMediaPlayer.video } returns flowOf(fakeMediaImage) + val localFile = getResourceAsPath("test.mp3") + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = mockMediaPlayer + ).apply { + setSourceToLocal(localFile) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.displayImage.toList(results) } + viewModel.setSourceToLocal(getResourceAsPath("test-no-cover.mp3")) + val localFileAlbumArt = localFile + .toFile() + .runCatching(AudioFileIO::read) + .getOrNull() + ?.tag + ?.firstArtwork + ?.binaryData + ?.inputStream() + ?.use(::loadImageBitmap) + advanceTimeBy(1.minutes) + assertThat(results.map(ImageBitmap?::getPixels)).containsExactly( + null, + localFileAlbumArt.getPixels(), + null, + defaultAudioImage.getPixels() + ) + } + + @Test + fun `When source is local containing album art and it is set to a local video file, display image should update to video frame`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val mockMediaPlayer = spyk() + val fakeMediaImage = decodeImage(getResourceAsPath("test.png")) + every { mockMediaPlayer.video } returns flowOf(fakeMediaImage) + val localFile = getResourceAsPath("test.mp3") + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = mockMediaPlayer + ).apply { + setSourceToLocal(localFile) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.displayImage.toList(results) } + viewModel.setSourceToLocal(getResourceAsPath("test.ts")) + val localFileAlbumArt = localFile + .toFile() + .runCatching(AudioFileIO::read) + .getOrNull() + ?.tag + ?.firstArtwork + ?.binaryData + ?.inputStream() + ?.use(::loadImageBitmap) + advanceTimeBy(1.minutes) + assertThat(results.map(ImageBitmap?::getPixels)).containsExactly( + null, + localFileAlbumArt.getPixels(), + null, + fakeMediaImage.getPixels() + ) + } + + @Test + fun `When source is set to a local static image, display image should update to the image`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val mockMediaPlayer = spyk() + val fakeMediaImage = decodeImage(getResourceAsPath("test-wide.png")) + every { mockMediaPlayer.video } returns flowOf(fakeMediaImage) + val localFile = getResourceAsPath("test.png") + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = mockMediaPlayer + ).apply { + setSourceToLocal(Path("test.ts")) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.displayImage.toList(results) } + viewModel.setSourceToLocal(localFile) + advanceTimeBy(1.minutes) + assertThat(results.map(ImageBitmap?::getPixels)).containsExactly( + null, + fakeMediaImage.getPixels(), + null, + decodeImage(localFile).getPixels() + ) + } + + @Test + fun `When source is set to a local GIF image, display image should update to the GIF frames`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val mockMediaPlayer = spyk() + val fakeMediaImage = decodeImage(getResourceAsPath("test.png")) + every { mockMediaPlayer.video } returns flowOf(fakeMediaImage) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = mockMediaPlayer + ).apply { + setSourceToLocal(Path("test.ts")) + } + val localFile = getResourceAsPath("test.gif") + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.displayImage.toList(results) } + viewModel.setSourceToLocal(localFile) + advanceTimeBy(1.minutes) + assertThat(results.map(ImageBitmap?::getPixels)).containsExactly( + null, + fakeMediaImage.getPixels(), + null, + fakeMediaImage.getPixels() + ) + } + + @Test + fun `When the current media progress is greater than 60 minutes, setting clip start to now should update clip start minute input to proper value`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val mockMediaPlayer = spyk() + every { mockMediaPlayer.progress } returns flowOf(Progress(0.97f, 65.minutes)) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = mockMediaPlayer + ).apply { + startMediaProgressListener() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.clipStartMinuteInput.toList(results) } + viewModel.onSetClipStartToNow() + assertThat(results.last()).isEqualTo(TextFieldValue(defaultLanguage.localizeDigits("63"))) + } + + @Test + fun `When the current media progress is greater than 60 minutes, setting clip end to now should update clip end minute input to proper value`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val mockMediaPlayer = spyk() + every { mockMediaPlayer.progress } returns flowOf(Progress(0.97f, 65.minutes)) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = mockMediaPlayer + ).apply { + startMediaProgressListener() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.clipEndMinuteInput.toList(results) } + viewModel.onSetClipEndToNow() + assertThat(results.last()).isEqualTo(TextFieldValue(defaultLanguage.localizeDigits("63"))) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/QualityTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/QualityTest.kt new file mode 100644 index 0000000..631bfa5 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/QualityTest.kt @@ -0,0 +1,36 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.defaultQuality +import ir.mahozad.cutcon.model.Quality +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class QualityTest { + @Test + fun `Quality should initially be the default`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.quality.first() + assertThat(result).isEqualTo(defaultQuality) + } + + @Test + fun `When setting quality to new value, it should update to that`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setQuality(Quality.HIGH.value.toFloat()) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.quality.toList(results) } + viewModel.setQuality(Quality.LOWEST.value.toFloat()) + assertThat(results).containsExactly(Quality.HIGH, Quality.LOWEST) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/SaveFileTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/SaveFileTest.kt new file mode 100644 index 0000000..218018f --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/SaveFileTest.kt @@ -0,0 +1,142 @@ +package ir.mahozad.cutcon.viewmodel + +import io.mockk.coEvery +import io.mockk.spyk +import ir.mahozad.cutcon.component.UrlMaker +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.converter.Converter +import ir.mahozad.cutcon.converter.ConverterFactory +import ir.mahozad.cutcon.converter.FFmpegOption +import ir.mahozad.cutcon.model.* +import ir.mahozad.cutcon.viewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.net.URL +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.div +import kotlin.io.path.name + +@OptIn(ExperimentalCoroutinesApi::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class SaveFileTest { + @Test + fun `saveFile should initially be null`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.saveFile.first() + assertThat(result).isEqualTo(null) + } + + /** + * Otherwise, when trying to create a new clip, it will overwrite the previous one because the save file is still the same. + */ + @Test + fun `When the conversion process is finished, saveFile should reset to null so the user will be forced to select a save file again`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } answers { + println("I'm a fake converter and I'm pretending to convert...") + } + val converterFactory = ConverterFactory { converter } + val urlMaker = UrlMaker { URL("file://") } + val saveFile = Path("xyz") / "1.mp4" + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + converterFactory = converterFactory + ).apply { + setSaveFile(saveFile) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.saveFile.toList(results) } + viewModel.startProcess() + assertThat(results).containsExactly(saveFile, null) + } + + @ParameterizedTest + @MethodSource("generateFileNamesAndExpectedResults") + fun `Changing save file should reflect in saveFile flow`( + argument: Pair + ) = runTest { + val (userFileNameInput, expectedResultFileName) = argument + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setFormat(Format.MP4) + } + val saveFile = Path("xyz") / userFileNameInput + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.saveFile.toList(results) } + viewModel.setSaveFile(saveFile) + assertThat(results.map { it?.name }).containsExactly(null, expectedResultFileName) + } + + private fun generateFileNamesAndExpectedResults() = listOf( + "1" to "1.mp4", + "mp4" to "mp4.mp4", + ".mp4" to ".mp4", + "1.mp4" to "1.mp4", + "1.MP4" to "1.mp4", + "1.jpg" to "1.jpg.mp4", + "1.abc.mp4" to "1.abc.mp4", + "1.mp4.abc" to "1.mp4.abc.mp4" + ) + + @Test + fun `When save file is specified, changing the format should update the save file extension`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val saveFile = Path("xyz") / "1.mp4" + val viewModel = constructMainViewModel(dispatcher).apply { + setFormat(Format.MP4) + setSaveFile(saveFile) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.saveFile.toList(results) } + viewModel.setFormat(Format.MP3) + assertThat(results).containsExactly(Path("xyz") / "1.mp4", Path("xyz") / "1.mp3") + } + + @Test + fun `When save file is specified and the format is raw, changing the local source file should update the save file extension`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setFormat(Format.RAW) + setSourceToLocal(Path("a/b/c/d.mp4")) + setSaveFile(Path("xyz") / "1.mp4") + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.saveFile.toList(results) } + viewModel.setSourceToLocal(Path("a/b/c/d.qwe")) + assertThat(results).containsExactly(Path("xyz") / "1.mp4", Path("xyz") / "1.qwe") + } + + @Test + fun `When save file is not specified, changing the format should not update the save file`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + viewModel.setFormat(Format.MP4) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.saveFile.toList(results) } + viewModel.setFormat(Format.MP3) + assertThat(results).containsExactly(null) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/SourceTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/SourceTest.kt new file mode 100644 index 0000000..348a59b --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/SourceTest.kt @@ -0,0 +1,35 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.defaultSource +import ir.mahozad.cutcon.model.Source +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import kotlin.io.path.Path + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class SourceTest { + @Test + fun `Initially, the source should be default`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.source.first() + assertThat(result).isEqualTo(defaultSource) + } + + @Test + fun `When source is changed, it should update to the new one`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.source.toList(results) } + viewModel.setSourceToLocal(Path("a/b/c.mp4")) + assertThat(results).containsExactly(defaultSource, Source.Local(Path("a/b/c.mp4"))) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/StatusTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/StatusTest.kt new file mode 100644 index 0000000..7d28390 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/StatusTest.kt @@ -0,0 +1,456 @@ +package ir.mahozad.cutcon.viewmodel + +import androidx.compose.ui.text.TextRange +import io.mockk.coEvery +import io.mockk.every +import io.mockk.spyk +import ir.mahozad.cutcon.* +import ir.mahozad.cutcon.component.MediaPlayer +import ir.mahozad.cutcon.component.UrlMaker +import ir.mahozad.cutcon.converter.Converter +import ir.mahozad.cutcon.converter.ConverterFactory +import ir.mahozad.cutcon.converter.FFmpegOption +import ir.mahozad.cutcon.converter.ProgressListener +import ir.mahozad.cutcon.model.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import java.net.URL +import kotlin.coroutines.cancellation.CancellationException +import kotlin.io.path.Path +import kotlin.io.path.div +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class StatusTest { + @Test + fun `Initially, status should be 'clip not specified' error`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setSourceToLocal(getResourceAsPath("test.ts")) + } + val result = viewModel.status.first() + assertThat(result).isEqualTo(Status.Error.ClipNotSet) + } + + @Test + fun `When source is set to an image, status should be 'image input does not support creating clip' error`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("2", TextRange.Zero) + setSourceToLocal(getResourceAsPath("test.ts")) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + viewModel.setSourceToLocal(getResourceAsPath("test.png")) + assertThat(results).containsExactly( + Status.Error.FileNotSet, + Status.Error.ClipFromImageNotSupported + ) + } + + @Test + fun `When source is set to an unsupported format, status should be 'input format does not support creating clip' error`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("2", TextRange.Zero) + setSourceToLocal(getResourceAsPath("test.ts")) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + viewModel.setSourceToLocal(getResourceAsPath("test-1.md")) + assertThat(results).containsExactly( + Status.Error.FileNotSet, + Status.Error.ClipFromFormatNotSupported + ) + } + + @Test + fun `When clip is set to negative duration, status should be 'clip duration negative' error`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + setSourceToLocal(getResourceAsPath("test.ts")) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + viewModel.onClipStartSecondChanged("2", TextRange.Zero) + assertThat(results).containsExactly( + Status.Error.ClipNotSet, + Status.Error.ClipLengthNegative + ) + } + + @Test + fun `When clip is set to zero duration, status should be 'clip duration zero' error`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + setSourceToLocal(getResourceAsPath("test.ts")) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + viewModel.onClipStartSecondChanged("2", TextRange.Zero) + viewModel.onClipEndSecondChanged("2", TextRange.Zero) + assertThat(results).containsExactly( + Status.Error.ClipNotSet, + Status.Error.ClipLengthNegative, + Status.Error.ClipLengthZero + ) + } + + @Test + fun `When clip start is larger than media length, status should be 'clip start after media end' error`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val mockMediaPlayer = spyk() + every { mockMediaPlayer.progress } returns flowOf(Progress(0.5f, 100.seconds)) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = mockMediaPlayer + ).apply { + startMediaProgressListener() + setSourceToLocal(getResourceAsPath("test.ts")) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + viewModel.onClipStartMinuteChanged("8", TextRange.Zero) + viewModel.onClipEndMinuteChanged("9", TextRange.Zero) + assertThat(results.first()).isEqualTo(Status.Error.ClipNotSet) + assertThat(results.last()).isEqualTo(Status.Error.ClipStartAfterMediaEnd) + } + + /** + * This happens when the media is changed + */ + @Disabled("Because when setting clip end to after media end it is coerced to media end, cannot get this test to work") + @Test + fun `When media length is zero and clip start is larger than media length, status error should be 'save file not set' rather than 'clip start after media end'`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val mockMediaPlayer = spyk() + every { mockMediaPlayer.progress } returns flowOf(Progress.ZERO) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = mockMediaPlayer + ).apply { + startMediaProgressListener() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + viewModel.onClipStartMinuteChanged("8", TextRange.Zero) + viewModel.onClipEndMinuteChanged("9", TextRange.Zero) + assertThat(results).containsExactly( + Status.Error.ClipNotSet, + Status.Error.ClipLengthNegative, + Status.Error.FileNotSet + ) + } + + @Test + fun `When clip is valid and save file is null, status should be 'save file not set' error`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + setSourceToLocal(getResourceAsPath("test.ts")) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + viewModel.onClipEndSecondChanged("1", TextRange.Zero) + assertThat(results).containsExactly( + Status.Error.ClipNotSet, + Status.Error.FileNotSet + ) + } + + @Test + fun `When clip is valid and save file is set, status should be ready`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + mediaPlayer = FakeMediaPlayer(100.seconds) + ).apply { + startMediaProgressListener() + setSourceToLocal(getResourceAsPath("test.ts")) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + viewModel.onClipEndSecondChanged("1", TextRange.Zero) + viewModel.setSaveFile(Path("a/1.mp4")) + assertThat(results).containsExactly( + Status.Error.ClipNotSet, + Status.Error.FileNotSet, + Status.Ready + ) + } + + @Test + fun `When clip is valid and save file is set and conversion process is started, status should be in progress`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + println("I'm a fake converter and I'm running an infinite loop pretending to convert...") + while (isActive) { + delay(15.milliseconds) + println("Fake conversion in work...") + arg(7).onProgressUpdate(0f) + } + } + val converterFactory = ConverterFactory { converter } + val urlMaker = UrlMaker { URL("file://") } + val saveFile = Path("xyz") / "1.mp4" + val sourcePath = getResourceAsPath("test.ts") + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + mediaPlayer = FakeMediaPlayer(100.seconds), + converterFactory = converterFactory + ).apply { + startMediaProgressListener() + setSourceToLocal(sourcePath) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + viewModel.onClipEndSecondChanged("1", TextRange.Zero) + viewModel.setSaveFile(saveFile) + viewModel.startProcess() + delay(50.milliseconds) + assertThat(results).containsExactly( + Status.Error.ClipNotSet, + Status.Error.FileNotSet, + Status.Ready, + Status.InProgress(source = Source.Local(sourcePath), progress = 0f) + ) + } + + @Test + fun `When the conversion process is canceled, status should update to proper value`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + println("I'm a fake converter and I'm running an infinite loop pretending to convert...") + println("Fake conversion in work...") + delay(1.seconds) + println("Simulating being canceled by throwing CancellationException") + throw CancellationException() + } + val converterFactory = ConverterFactory { converter } + val urlMaker = UrlMaker { getResourceAsURL("test.ts") } + val saveFile = Path("xyz") / "1.mp4" + val sourcePath = getResourceAsPath("test.ts") + val results = mutableListOf() + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + mediaPlayer = FakeMediaPlayer(100.seconds), + converterFactory = converterFactory + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("14", TextRange.Zero) + setFormat(Format.MP4) + setSaveFile(saveFile) + setSourceToLocal(sourcePath) + } + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + viewModel.startProcess() + delay(5.seconds) + assertThat(results).containsExactly( + Status.Ready, + Status.InProgress(source = Source.Local(sourcePath), progress = 0f), + Status.Ready, + Status.Error.FileNotSet + ) + } + + @Test + fun `When the conversion process succeeds, status should change to finish with success`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } answers { + println("I'm a fake converter and I'm pretending to convert...") + } + val converterFactory = ConverterFactory { converter } + val urlMaker = UrlMaker { URL("file://") } + val saveFile = Path("xyz") / "1.mp4" + val results = mutableListOf() + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + mediaPlayer = FakeMediaPlayer(100.seconds), + converterFactory = converterFactory + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("14", TextRange.Zero) + setFormat(Format.MP4) + setSaveFile(saveFile) + setSourceToLocal(getResourceAsPath("test.ts")) + } + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + viewModel.startProcess() + assertThat(results.map { it::class }).containsExactly( + Status.Ready::class, + Status.Finished.Success::class + ) + } + + @Test + fun `When the conversion process is run multiple times with success, the time in success state should be a proper value with its start time reset every time`() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + println("I'm a fake converter and I'm pretending to convert...") + Thread.sleep(15) + } + val converterFactory = ConverterFactory { converter } + val urlMaker = UrlMaker { URL("file://") } + val saveFile = Path("xyz") / "1.mp4" + val results = mutableListOf() + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + mediaPlayer = FakeMediaPlayer(100.seconds), + converterFactory = converterFactory + ).apply { + setSourceToLocal(getResourceAsPath("test.ts")) + startMediaProgressListener() + onClipEndSecondChanged("14", TextRange.Zero) + setFormat(Format.MP4) + } + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + for (i in 1..5) { + viewModel.setSaveFile(saveFile) + viewModel.startProcess() + } + assertThat((results.reversed()[1] as Status.Finished.Success).totalTime) + .isBetween(10.milliseconds, 50.milliseconds) + } + + @Test + fun `When the conversion process fails, status should change to finish with failure`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } throws Exception() + val converterFactory = ConverterFactory { converter } + val urlMaker = UrlMaker { URL("file://") } + val saveFile = Path("xyz") / "1.mp4" + val results = mutableListOf() + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + mediaPlayer = FakeMediaPlayer(100.seconds), + converterFactory = converterFactory + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("14", TextRange.Zero) + setFormat(Format.MP4) + setSaveFile(saveFile) + setSourceToLocal(getResourceAsPath("test.ts")) + } + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + viewModel.startProcess() + assertThat(results.map { it::class }).containsExactly( + Status.Ready::class, + Status.Finished.Failure::class + ) + } + + @Test + fun `After dismissing finish dialog, status should change to proper value`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val converter = spyk(object : Converter(dispatcher) { + override fun ffmpegOptions( + quality: Quality, + introOptions: IntroOptions, + coverOptions: CoverOptions, + flags: ConverterFlags + ) = emptyList() + }) + coEvery { converter.convert(any(), any(), any(), any(), any(), any(), any(), any()) } answers { + println("I'm a fake converter and I'm pretending to convert...") + } + val converterFactory = ConverterFactory { converter } + val urlMaker = UrlMaker { URL("file://") } + val saveFile = Path("xyz") / "1.mp4" + val results = mutableListOf() + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + urlMaker = urlMaker, + mediaPlayer = FakeMediaPlayer(100.seconds), + converterFactory = converterFactory + ).apply { + startMediaProgressListener() + onClipEndSecondChanged("14", TextRange.Zero) + setSourceToLocal(getResourceAsPath("test.ts")) + setFormat(Format.MP4) + setSaveFile(saveFile) + startProcess() + } + backgroundScope.launch(dispatcher) { viewModel.status.toList(results) } + viewModel.onFinishDialogDismissRequest() + assertThat(results.map { it::class }).containsExactly( + Status.Finished.Success::class, + Status.Error.FileNotSet::class + ) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/ThemeTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/ThemeTest.kt new file mode 100644 index 0000000..e07ef24 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/ThemeTest.kt @@ -0,0 +1,73 @@ +package ir.mahozad.cutcon.viewmodel + +import ir.mahozad.cutcon.BuildConfig +import ir.mahozad.cutcon.constructMainViewModel +import ir.mahozad.cutcon.defaultTheme +import ir.mahozad.cutcon.model.PreferenceKeys +import ir.mahozad.cutcon.model.Theme +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.prefs.Preferences + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class ThemeTest { + + private val settings = Preferences + .userRoot() + .node("/${BuildConfig.APP_NAME}/test")!! + + @BeforeEach + fun setUp() { + settings.clear() + } + + @Test + fun `For first time app launch, the theme should be default`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val result = viewModel.theme.first() + assertThat(result).isEqualTo(defaultTheme) + } + + @Test + fun `When settings has theme, the theme should be initialized to it`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + settings.put(PreferenceKeys.PREF_THEME, Theme.DARK.name) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.theme.toList(results) } + assertThat(results).containsExactly(Theme.DARK) + } + + @Test + fun `After changing theme, the theme should be updated`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.theme.toList(results) } + viewModel.setTheme(Theme.DARK) + assertThat(results).containsExactly(defaultTheme, Theme.DARK) + } + + @Test + fun `After changing theme, the theme should be persisted`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel( + dispatcher = dispatcher, + settings = settings + ) + viewModel.setTheme(Theme.DARK) + val theme = settings[PreferenceKeys.PREF_THEME, null]?.let(Theme::valueOf) + assertThat(theme).isEqualTo(Theme.DARK) + } +} diff --git a/src/test/kotlin/ir/mahozad/cutcon/viewmodel/WindowPositionTest.kt b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/WindowPositionTest.kt new file mode 100644 index 0000000..4060746 --- /dev/null +++ b/src/test/kotlin/ir/mahozad/cutcon/viewmodel/WindowPositionTest.kt @@ -0,0 +1,167 @@ +package ir.mahozad.cutcon.viewmodel + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import ir.mahozad.cutcon.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.awt.GraphicsEnvironment + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class WindowPositionTest { + @Test + fun `Window position should initially be center`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val screenSize = GraphicsEnvironment.getLocalGraphicsEnvironment().maximumWindowBounds + val viewModel = constructMainViewModel(dispatcher) + val position = viewModel.windowPosition.first() + assertThat(position).isEqualTo( + WindowPosition( + x = ((screenSize.width - WINDOW_WIDTH_WITH_PANEL) / 2).dp, + y = ((screenSize.height - WINDOW_HEIGHT_REGULAR) / 2).dp + ) + ) + } + + @Test + fun `When hiding side panel, window position should stay the same`() = runTest { + val screenSize = GraphicsEnvironment.getLocalGraphicsEnvironment().maximumWindowBounds + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.windowPosition.toList(results) } + viewModel.toggleSidePanel() + assertThat(results).containsExactly( + WindowPosition( + x = ((screenSize.width - WINDOW_WIDTH_WITH_PANEL) / 2).dp, + y = ((screenSize.height - WINDOW_HEIGHT_REGULAR) / 2).dp + ) + ) + } + + @Test + fun `After switching to mini player, window position should update to bottom end`() = runTest { + val screenSize = GraphicsEnvironment.getLocalGraphicsEnvironment().maximumWindowBounds + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.windowPosition.toList(results) } + viewModel.toggleMiniScreen() + // This is set automatically in real app + viewModel.onWindowPositionChanged( + WindowPosition( + x = (screenSize.width - WINDOW_WIDTH_MINI).dp, + y = (screenSize.height - WINDOW_HEIGHT_MINI).dp + ) + ) + assertThat(results).containsExactly( + WindowPosition( + x = ((screenSize.width - WINDOW_WIDTH_WITH_PANEL) / 2).dp, + y = ((screenSize.height - WINDOW_HEIGHT_REGULAR) / 2).dp + ), + WindowPosition( + x = (screenSize.width - WINDOW_WIDTH_MINI).dp, + y = (screenSize.height - WINDOW_HEIGHT_MINI).dp + ) + ) + } + + @Test + fun `After restoring from mini player and app side panel was shown before entering mini, window position should update to center`() = + runTest { + val screenSize = GraphicsEnvironment.getLocalGraphicsEnvironment().maximumWindowBounds + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + toggleMiniScreen() + // This is set automatically in real app + onWindowPositionChanged( + WindowPosition( + x = (screenSize.width - WINDOW_WIDTH_MINI).dp, + y = (screenSize.height - WINDOW_HEIGHT_MINI).dp + ) + ) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.windowPosition.toList(results) } + viewModel.toggleMiniScreen() + assertThat(results).containsExactly( + WindowPosition( + x = (screenSize.width - WINDOW_WIDTH_MINI).dp, + y = (screenSize.height - WINDOW_HEIGHT_MINI).dp + ), + WindowPosition( + x = ((screenSize.width - WINDOW_WIDTH_WITH_PANEL) / 2).dp, + y = ((screenSize.height - WINDOW_HEIGHT_REGULAR) / 2).dp + ) + ) + } + + @Test + fun `After restoring from mini player and app side panel was hidden before entering mni, window position should update to center`() = + runTest { + val screenSize = GraphicsEnvironment.getLocalGraphicsEnvironment().maximumWindowBounds + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + toggleSidePanel() + toggleMiniScreen() + // This is set automatically in real app + onWindowPositionChanged( + WindowPosition( + x = (screenSize.width - WINDOW_WIDTH_MINI).dp, + y = (screenSize.height - WINDOW_HEIGHT_MINI).dp + ) + ) + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.windowPosition.toList(results) } + viewModel.toggleMiniScreen() + assertThat(results).containsExactly( + WindowPosition( + x = (screenSize.width - WINDOW_WIDTH_MINI).dp, + y = (screenSize.height - WINDOW_HEIGHT_MINI).dp + ), + WindowPosition( + x = ((screenSize.width - WINDOW_WIDTH_NO_PANEL) / 2).dp, + y = ((screenSize.height - WINDOW_HEIGHT_REGULAR) / 2).dp + ) + ) + } + + @Test + fun `After dragging window and then hiding side panel, window position should stay where it is`() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher).apply { + onWindowPositionChanged(WindowPosition(x = (100).dp, y = (222).dp)) + toggleSidePanel() + } + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.windowPosition.toList(results) } + assertThat(results).containsExactly(WindowPosition(x = (100).dp, y = (222).dp)) + } + + @Test + fun `After dragging window and then hiding side panel and then showing side panel, window position should stay where it is`() = + runTest { + val screenSize = GraphicsEnvironment.getLocalGraphicsEnvironment().maximumWindowBounds + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val viewModel = constructMainViewModel(dispatcher) + val results = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.windowPosition.toList(results) } + viewModel.onWindowPositionChanged(WindowPosition(x = (100).dp, y = (222).dp)) + viewModel.toggleSidePanel() + viewModel.toggleSidePanel() + assertThat(results).containsExactly( + WindowPosition( + x = ((screenSize.width - WINDOW_WIDTH_WITH_PANEL) / 2).dp, + y = ((screenSize.height - WINDOW_HEIGHT_REGULAR) / 2).dp + ), + WindowPosition(x = (100).dp, y = (222).dp) + ) + } +} diff --git a/src/test/resources/README.md b/src/test/resources/README.md new file mode 100644 index 0000000..c74bf11 --- /dev/null +++ b/src/test/resources/README.md @@ -0,0 +1,41 @@ +The *test.mp4* file was downloaded from https://www.pexels.com/video/close-up-video-of-a-kiwi-5857693/ +(the audio is the same as *test.mp3* added to the video) + +Licence: Free + +All photos and videos on Pexels can be downloaded and used for free. +What is allowed? 👌 +✓ All photos and videos on Pexels are free to use. +✓ Attribution is not required. Giving credit to the photographer or Pexels is not necessary but always appreciated. +✓ You can modify the photos and videos from Pexels. Be creative and edit them as you like. +What is not allowed? 👎 +✕ Identifiable people may not appear in a bad light or in a way that is offensive. +✕ Don't sell unaltered copies of a photo or video, e.g. as a poster, print or on a physical product without modifying it first. +✕ Don't imply endorsement of your product by people or brands on the imagery. +✕ Don't redistribute or sell the photos and videos on other stock photo or wallpaper platforms. +✕ Don't use the photos or videos as part of your trade-mark, design-mark, trade-name, business name or service mark. + + +--- + + +The *test.mp3* file was downloaded from https://pixabay.com/music/build-up-scenes-science-documentary-169621/ + +Licence: Free + +Content License Summary +Welcome to Pixabay! Pixabay is a vibrant community of authors, artists and creators sharing royalty-free images, video, audio and other media. We refer to this collectively as "Content". By accessing and using Content, or by contributing Content, you agree to comply with our Content License. + +At Pixabay, we like to keep things as simple as possible. For this reason, we have created this short summary of our Content License which is available in full here. Please keep in mind that only the full Content License is legally binding. + +What are you allowed to do with Content? +✓ Use Content for free +✓ Use Content without having to attribute the author (although giving credit is always appreciated by our community!) +✓ Modify or adapt Content into new works + +What are you not allowed to do with Content? +✕ You cannot sell or distribute Content (either in digital or physical form) on a Standalone basis. Standalone means where no creative effort has been applied to the Content and it remains in substantially the same form as it exists on our website. +✕ If Content contains any recognisable trademarks, logos or brands, you cannot use that Content for commercial purposes in relation to goods and services. In particular, you cannot print that Content on merchandise or other physical products for sale. +✕ You cannot use Content in any immoral or illegal way, especially Content which features recognisable people. +✕ You cannot use Content in a misleading or deceptive way. +✕ You cannot use any of the Content as part of a trade-mark, design-mark, trade-name, business name or service mark. diff --git a/src/test/resources/reference/1.png b/src/test/resources/reference/1.png new file mode 100644 index 0000000..768f45d Binary files /dev/null and b/src/test/resources/reference/1.png differ diff --git a/src/test/resources/reference/2.png b/src/test/resources/reference/2.png new file mode 100644 index 0000000..73d00f1 Binary files /dev/null and b/src/test/resources/reference/2.png differ diff --git a/src/test/resources/reference/3.png b/src/test/resources/reference/3.png new file mode 100644 index 0000000..d27df8b Binary files /dev/null and b/src/test/resources/reference/3.png differ diff --git a/src/test/resources/reference/4.png b/src/test/resources/reference/4.png new file mode 100644 index 0000000..81eb40a Binary files /dev/null and b/src/test/resources/reference/4.png differ diff --git a/src/test/resources/reference/5.png b/src/test/resources/reference/5.png new file mode 100644 index 0000000..d27df8b Binary files /dev/null and b/src/test/resources/reference/5.png differ diff --git a/src/test/resources/reference/6.png b/src/test/resources/reference/6.png new file mode 100644 index 0000000..6476ce9 Binary files /dev/null and b/src/test/resources/reference/6.png differ diff --git a/src/test/resources/reference/7.png b/src/test/resources/reference/7.png new file mode 100644 index 0000000..db50af3 Binary files /dev/null and b/src/test/resources/reference/7.png differ diff --git a/src/test/resources/reference/8.png b/src/test/resources/reference/8.png new file mode 100644 index 0000000..562a00c Binary files /dev/null and b/src/test/resources/reference/8.png differ diff --git a/src/test/resources/reference/9.png b/src/test/resources/reference/9.png new file mode 100644 index 0000000..80037e2 Binary files /dev/null and b/src/test/resources/reference/9.png differ diff --git a/src/test/resources/test-1.md b/src/test/resources/test-1.md new file mode 100644 index 0000000..6d3f7bb --- /dev/null +++ b/src/test/resources/test-1.md @@ -0,0 +1,54 @@ +# History of notable changes introduced in each version + +## v1.2.0 (2023-06-15) +#### New features + - (En) Add progress/seek bar and live button to mini player ([`76206c93`](https://github.com/mahozad/cutcon/commit/76206c93)) + - (Fa) اضافه شدن نوار پیشرفت و جلو/عقب و دکمه پخش زنده به پخش‌کننده مینی + - (En) Add a new slow speed (0.75) ([`50a640b1`](https://github.com/mahozad/cutcon/commit/50a640b1)) + - (Fa) اضافه شدن یک سرعت جدید (۰٫۷۵) + +#### Bug fixes + - (En) Fix the bug with speed number being reset when toggling side panel or mini player ([`4e906af4`](https://github.com/mahozad/cutcon/commit/4e906af4)) + - (Fa) رفع مشکل ریست شدن عدد ورودی سرعت هنگام فعال یا غیر فعال کردن پخش‌کننده مینی + +#### Updates + - (En) Redesign the speed input ([`4e906af4`](https://github.com/mahozad/cutcon/commit/4e906af4)) + - (Fa) بازطراحی ورودی سرعت + - (En) Update some of the icons (various commits) + - (Fa) بروزرسانی بعضی از آیکون‌ها + - (En) Update the clip creation label ([`82855752`](https://github.com/mahozad/cutcon/commit/82855752)) + - (Fa) بروزرسانی برچسب ایجاد کلیپ + +#### Other + - (En) Update the inputs aesthetics + + (En) Also, change how they handle clicks + - (Fa) بروزرسانی ظاهر ورودی‌ها + + (Fa) همچنین، تغییر نحوه‌ی انجام کلیک بر روی آن‌ها + +## v1.1.0 (2023-06-08) +#### New features + - (En) Add native splash screen ([`b46185ce`](https://github.com/mahozad/cutcon/commit/b46185ce)) + - (Fa) اضافه شدن صفحه اسپلش (تصویر شروع) + - (En) Show success window with a notification sound when the clip creation is done ([`cfdfabdf`](https://github.com/mahozad/cutcon/commit/cfdfabdf)) + - (Fa) نمایش پنجره موفقیت همراه با یک افکت صوتی هنگام تکمیل ایجاد کلیپ + - (En) Add mini player mode ([`c72762cf`](https://github.com/mahozad/cutcon/commit/c72762cf)) + - (Fa) اضافه شدن پخش‌کننده مینی + +#### Bug fixes + - (En) Fix minor bugs (various commits) + - (Fa) رفع برخی باگ‌های جزئی + + (En) bug 1 + + (Fa) باگ ۱ + + (En) bug 2 + + (Fa) باگ ۲ + + (En) bug 3 + + (Fa) باگ ۳ + +#### Removals + - (En) Remove the screenshot button ([`ceb61c97`](https://github.com/mahozad/cutcon/commit/ceb61c97)) + - (Fa) حذف دکمه اسکرین‌شات + +## v1.0.0 (2023-06-01) +#### Announcement + - (En) Release the first version of the app + - (Fa) انتشار نخستین ویرایش پایدار برنامه diff --git a/src/test/resources/test-2.md b/src/test/resources/test-2.md new file mode 100644 index 0000000..f2cc9ef --- /dev/null +++ b/src/test/resources/test-2.md @@ -0,0 +1,46 @@ +# History of notable changes introduced in each version + +## v1.2.5 (2023-06-19) +#### Other + - (@) Add some changes + - (@) Update some TODOs + - (@) Add another change + - (@) Refactor build code + - (@) Rename some methods + +## v1.2.0 (2023-06-15) +#### New features + - (En) Add progress/seek bar and live button to mini player ([`76206c93`](https://github.com/mahozad/cutcon/commit/76206c93)) + - (Fa) اضافه شدن نوار پیشرفت و جلو/عقب و دکمه پخش زنده به پخش‌کننده مینی + - (En) Add a new slow speed (0.75) ([`50a640b1`](https://github.com/mahozad/cutcon/commit/50a640b1)) + - (Fa) اضافه شدن یک سرعت جدید (۰٫۷۵) + +#### Other + - (En) Update the inputs aesthetics + + (En) Also, change how they handle clicks + - (Fa) بروزرسانی ظاهر ورودی‌ها + + (Fa) همچنین، تغییر نحوه‌ی انجام کلیک بر روی آن‌ها + +## v1.1.0 (2023-06-08) +#### New features + - (En) Add native splash screen ([`b46185ce`](https://github.com/mahozad/cutcon/commit/b46185ce)) + - (Fa) اضافه شدن صفحه اسپلش (تصویر شروع) + +#### Bug fixes + - (En) Fix minor bugs (various commits) + - (Fa) رفع برخی باگ‌های جزئی + + (En) bug 1 + + (Fa) باگ ۱ + + (En) bug 2 + + (Fa) باگ ۲ + + (En) bug 3 + + (Fa) باگ ۳ + +#### Removals + - (En) Remove the screenshot button ([`ceb61c97`](https://github.com/mahozad/cutcon/commit/ceb61c97)) + - (Fa) حذف دکمه اسکرین‌شات + +## v1.0.0 (2023-06-01) +#### Announcement + - (En) Release the first version of the app + - (Fa) انتشار نخستین ویرایش پایدار برنامه diff --git a/src/test/resources/test-no-cover.mp3 b/src/test/resources/test-no-cover.mp3 new file mode 100644 index 0000000..00672d8 Binary files /dev/null and b/src/test/resources/test-no-cover.mp3 differ diff --git a/src/test/resources/test-no-video.ts b/src/test/resources/test-no-video.ts new file mode 100644 index 0000000..fa7742a Binary files /dev/null and b/src/test/resources/test-no-video.ts differ diff --git a/src/test/resources/test-wide.png b/src/test/resources/test-wide.png new file mode 100644 index 0000000..62d8de2 Binary files /dev/null and b/src/test/resources/test-wide.png differ diff --git a/src/test/resources/test.gif b/src/test/resources/test.gif new file mode 100644 index 0000000..8f1e178 Binary files /dev/null and b/src/test/resources/test.gif differ diff --git a/src/test/resources/test.mp3 b/src/test/resources/test.mp3 new file mode 100644 index 0000000..c3d99e0 Binary files /dev/null and b/src/test/resources/test.mp3 differ diff --git a/src/test/resources/test.png b/src/test/resources/test.png new file mode 100644 index 0000000..36b34ca Binary files /dev/null and b/src/test/resources/test.png differ diff --git a/src/test/resources/test.svg b/src/test/resources/test.svg new file mode 100644 index 0000000..f5c9887 --- /dev/null +++ b/src/test/resources/test.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/test/resources/test.ts b/src/test/resources/test.ts new file mode 100644 index 0000000..2295148 Binary files /dev/null and b/src/test/resources/test.ts differ diff --git a/src/uiTest/kotlin/ir/mahozad/cutcon/ui/UiTest.kt b/src/uiTest/kotlin/ir/mahozad/cutcon/ui/UiTest.kt new file mode 100644 index 0000000..0a905eb --- /dev/null +++ b/src/uiTest/kotlin/ir/mahozad/cutcon/ui/UiTest.kt @@ -0,0 +1,168 @@ +package ir.mahozad.cutcon.ui + +import androidx.compose.animation.core.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.asSkiaBitmap +import androidx.compose.ui.graphics.toAwtImage +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.* +import ir.mahozad.cutcon.defaultLanguage +import ir.mahozad.cutcon.ui.panel.SidePanel +import kotlinx.coroutines.test.runTest +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Image +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import javax.imageio.ImageIO +import kotlin.io.path.Path +import kotlin.io.path.outputStream +import kotlin.io.path.readBytes +import kotlin.io.path.writeBytes + +/** + * See https://github.com/JetBrains/compose-multiplatform/issues/2520 + */ +class UiTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Ignore + @Test + fun exampleTest1() { + composeTestRule.setContent { + Text(text = "test") + } + composeTestRule + .onNodeWithText(text = "test") + .performClick() + .assertIsDisplayed() + } + + @Ignore + @Test + fun exampleTest2() = runTest { + // To test Composable components that need Window or [Frame]WindowScope, + // we may think we can do this: + // composeTestRule.setContent { + // Window({}) { + // TheComposable() + // } + // } + // But it does not work because Window cannot be seen through by the test rule. + // print the hierarchy with println(composeTestRule.onRoot().printToString()) to see. + // See https://github.com/JetBrains/compose-multiplatform/issues/2107 + val fakeWindowScope = object : FrameWindowScope { + override val window get() = ComposeWindow() + } + with(fakeWindowScope) { + composeTestRule.setContent { + SidePanel() + } + } + + composeTestRule.awaitIdle() + + println(composeTestRule.onRoot().printToString()) + + composeTestRule + .onNodeWithText(defaultLanguage.messages.btnLblStartConversion) + .assertIsDisplayed() + } + + /** + * See https://stackoverflow.com/a/76817459 + */ + @Ignore + @Test + @OptIn(ExperimentalTestApi::class) + fun takeExampleScreenshot() = runDesktopComposeUiTest(width = 400, height = 50) { + setContent { + Surface { + Text(text = "test") + } + } + + val referencePath = Path("reference.png") + val screenshot = Image.makeFromBitmap(captureToImage().asSkiaBitmap()) + val actualPath = Path("screenshot.png") + val actualData = screenshot.encodeToData(EncodedImageFormat.PNG) ?: error("Could not encode image as png") + actualPath.writeBytes(actualData.bytes) + + assert(actualPath.readBytes().contentEquals(referencePath.readBytes())) { + "The screenshot '$actualPath' does not match the reference '$referencePath'" + } + } + + @Ignore + @Test + fun takeAnotherExampleScreenshot() { + composeTestRule.setContent { + Surface { + Text(text = "test") + } + } + val image = composeTestRule.onRoot().captureToImage() + ImageIO.write(image.toAwtImage(), "PNG", Path("output.png").outputStream()) + } + + /** + * FFmpeg 5.1 gpl + * ffmpeg.exe -f gdigrab -framerate 33.3333333 -i title="SplashScreenCreator" -plays 0 -y out.apng + * ffmpeg.exe -ss 6s -to 9s -i out.apng -plays 0 out-trimmed.apng + * apngtogif converter 1.8 with threshold 64 to create the gif + * optimize the GIF with https://ezgif.com/optimize + */ + @Test + fun createSplashScreen() { + application(exitProcessOnExit = false) { + Window( + title = "SplashScreenCreator", + undecorated = true, + transparent = true, + resizable = false, + state = rememberWindowState( + size = DpSize.Unspecified, + position = WindowPosition(Alignment.Center) + ), + onCloseRequest = ::exitApplication + ) { + val rotation by rememberInfiniteTransition() + .animateFloat( + initialValue = 0f, + targetValue = 30f, + animationSpec = infiniteRepeatable( + tween( + durationMillis = 3000, + easing = LinearEasing + ) + ) + ) + Box(modifier = Modifier.size(180.dp)) { + Image( + painter = painterResource("logo-background.svg"), + contentDescription = null, + modifier = Modifier.rotate(rotation) + ) + Image( + painter = painterResource("logo.svg"), + contentDescription = null + ) + } + } + } + } +} diff --git a/src/uiTest/resources/logo-background.svg b/src/uiTest/resources/logo-background.svg new file mode 100644 index 0000000..8136ca7 --- /dev/null +++ b/src/uiTest/resources/logo-background.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/uiTest/resources/logo.svg b/src/uiTest/resources/logo.svg new file mode 100644 index 0000000..018355e --- /dev/null +++ b/src/uiTest/resources/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + +