diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 69b6e9e88..f9337f1c8 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -17,18 +17,9 @@ jobs: - uses: actions/checkout@v3 with: submodules: recursive - - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - - - name: Set up Gradle - uses: gradle/gradle-build-action@v3 - - - name: Grant execute permission for gradlew - run: chmod +x gradlew + - uses: cachix/install-nix-action@v27 + - uses: DeterminateSystems/magic-nix-cache-action@main + - uses: nicknovitski/nix-develop@v1 - name: Build debug APK run: ./gradlew androidApp:packageDebug @@ -54,7 +45,7 @@ jobs: uses: discord-actions/message@v2 env: BUILD_NOTIFICATION_DISCORD_WEBHOOK: ${{ secrets.BUILD_NOTIFICATION_DISCORD_WEBHOOK }} - if: env.BUILD_NOTIFICATION_DISCORD_WEBHOOK != null + if: env.BUILD_NOTIFICATION_DISCORD_WEBHOOK != null && github.event_name != 'pull_request' with: webhookUrl: ${{ secrets.BUILD_NOTIFICATION_DISCORD_WEBHOOK }} message: "${{ github.workflow }} [build](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) completed for commit - [${{ env.COMMIT_SHA }}](<${{ github.event.head_commit.url }}>) ${{ github.event.head_commit.message }} - [Downloads](https://nightly.link/${{ github.repository }}/actions/runs/${{ github.run_id }})" diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 2d9fa3db8..f94167ed3 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -18,18 +18,17 @@ jobs: with: submodules: recursive - - name: Set up JDK 17 + - name: Set up JDKs uses: actions/setup-java@v3 with: - java-version: '17' distribution: 'temurin' + java-version: | + 22 + 21 - name: Set up Gradle uses: gradle/gradle-build-action@v3 - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - name: Install desktop-file-utils, appstream uses: awalsh128/cache-apt-pkgs-action@latest with: diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 85a70bb19..f89e3542e 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -18,11 +18,13 @@ jobs: with: submodules: recursive - - name: Set up JDK 17 + - name: Set up JDKs uses: actions/setup-java@v3 with: - java-version: '17' distribution: 'temurin' + java-version: | + 22 + 21 - name: Set up Gradle uses: gradle/gradle-build-action@v3 diff --git a/.gitmodules b/.gitmodules index 71543a018..e69de29bb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "spmp-server"] - path = spmp-server - url = https://github.com/toasterofbread/spmp-server diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index e827f7a50..b150b87a5 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -79,7 +79,7 @@ android { versionName = getString("version_string") applicationId = "com.toasterofbread.spmp" - minSdk = 23 + minSdk = (findProperty("android.minSdk") as String).toInt() targetSdk = (findProperty("android.targetSdk") as String).toInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -122,8 +122,8 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_22 + targetCompatibility = JavaVersion.VERSION_22 isCoreLibraryDesugaringEnabled = true } diff --git a/androidApp/proguard-rules.pro b/androidApp/proguard-rules.pro index 7ab68ed4a..4e7d9906d 100644 --- a/androidApp/proguard-rules.pro +++ b/androidApp/proguard-rules.pro @@ -39,6 +39,9 @@ -dontwarn org.jaudiotagger.** -keep class org.jaudiotagger.** { *; } +# Ktor +-dontwarn io.ktor.** + # From proguard-android-optimize.txt -optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/* diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index fa9388c43..bf8175b66 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -58,7 +58,17 @@ + + + + + + + diff --git a/androidApp/src/main/java/com/toasterofbread/spmp/MainActivity.kt b/androidApp/src/main/java/com/toasterofbread/spmp/MainActivity.kt index fb427a45b..ee319ebc5 100644 --- a/androidApp/src/main/java/com/toasterofbread/spmp/MainActivity.kt +++ b/androidApp/src/main/java/com/toasterofbread/spmp/MainActivity.kt @@ -86,17 +86,19 @@ class MainActivity: ComponentActivity() { if (intent.action == Intent.ACTION_VIEW) intent.data else null + val launch_arguments: ProgramArguments = ProgramArguments() + setContent { val player_coroutine_scope: CoroutineScope = rememberCoroutineScope() var player_initialised: Boolean by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - SpMp.initPlayer(player_coroutine_scope) + SpMp.initPlayer(launch_arguments, player_coroutine_scope) player_initialised = true } if (player_initialised) { - SpMp.App(ProgramArguments(), shortcut_state, open_uri = open_uri?.toString()) + SpMp.App(launch_arguments, shortcut_state, open_uri = open_uri?.toString()) } } } diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml deleted file mode 100644 index 55344e519..000000000 --- a/androidApp/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 2d79858a2..6dd7d98d6 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -9,3 +9,7 @@ repositories { dependencies { implementation("com.github.gmazzo.buildconfig:plugin:5.3.5") } + +tasks.withType(JavaCompile::class) { + options.release.set(21) +} diff --git a/buildSrc/src/main/kotlin/plugins/spmp/Dependencies.kt b/buildSrc/src/main/kotlin/plugins/spmp/Dependencies.kt index 42af7dc02..ed975e0a3 100644 --- a/buildSrc/src/main/kotlin/plugins/spmp/Dependencies.kt +++ b/buildSrc/src/main/kotlin/plugins/spmp/Dependencies.kt @@ -23,6 +23,14 @@ class SpMpDeps(extra: Map) { val dependencies: Map = mapOf( + "dev.toastbits:spms" to DependencyInfo( + version = "0.4.0-alpha2", + name = "spmp-server", + author = "toasterofbread", + url = "https://github.com/toasterofbread/spmp-server", + license = "GPL-2.0", + license_url = "https://github.com/toasterofbread/spmp-server/blob/6dde651ffc102d604ac7ecd5ac7471b1572fd2e6/LICENSE" + ), "dev.toastbits.composekit" to DependencyInfo( version = "6788078322", name = "ComposeKit", @@ -32,7 +40,7 @@ class SpMpDeps(extra: Map) { license_url = "https://github.com/toasterofbread/ComposeKit/blob/136f216e65395660255d3270af9b79c90ae2254c/LICENSE" ), "dev.toastbits.ytmkt" to DependencyInfo( - version = "0.2.0", + version = "0.2.2", name = "ytm-kt", author = "toasterofbread", url = "https://github.com/toasterofbread/ytm-kt", @@ -155,7 +163,7 @@ class SpMpDeps(extra: Map) { fork_url = "https://github.com/marcoc1712/jaudiotagger" ), "com.github.teamnewpipe:NewPipeExtractor" to DependencyInfo( - version = "v0.22.7", + version = "v0.24.0", name = "NewPipe Extractor", author = "Team NewPipe", url = "https://github.com/TeamNewPipe/NewPipeExtractor", @@ -163,7 +171,7 @@ class SpMpDeps(extra: Map) { license_url = "https://github.com/TeamNewPipe/NewPipeExtractor/blob/ec3e8378c627c682964f104fc2fb06ea5513b6b7/LICENSE" ), "org.zeromq:jeromq" to DependencyInfo( - version = "0.5.3", + version = "0.6.0", name = "JeroMQ", author = "zeromq", url = "https://github.com/zeromq/jeromq", diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 42f4cd6cb..377000984 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -57,6 +57,8 @@ fun getString(key: String): String { } kotlin { + jvmToolchain(21) + jvm() sourceSets { val deps: SpMpDeps = SpMpDeps(extra.properties) @@ -108,6 +110,9 @@ compose.desktop { targetFormats(TargetFormat.AppImage, TargetFormat.Deb, TargetFormat.Exe) + // spms + jvmArgs += listOf("--enable-preview", "--enable-native-access=ALL-UNNAMED") + linux { iconFile.set(rootProject.file("metadata/en-US/images/icon.png")) appRelease = getString("version_code") @@ -133,145 +138,7 @@ compose.desktop { } } -abstract class PackageTask: DefaultTask() { - private data class Configuration( - val target_name: String, - val target_os: OS, - val server_build_task: Task, - val package_server: Boolean - ) - - private var configured: Boolean = false - private lateinit var configuration: Configuration - - companion object { - const val FLAG_PACKAGE_SERVER: String = "packageServer" - - fun getResourcesDir(project: Project, os: OS): File = - when (os) { - OS.LINUX -> project.file("build/package/linux") - OS.WINDOWS -> project.file("build/package/windows") - } - } - - init { - outputs.upToDateWhen { false } - } - - fun configure(package_server: Boolean, spms_os: OS, spms_arch: String, is_release: Boolean) { - val server_project = project.rootProject.project("spmp-server") - server_project.ext.set("LINK_STATIC", 1) - - val target_name: String = - spms_os.name.lowercase() + '-' + spms_arch - - val build_type: String = - if (is_release) "Release" - else "Debug" - val task_name: String = - "link${build_type}Executable${target_name.replaceFirstChar { it.uppercase() }}" - - configuration = Configuration( - target_name = target_name, - target_os = spms_os, - server_build_task = server_project.tasks.getByName(task_name), - package_server = package_server - ) - - if (package_server) { - dependsOn(configuration.server_build_task) - } - - configured = true - } - - fun File.addExecutePermission() { - if (OperatingSystem.current().isUnix()) { - val permissions: MutableSet = getPosixFilePermissions(toPath()) - permissions.add(PosixFilePermission.OWNER_EXECUTE) - setPosixFilePermissions(toPath(), permissions) - } - } - - fun getPlatformServerFileExtension(target_os: OS): String = - when (target_os) { - OS.LINUX -> "kexe" - OS.WINDOWS -> "exe" - } - - fun getPlatformServerFilename(target_os: OS): String = - "spms." + getPlatformServerFileExtension(target_os) - - fun buildPlatformServer(dst_dir: File) { - check(configured) { "PackageTask was not configured" } - - if (!configuration.package_server) { - val server_file: File = dst_dir.resolve(getPlatformServerFilename(OS.LINUX)) - if (server_file.isFile) { - server_file.delete() - } - return - } - - val output_directory: File = configuration.server_build_task.outputs.files.files.single() - val executable_file: File = output_directory.resolve("spms-${configuration.target_name}." + getPlatformServerFileExtension(configuration.target_os)) - - for (file in output_directory.listFiles()) { - if (file == executable_file || !file.name.endsWith(".dll")) { - continue - } - - file.copyTo(dst_dir.resolve(file.name), overwrite = true) - } - - check(executable_file.isFile) { - "Server executable $executable_file does not exist ($configuration)" - } - - try { - println("Attempting to strip server executable ${executable_file.absolutePath}...") - CommandClass(project).cmd("strip", executable_file.absolutePath) - } - catch (e: Throwable) { - RuntimeException("Strip failed", e).printStackTrace() - } - - val output_file: File = dst_dir.resolve(getPlatformServerFilename(configuration.target_os)) - try { - executable_file.copyTo(output_file, overwrite = true) - } - catch (e: Throwable) { - throw RuntimeException("Copying $executable_file to $output_file failed", e) - } - - output_file.addExecutePermission() - } - - fun extractZip(file: File, output_dir: File) { - ZipFile(file).use { zip -> - val entries = zip.entries() - while (entries.hasMoreElements()) { - val entry = entries.nextElement() - val entry_destination = File(output_dir, entry.name) - - if (entry.isDirectory) { - entry_destination.mkdirs() - continue - } - - entry_destination.parentFile.mkdirs() - - zip.getInputStream(entry).use { input -> - FileOutputStream(entry_destination).use { output -> - input.copyTo(output) - } - } - } - } - } -} - -abstract class ActuallyPackageAppImageTask: PackageTask() { +abstract class ActuallyPackageAppImageTask: DefaultTask() { @get:Input abstract val appimage_arch: Property @@ -314,9 +181,6 @@ abstract class ActuallyPackageAppImageTask: PackageTask() { val icon_dst: File = icon_dst_file.get().asFile icon_src.copyTo(icon_dst, overwrite = true) - val server_dst_dir: File = appimage_dst.resolve("bin") - buildPlatformServer(server_dst_dir) - val arch: String = appimage_arch.get() val appimage_output: File = appimage_output_file.get().asFile @@ -333,6 +197,14 @@ abstract class ActuallyPackageAppImageTask: PackageTask() { project.logger.lifecycle("\nAppImage successfully packaged to ${appimage_output.absolutePath}") } } + + fun File.addExecutePermission() { + if (OperatingSystem.current().isUnix()) { + val permissions: MutableSet = getPosixFilePermissions(toPath()) + permissions.add(PosixFilePermission.OWNER_EXECUTE) + setPosixFilePermissions(toPath(), permissions) + } + } } fun registerAppImagePackageTasks() { @@ -342,62 +214,24 @@ fun registerAppImagePackageTasks() { ) for ((task_name, is_release) in package_tasks) { - val with_server_task = - tasks.register(task_name + "WithServer") { - finalizedBy(task_name) - group = tasks.getByName(task_name).group - } + val subtask_name: String = + if (is_release) "finishPackagingReleaseAppImage" + else "finishPackagingAppImage" - for (with_server in listOf(false, true)) { - val suffix: String = - if (with_server) "WithServer" - else "" + tasks.register(subtask_name) { + val build_dir: File = tasks.getByName(task_name).outputs.files.toList().single() - val subtask_name: String = - if (is_release) "finishPackagingReleaseAppImage" + suffix - else "finishPackagingAppImage" + suffix + appimage_arch = "x86_64" + appimage_output_file = build_dir.parentFile.resolve("appimage").resolve(rootProject.name.lowercase() + "-" + getString("version_string") + ".appimage") - tasks.register(subtask_name) { - val build_dir: File = tasks.getByName(task_name).outputs.files.toList().single() + appimage_src_dir = projectDir.resolve("appimage") + appimage_dst_dir = build_dir.resolve(rootProject.name) - val arch: String = "x86_64" - configure(with_server, OS.LINUX, arch, is_release) - - appimage_arch = arch - appimage_output_file = build_dir.parentFile.resolve("appimage").resolve(rootProject.name.lowercase() + "-" + getString("version_string") + ".appimage") - - appimage_src_dir = projectDir.resolve("appimage") - appimage_dst_dir = build_dir.resolve(rootProject.name) - - icon_src_file = rootDir.resolve("metadata/en-US/images/icon.png") - icon_dst_file = appimage_dst_dir.get().asFile.resolve("${rootProject.name}.png") - - onlyIf { - with_server || !gradle.taskGraph.hasTask(":" + project.name + ":" + with_server_task.name) - } - } - - tasks.getByName(task_name + suffix).finalizedBy(subtask_name) + icon_src_file = rootDir.resolve("metadata/en-US/images/icon.png") + icon_dst_file = appimage_dst_dir.get().asFile.resolve("${rootProject.name}.png") } - } -} - -fun registerExePackageTasks() { - val package_tasks: List> = listOf( - tasks.getByName("packageExe") to false, - tasks.getByName("packageReleaseExe") to true - ) - - for ((task, is_release) in package_tasks) { - tasks.register(task.name + "WithServer") { - finalizedBy("packageReleaseExe") - group = task.group - configure(true, OS.WINDOWS, "x86_64", is_release) - doFirst { - buildPlatformServer(PackageTask.getResourcesDir(project, OS.WINDOWS)) - } - } + tasks.getByName(task_name).finalizedBy(subtask_name) } } @@ -405,7 +239,10 @@ fun configureRunTask() { tasks.getByName("run") { val local_properties: Properties = Properties().apply { try { - load(FileInputStream(rootProject.file(local_properties_path))) + val file: File = rootProject.file(local_properties_path) + if (file.isFile) { + load(FileInputStream(file)) + } } catch (e: Throwable) { RuntimeException("Ignoring exception while loading '$local_properties_path' in configureRunTask()", e).printStackTrace() @@ -417,6 +254,5 @@ fun configureRunTask() { afterEvaluate { registerAppImagePackageTasks() - registerExePackageTasks() configureRunTask() } diff --git a/desktopApp/src/jvmMain/kotlin/main.kt b/desktopApp/src/jvmMain/kotlin/main.kt index 0100b4f5b..684535db4 100644 --- a/desktopApp/src/jvmMain/kotlin/main.kt +++ b/desktopApp/src/jvmMain/kotlin/main.kt @@ -41,7 +41,7 @@ fun main(args: Array) { SpMp.init(context) - val force_software_renderer: Boolean = context.settings.desktop.FORCE_SOFTWARE_RENDERER.get() + val force_software_renderer: Boolean = context.settings.platform.FORCE_SOFTWARE_RENDERER.get() if (force_software_renderer) { System.setProperty("skiko.renderApi", "SOFTWARE") } @@ -105,7 +105,7 @@ fun main(args: Array) { var player_initialised: Boolean by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - player = SpMp.initPlayer(player_coroutine_scope) + player = SpMp.initPlayer(arguments, player_coroutine_scope) player_initialised = true window = this@Window.window @@ -114,7 +114,7 @@ fun main(args: Array) { window.background = java.awt.Color(0, 0, 0, 0) } - val startup_command: String = context.settings.desktop.STARTUP_COMMAND.get() + val startup_command: String = context.settings.platform.STARTUP_COMMAND.get() if (startup_command.isBlank()) { return@LaunchedEffect } diff --git a/flake.lock b/flake.lock index 4e7d5df79..da4413e61 100644 --- a/flake.lock +++ b/flake.lock @@ -81,11 +81,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1718318537, - "narHash": "sha256-4Zu0RYRcAY/VWuu6awwq4opuiD//ahpc2aFHg2CWqFY=", + "lastModified": 1718895438, + "narHash": "sha256-k3JqJrkdoYwE3fHE6xGDY676AYmyh4U2Zw+0Bwe5DLU=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9ee548d90ff586a6471b4ae80ae9cfcbceb3420", + "rev": "d603719ec6e294f034936c0d0dc06f689d91b6c3", "type": "github" }, "original": { @@ -97,11 +97,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1719082008, - "narHash": "sha256-jHJSUH619zBQ6WdC21fFAlDxHErKVDJ5fpN0Hgx4sjs=", + "lastModified": 1718983919, + "narHash": "sha256-+1xgeIow4gJeiwo4ETvMRvWoircnvb0JOt7NS9kUhoM=", "owner": "nixos", "repo": "nixpkgs", - "rev": "9693852a2070b398ee123a329e68f0dab5526681", + "rev": "90338afd6177fc683a04d934199d693708c85a3b", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 57e897d6c..e8767f577 100644 --- a/flake.nix +++ b/flake.nix @@ -29,18 +29,29 @@ in pkgs.mkShell { packages = with pkgs; [ - jdk17 + jdk21 + jdk22 android-sdk # Runtime libglvnd xorg.libX11 fontconfig + mpv ]; - JAVA_HOME = "${pkgs.jdk17}/lib/openjdk"; + JAVA_21_HOME = "${pkgs.jdk21}/lib/openjdk"; + JAVA_22_HOME = "${pkgs.jdk22}/lib/openjdk"; + JAVA_HOME = "${pkgs.jdk22}/lib/openjdk"; GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${android-sdk}/share/android-sdk/build-tools/34.0.0/aapt2"; + + shellHook = '' + # Add NIX_LDFLAGS to LD_LIBRARY_PATH + lib_paths=($(echo $NIX_LDFLAGS | grep -oP '(?<=-rpath\s| -L)[^ ]+')) + lib_paths_str=$(IFS=:; echo "''${lib_paths[*]}") + export LD_LIBRARY_PATH="$lib_paths_str:$LD_LIBRARY_PATH" + ''; }; }; } diff --git a/gradle.properties b/gradle.properties index 3992810ba..0844a99f7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,18 +5,22 @@ org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" kotlin.code.style=official kotlin.mpp.enableCInteropCommonization=true kotlin.mpp.enableCInteropCommonization.nowarn=true +kotlin.mpp.androidGradlePluginCompatibility.nowarn=true # Android android.useAndroidX=true android.compileSdk=34 android.targetSdk=34 -android.minSdk=23 +android.minSdk=26 android.nonTransitiveRClass=true android.enableR8.fullMode=true android.suppressUnsupportedCompileSdk=34 # Plugin versions kotlin.version=2.0.0-RC1 -agp.version=8.2.2 +agp.version=8.5.0 compose.version=1.6.2 sqldelight.version = 2.0.0 + +# Nix +org.gradle.java.installations.fromEnv=JAVA_21_HOME,JAVA_22_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index 8e7dead74..c5d02d2da 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,5 @@ rootProject.name = "spmp" -include(":spmp-server") include(":shared") include(":androidApp") include(":desktopApp") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 20b415c0c..fe1bf2ea5 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -36,7 +36,6 @@ kotlin { commonMain { kotlin { - srcDir(rootProject.file("spmp-server/src/commonMain/kotlin/spms/socketapi/shared/")) srcDir(buildConfigDir) } @@ -49,8 +48,9 @@ kotlin { implementation(compose.material3) implementation(compose.components.resources) - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation(deps.get("dev.toastbits:spms")) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") implementation(deps.get("org.apache.commons:commons-text")) implementation(deps.get("com.atilika.kuromoji:kuromoji-ipadic")) implementation(deps.get("org.jsoup:jsoup")) @@ -120,8 +120,8 @@ android { namespace = "com.toasterofbread.spmp.shared" compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_22 + targetCompatibility = JavaVersion.VERSION_22 } sourceSets.getByName("main") { @@ -129,7 +129,7 @@ android { resources.srcDirs("src/commonMain/resources") } defaultConfig { - minSdk = 23 + minSdk = (findProperty("android.minSdk") as String).toInt() } } diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/PlayerListener.android.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/PlayerListener.android.kt index b9b027e67..f4ba50252 100644 --- a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/PlayerListener.android.kt +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/PlayerListener.android.kt @@ -3,8 +3,8 @@ package com.toasterofbread.spmp.platform import androidx.media3.common.MediaItem import androidx.media3.common.Player import com.toasterofbread.spmp.model.mediaitem.song.Song -import spms.socketapi.shared.SpMsPlayerRepeatMode -import spms.socketapi.shared.SpMsPlayerState +import dev.toastbits.spms.socketapi.shared.SpMsPlayerRepeatMode +import dev.toastbits.spms.socketapi.shared.SpMsPlayerState import com.toasterofbread.spmp.platform.playerservice.convertState import com.toasterofbread.spmp.platform.playerservice.getSong diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/AudioDeviceCallback.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/AudioDeviceCallback.kt new file mode 100644 index 000000000..4336ec381 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/AudioDeviceCallback.kt @@ -0,0 +1,62 @@ +package com.toasterofbread.spmp.platform.playerservice + +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.os.Build +import androidx.media3.common.Player +import com.toasterofbread.spmp.model.settings.category.PlayerSettings + +internal class PlayerAudioDeviceCallback( + private val service: ForegroundPlayerService +): AudioDeviceCallback() { + private fun isBluetoothAudio(device: AudioDeviceInfo): Boolean { + if (!device.isSink) { + return false + } + return device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP + } + private fun isWiredAudio(device: AudioDeviceInfo): Boolean { + if (!device.isSink) { + return false + } + return ( + device.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || + device.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && device.type == AudioDeviceInfo.TYPE_USB_HEADSET) + ) + } + + override fun onAudioDevicesAdded(addedDevices: Array) { + if (service.player.isPlaying || !service.paused_by_device_disconnect) { + return + } + + val resume_on_bt: Boolean = service.context.settings.player.RESUME_ON_BT_CONNECT.get() + val resume_on_wired: Boolean = service.context.settings.player.RESUME_ON_WIRED_CONNECT.get() + + for (device in addedDevices) { + if ((resume_on_bt && isBluetoothAudio(device)) || (resume_on_wired && isWiredAudio(device))) { + service.player.play() + break + } + } + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + if (!service.player.isPlaying && service.player.playbackState == Player.STATE_READY) { + return + } + + val pause_on_bt: Boolean = service.context.settings.player.PAUSE_ON_BT_DISCONNECT.get() + val pause_on_wired: Boolean = service.context.settings.player.PAUSE_ON_WIRED_DISCONNECT.get() + + for (device in removedDevices) { + if ((pause_on_bt && isBluetoothAudio(device)) || (pause_on_wired && isWiredAudio(device))) { + service.device_connection_changed_playing_status = true + service.paused_by_device_disconnect = true + service.player.pause() + break + } + } + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ExoPlayerUtils.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ExoPlayerUtils.kt new file mode 100644 index 000000000..1dbb0804b --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ExoPlayerUtils.kt @@ -0,0 +1,58 @@ +package com.toasterofbread.spmp.platform.playerservice + +import android.media.audiofx.LoudnessEnhancer +import androidx.core.net.toUri +import androidx.media3.common.MediaMetadata +import com.toasterofbread.spmp.model.mediaitem.song.Song +import com.toasterofbread.spmp.model.mediaitem.song.SongRef +import com.toasterofbread.spmp.model.settings.category.StreamingSettings +import com.toasterofbread.spmp.platform.AppContext +import com.toasterofbread.spmp.db.Database +import dev.toastbits.spms.socketapi.shared.SpMsPlayerState +import androidx.media3.common.MediaItem as ExoMediaItem +import androidx.compose.material.icons.filled.Album + +internal fun Song.buildExoMediaItem(context: AppContext): ExoMediaItem = + ExoMediaItem.Builder() + .setRequestMetadata(ExoMediaItem.RequestMetadata.Builder().setMediaUri(id.toUri()).build()) + .setUri(id) + .setCustomCacheKey(id) + .setMediaMetadata( + MediaMetadata.Builder() + .apply { + val db: Database = context.database + + setArtworkUri(id.toUri()) + setTitle(getActiveTitle(db)) + setArtist(Artists.get(db)?.firstOrNull()?.getActiveTitle(db)) + + val album = Album.get(db) + setAlbumTitle(album?.getActiveTitle(db)) + setAlbumArtist(album?.Artists?.get(db)?.firstOrNull()?.getActiveTitle(db)) + } + .build() + ) + .build() + +fun convertState(exo_state: Int): SpMsPlayerState = + SpMsPlayerState.entries[exo_state - 1] + +fun ExoMediaItem.getSong(): Song = + SongRef(mediaMetadata.artworkUri.toString()) + +internal fun LoudnessEnhancer.update(song: Song?, context: AppContext) { + if (song == null || !context.settings.streaming.ENABLE_AUDIO_NORMALISATION.get()) { + enabled = false + return + } + + val loudness_db: Float? = song.LoudnessDb.get(context.database) + if (loudness_db == null) { + setTargetGain(0) + } + else { + setTargetGain((loudness_db * 100).toInt()) + } + + enabled = true +} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ForegroundPlayerService.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ForegroundPlayerService.kt new file mode 100644 index 000000000..2c3c49cc4 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ForegroundPlayerService.kt @@ -0,0 +1,350 @@ +package com.toasterofbread.spmp.platform.playerservice + +import android.content.Intent +import android.media.AudioManager +import android.media.MediaRouter +import android.media.audiofx.LoudnessEnhancer +import android.os.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.media3.common.* +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.audio.* +import androidx.media3.session.* +import dev.toastbits.composekit.platform.PlatformPreferences +import dev.toastbits.composekit.platform.PlatformPreferencesListener +import dev.toastbits.ytmkt.model.external.SongLikedStatus +import dev.toastbits.ytmkt.model.implementedOrNull +import dev.toastbits.ytmkt.endpoint.SetSongLikedEndpoint +import com.toasterofbread.spmp.platform.visualiser.FFTAudioProcessor +import com.toasterofbread.spmp.model.mediaitem.song.Song +import com.toasterofbread.spmp.model.mediaitem.song.updateLiked +import com.toasterofbread.spmp.model.mediaitem.song.SongLikedStatusListener +import com.toasterofbread.spmp.model.settings.category.BehaviourSettings +import com.toasterofbread.spmp.model.settings.category.StreamingSettings +import com.toasterofbread.spmp.model.radio.RadioInstance +import com.toasterofbread.spmp.platform.AppContext +import com.toasterofbread.spmp.platform.PlayerListener +import com.toasterofbread.spmp.platform.PlayerServiceCommand +import com.toasterofbread.spmp.platform.playerservice.* +import com.toasterofbread.spmp.platform.visualiser.MusicVisualiser +import com.toasterofbread.spmp.shared.R +import com.toasterofbread.spmp.service.playercontroller.RadioHandler +import kotlinx.coroutines.* +import dev.toastbits.spms.socketapi.shared.SpMsPlayerRepeatMode +import dev.toastbits.spms.socketapi.shared.SpMsPlayerState + +@androidx.annotation.OptIn(UnstableApi::class) +open class ForegroundPlayerService( + private val play_when_ready: Boolean, + private val playlist_auto_progress: Boolean = true +): MediaSessionService(), PlayerService { + override val load_state: PlayerServiceLoadState = PlayerServiceLoadState(false) + override val context: AppContext get() = _context + private lateinit var _context: AppContext + + internal val coroutine_scope: CoroutineScope = CoroutineScope(Dispatchers.Main) + internal lateinit var player: ExoPlayer + internal lateinit var media_session: MediaSession + internal lateinit var audio_sink: AudioSink + internal var loudness_enhancer: LoudnessEnhancer? = null + + internal var current_song: Song? = null + internal var paused_by_device_disconnect: Boolean = false + internal var device_connection_changed_playing_status: Boolean = false + + private val song_liked_listener: SongLikedStatusListener = SongLikedStatusListener { song, liked_status -> + if (song == current_song) { + updatePlayerCustomActions(liked_status) + } + } + + private val audio_device_callback: PlayerAudioDeviceCallback = PlayerAudioDeviceCallback(this) + + private val prefs_listener: PlatformPreferencesListener = + PlatformPreferencesListener { _, key -> + when (key) { + context.settings.streaming.ENABLE_AUDIO_NORMALISATION.key -> { + loudness_enhancer?.update(current_song, context) + } + context.settings.streaming.ENABLE_SILENCE_SKIPPING.key -> { + audio_sink.skipSilenceEnabled = context.settings.streaming.ENABLE_SILENCE_SKIPPING.get() + } + } + } + + private val listeners: MutableList = mutableListOf() + + override fun addListener(listener: PlayerListener) { + listener.addToPlayer(player) + listeners.add(listener) + } + override fun removeListener(listener: PlayerListener) { + listeners.remove(listener) + listener.removeFromPlayer(player) + } + + protected open fun onRadioCancelled() {} + + protected open fun getNotificationPlayer(player: Player): Player = player + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + return START_NOT_STICKY + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + return media_session + } + + override fun onBind(intent: Intent?): IBinder? { + try { + val binder = super.onBind(intent) + if (binder != null) { + return binder + } + } + catch (_: Throwable) {} + + return PlayerBinder(this) + } + + override fun onCreate() { + super.onCreate() + + _context = AppContext(this, coroutine_scope) + _context.getPrefs().addListener(prefs_listener) + + initialiseSessionAndPlayer( + play_when_ready, + playlist_auto_progress, + getNotificationPlayer = { getNotificationPlayer(it) } + ) + + _service_player = object : PlayerServicePlayer(this) { + override fun onUndoStateChanged() { + for (listener in listeners) { + listener.onUndoStateChanged() + } + } + + override val radio: RadioHandler = + object : RadioHandler(this, context) { + override fun onRadioCancelled() { + super.onRadioCancelled() + this@ForegroundPlayerService.onRadioCancelled() + } + } + } + + val audio_manager = getSystemService(AUDIO_SERVICE) as AudioManager? + audio_manager?.registerAudioDeviceCallback(audio_device_callback, null) + + setMediaNotificationProvider( + DefaultMediaNotificationProvider(this).apply { + setSmallIcon(R.drawable.ic_spmp) + } + ) + + SongLikedStatusListener.addListener(song_liked_listener) + } + + override fun onDestroy() { + stopSelf() + + _context.getPrefs().removeListener(prefs_listener) + coroutine_scope.cancel() + service_player.release() + player.release() + media_session.release() + loudness_enhancer?.release() + SongLikedStatusListener.removeListener(song_liked_listener) + + val audio_manager = getSystemService(AUDIO_SERVICE) as AudioManager? + audio_manager?.unregisterAudioDeviceCallback(audio_device_callback) + + clearListener() + super.onDestroy() + } + + override fun onTaskRemoved(intent: Intent?) { + super.onTaskRemoved(intent) + + if ( + (!player.isPlaying && convertState(player.playbackState) != SpMsPlayerState.BUFFERING) + || ( + context.settings.behaviour.STOP_PLAYER_ON_APP_CLOSE.get() + && intent?.component?.packageName == packageName + ) + ) { + stopSelf() + onDestroy() + } + } + + internal fun onPlayerServiceCommand(command: PlayerServiceCommand): Bundle { + when (command) { + is PlayerServiceCommand.SetLiked -> { + val song: Song = current_song ?: return Bundle.EMPTY + coroutine_scope.launch { + val endpoint: SetSongLikedEndpoint = + context.ytapi.user_auth_state?.SetSongLiked?.implementedOrNull() ?: return@launch + + song.updateLiked( + command.value, + endpoint, + context + ) + } + } + } + + return Bundle.EMPTY + } + + private lateinit var _service_player: PlayerServicePlayer + override val service_player: PlayerServicePlayer get() = _service_player + override val state: SpMsPlayerState get() = convertState(player.playbackState) + override val is_playing: Boolean get() = player.isPlaying + override val song_count: Int get() = player.mediaItemCount + override val current_song_index: Int get() = player.currentMediaItemIndex + override val current_position_ms: Long get() = player.currentPosition + override val duration_ms: Long get() = player.duration + override val radio_instance: RadioInstance get() = service_player.radio_instance + override var repeat_mode: SpMsPlayerRepeatMode + get() = SpMsPlayerRepeatMode.entries[player.repeatMode] + set(value) { + player.repeatMode = value.ordinal + } + override var volume: Float + get() = player.volume + set(value) { + player.volume = value + } + override val has_focus: Boolean + get() = TODO() + + override fun isPlayingOverLatentDevice(): Boolean { + val media_router: MediaRouter = (getSystemService(MEDIA_ROUTER_SERVICE) as MediaRouter?) ?: return false + val selected_route: MediaRouter.RouteInfo = media_router.getSelectedRoute(MediaRouter.ROUTE_TYPE_LIVE_AUDIO) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return selected_route.deviceType == MediaRouter.RouteInfo.DEVICE_TYPE_BLUETOOTH + } + else { + return false + } + } + + override fun play() { + player.play() + } + + override fun pause() { + player.pause() + } + + override fun playPause() { + if (player.isPlaying) { + player.pause() + } + else { + player.play() + } + } + + private val song_seek_undo_stack: MutableList> = mutableListOf() + private fun getSeekPosition(): Pair = Pair(current_song_index, current_position_ms) + + override fun seekTo(position_ms: Long) { + val current: Pair = getSeekPosition() + player.seekTo(position_ms) + listeners.forEach { it.onSeeked(position_ms) } + + if (current != getSeekPosition()) { + song_seek_undo_stack.add(current) + } + } + + override fun seekToSong(index: Int) { + val current: Pair = getSeekPosition() + player.seekTo(index, 0) + + if (current != getSeekPosition()) { + song_seek_undo_stack.add(current) + } + } + + override fun seekToNext() { + val current: Pair = getSeekPosition() + player.seekToNext() + + if (current != getSeekPosition()) { + song_seek_undo_stack.add(current) + } + } + + override fun seekToPrevious() { + val current: Pair = getSeekPosition() + player.seekToPrevious() + + if (current != getSeekPosition()) { + song_seek_undo_stack.add(current) + } + } + + override fun undoSeek() { + val (index: Int, position_ms: Long) = song_seek_undo_stack.removeLastOrNull() ?: return + + if (index != current_song_index) { + player.seekTo(index, position_ms) + } + else { + player.seekTo(position_ms) + } + } + + override fun getSong(): Song? { + return player.currentMediaItem?.getSong() + } + + override fun getSong(index: Int): Song? { + if (index !in 0 until song_count) { + return null + } + + return player.getMediaItemAt(index).getSong() + } + + override fun addSong(song: Song, index: Int) { + player.addMediaItem(index, song.buildExoMediaItem(context)) + listeners.forEach { it.onSongAdded(index, song) } + + service_player.session_started = true + } + + override fun moveSong(from: Int, to: Int) { + player.moveMediaItem(from, to) + listeners.forEach { it.onSongMoved(from, to) } + } + + override fun removeSong(index: Int) { + val song: Song = player.getMediaItemAt(index).getSong() + player.removeMediaItem(index) + listeners.forEach { it.onSongRemoved(index, song) } + } + + @Composable + override fun Visualiser(colour: Color, modifier: Modifier, opacity: Float) { + val visualiser: MusicVisualiser = remember { MusicVisualiser(fft_audio_processor) } + visualiser.Visualiser(colour, modifier, opacity) + } + + companion object { + // If there's a better way to provide information to MediaControllers, I'd like to know + val fft_audio_processor: FFTAudioProcessor = FFTAudioProcessor() + } +} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/HeadlessExternalPlayerService.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/HeadlessExternalPlayerService.kt new file mode 100644 index 000000000..6d84af9fe --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/HeadlessExternalPlayerService.kt @@ -0,0 +1,73 @@ +package com.toasterofbread.spmp.platform.playerservice + +import ProgramArguments +import androidx.compose.ui.Modifier +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import com.toasterofbread.spmp.resources.getString +import com.toasterofbread.spmp.service.playercontroller.PlayerState +import com.toasterofbread.spmp.platform.AppContext +import LocalProgramArguments +import LocalPlayerState + +internal class HeadlessExternalPlayerService: ExternalPlayerService(plays_audio = false), PlayerService { + @Composable + override fun PersistentContent(requestServiceChange: (PlayerServiceCompanion) -> Unit) { + val player: PlayerState = LocalPlayerState.current + val launch_arguments: ProgramArguments = LocalProgramArguments.current + val ui_only: Boolean by player.settings.platform.EXTERNAL_SERVER_MODE_UI_ONLY.observe() + LaunchedEffect(ui_only) { + if (!ui_only && PlatformExternalPlayerService.isAvailable(player.context, launch_arguments)) { + requestServiceChange(PlatformExternalPlayerService.Companion) + } + } + } + + @Composable + override fun LoadScreenExtraContent(item_modifier: Modifier, requestServiceChange: (PlayerServiceCompanion) -> Unit) { + val launch_arguments: ProgramArguments = LocalProgramArguments.current + val internal_service_available: Boolean = remember(launch_arguments) { PlatformInternalPlayerService.Companion.isAvailable(context, launch_arguments) } + + if (internal_service_available) { + Button( + { + requestServiceChange(PlatformInternalPlayerService.Companion) + }, + item_modifier + ) { + Text(getString("loading_splash_button_launch_without_server")) + } + } + } + + companion object: PlayerServiceCompanion { + override fun isServiceRunning(context: AppContext): Boolean = true + override fun playsAudio(): Boolean = true + + override fun connect( + context: AppContext, + launch_arguments: ProgramArguments, + instance: PlayerService?, + onConnected: (PlayerService) -> Unit, + onDisconnected: () -> Unit + ): Any { + require(instance is ExternalPlayerService?) + val service: ExternalPlayerService = + if (instance != null) instance.also { it.setContext(context) } + else HeadlessExternalPlayerService().also { + it.setContext(context) + it.onCreate() + } + onConnected(service) + return service + } + + override fun disconnect(context: AppContext, connection: Any) { + (connection as ExternalPlayerService).onDestroy() + } + } +} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/InternalPlayerServiceCompanion.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/InternalPlayerServiceCompanion.kt new file mode 100644 index 000000000..65dcbedf3 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/InternalPlayerServiceCompanion.kt @@ -0,0 +1,61 @@ +package com.toasterofbread.spmp.platform.playerservice + +import ProgramArguments +import android.app.ActivityManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Binder +import android.os.IBinder +import com.toasterofbread.spmp.platform.AppContext +import kotlin.reflect.KClass + +internal class PlayerBinder(val service: ForegroundPlayerService): Binder() + +abstract class InternalPlayerServiceCompanion( + private val service_class: KClass<*> +): PlayerServiceCompanion { + private fun AppContext.getAndroidContext(): Context = + ctx.applicationContext + + override fun connect( + context: AppContext, + launch_arguments: ProgramArguments, + instance: PlayerService?, + onConnected: (PlayerService) -> Unit, + onDisconnected: () -> Unit + ): Any { + val ctx: Context = context.getAndroidContext() + + val service_connection: ServiceConnection = + object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + onConnected((service as PlayerBinder).service) + } + + override fun onServiceDisconnected(name: ComponentName?) { + onDisconnected() + } + } + + ctx.startService(Intent(ctx, service_class.java)) + ctx.bindService(Intent(ctx, service_class.java), service_connection, Context.BIND_AUTO_CREATE) + + return service_connection + } + + override fun disconnect(context: AppContext, connection: Any) { + context.getAndroidContext().unbindService(connection as ServiceConnection) + } + + override fun isServiceRunning(context: AppContext): Boolean { + val manager: ActivityManager = context.ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + for (service in manager.getRunningServices(Int.MAX_VALUE)) { + if (service.service.className == service_class.java.name) { + return true + } + } + return false + } +} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/InternalPlayerServicePlayerListener.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/InternalPlayerServicePlayerListener.kt new file mode 100644 index 000000000..3f5d7b4a3 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/InternalPlayerServicePlayerListener.kt @@ -0,0 +1,43 @@ +package com.toasterofbread.spmp.platform.playerservice + +import android.media.audiofx.LoudnessEnhancer +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import com.toasterofbread.spmp.model.mediaitem.song.Song + +class InternalPlayerServicePlayerListener( + private val service: ForegroundPlayerService +): Player.Listener { + override fun onMediaItemTransition(media_item: MediaItem?, reason: Int) { + val song: Song? = media_item?.getSong() + if (song?.id == service.current_song?.id) { + return + } + + service.current_song = song + service.updatePlayerCustomActions() + + if (service.loudness_enhancer == null) { + service.loudness_enhancer = LoudnessEnhancer(service.player.audioSessionId) + } + + service.loudness_enhancer?.update(song, service.context) + } + + override fun onAudioSessionIdChanged(audioSessionId: Int) { + service.loudness_enhancer?.release() + service.loudness_enhancer = LoudnessEnhancer(audioSessionId).apply { + update(service.current_song, service.context) + enabled = true + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (service.device_connection_changed_playing_status) { + service.device_connection_changed_playing_status = false + } + else { + service.paused_by_device_disconnect = false + } + } +} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/LocalServer.android.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/LocalServer.android.kt new file mode 100644 index 000000000..fd67f1a14 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/LocalServer.android.kt @@ -0,0 +1,14 @@ +package com.toasterofbread.spmp.platform.playerservice + +import ProgramArguments +import com.toasterofbread.spmp.platform.AppContext +import kotlinx.coroutines.Job + +actual object LocalServer { + actual fun getLocalServerUnavailabilityReason(): String? = null + + actual fun startLocalServer( + context: AppContext, + port: Int, + ): Job = throw IllegalAccessError() +} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/NotificationImageUtils.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/NotificationImageUtils.kt new file mode 100644 index 000000000..fa74f51f2 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/NotificationImageUtils.kt @@ -0,0 +1,52 @@ +package com.toasterofbread.spmp.platform.playerservice + +import android.graphics.Bitmap +import android.os.Build +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import com.toasterofbread.spmp.model.mediaitem.song.Song +import com.toasterofbread.spmp.platform.AppContext +import kotlin.math.roundToInt + +private const val A13_MEDIA_NOTIFICATION_ASPECT = 2.9f / 5.7f + +fun getMediaNotificationImageMaxOffset(image: Bitmap): IntOffset { + val dimensions: IntSize = getMediaNotificationImageSize(image) + return IntOffset( + (image.width - dimensions.width) / 2, + (image.height - dimensions.height) / 2 + ) +} + +fun getMediaNotificationImageSize(image: Bitmap): IntSize { + val aspect: Float = if (Build.VERSION.SDK_INT >= 33) A13_MEDIA_NOTIFICATION_ASPECT else 1f + if (image.width > image.height) { + return IntSize( + image.height, + (image.height * aspect).roundToInt() + ) + } + else { + return IntSize( + image.width, + (image.width * aspect).roundToInt() + ) + } +} + +internal fun formatMediaNotificationImage( + image: Bitmap, + song: Song, + context: AppContext, + ): Bitmap { + val dimensions: IntSize = getMediaNotificationImageSize(image) + val offset: IntOffset = song.NotificationImageOffset.get(context.database) ?: IntOffset.Zero + + return Bitmap.createBitmap( + image, + (((image.width - dimensions.width) / 2) + offset.x).coerceIn(0, image.width - dimensions.width), + (((image.height - dimensions.height) / 2) + offset.y).coerceIn(0, image.height - dimensions.height), + dimensions.width, + dimensions.height + ) +} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformExternalPlayerService.android.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformExternalPlayerService.android.kt new file mode 100644 index 000000000..9e67ea7d5 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformExternalPlayerService.android.kt @@ -0,0 +1,274 @@ +package com.toasterofbread.spmp.platform.playerservice + +import com.toasterofbread.spmp.model.mediaitem.song.Song +import com.toasterofbread.spmp.model.radio.RadioInstance +import com.toasterofbread.spmp.platform.AppContext +import com.toasterofbread.spmp.platform.PlayerListener +import com.toasterofbread.spmp.resources.getString +import com.toasterofbread.spmp.service.playercontroller.PlayerState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import dev.toastbits.spms.socketapi.shared.SpMsPlayerRepeatMode +import dev.toastbits.spms.socketapi.shared.SpMsPlayerState +import androidx.media3.common.Player +import androidx.media3.common.ForwardingPlayer +import androidx.media3.common.MediaItem as ExoMediaItem +import androidx.compose.material3.Text +import androidx.compose.material3.Button +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import ProgramArguments +import LocalProgramArguments +import LocalPlayerState + +actual class PlatformExternalPlayerService: ForegroundPlayerService(play_when_ready = false), PlayerService { + private var target_playing: Boolean = false + private var target_seek: Long? = null + + @Composable + override fun PersistentContent(requestServiceChange: (PlayerServiceCompanion) -> Unit) { + val player: PlayerState = LocalPlayerState.current + val launch_arguments: ProgramArguments = LocalProgramArguments.current + val ui_only: Boolean by player.settings.platform.EXTERNAL_SERVER_MODE_UI_ONLY.observe() + LaunchedEffect(ui_only) { + if (ui_only && PlatformExternalPlayerService.isAvailable(player.context, launch_arguments)) { + requestServiceChange(PlatformExternalPlayerService.Companion) + } + } + } + + @Composable + override fun LoadScreenExtraContent(item_modifier: Modifier, requestServiceChange: (PlayerServiceCompanion) -> Unit) { + val launch_arguments: ProgramArguments = LocalProgramArguments.current + val internal_service_available: Boolean = remember(launch_arguments) { PlatformInternalPlayerService.Companion.isAvailable(context, launch_arguments) } + + if (internal_service_available) { + Button( + { + requestServiceChange(PlatformInternalPlayerService.Companion) + }, + item_modifier + ) { + Text(getString("loading_splash_button_launch_without_server")) + } + } + } + + override fun onRadioCancelled() { + super.onRadioCancelled() + server.onRadioCancelled() + } + + override fun getNotificationPlayer(player: Player): Player = + object : ForwardingPlayer(player) { + override fun play() { + server.play() + } + + override fun pause() { + server.pause() + } + + override fun seekToNext() { + server.seekToNext() + } + + override fun seekToNextMediaItem() { + server.seekToNext() + } + + override fun seekToPrevious() { + server.seekToPrevious() + } + + override fun seekToPreviousMediaItem() { + server.seekToPrevious() + } + + override fun seekTo(index: Int, position_ms: Long) { + server.seekToSong(index) + server.seekTo(position_ms) + } + } + + private val server: ExternalPlayerService = + object : ExternalPlayerService(plays_audio = true) { + override fun createServicePlayer(): PlayerServicePlayer = this@PlatformExternalPlayerService.service_player + } + + private val server_listener: PlayerListener = + object : PlayerListener() { + override fun onSongAdded(index: Int, song: Song) = this@PlatformExternalPlayerService.onSongAdded(index, song) + override fun onPlayingChanged(is_playing: Boolean) = this@PlatformExternalPlayerService.onPlayingChanged(is_playing) + override fun onSeeked(position_ms: Long) = this@PlatformExternalPlayerService.onSeeked(position_ms) + override fun onSongMoved(from: Int, to: Int) = this@PlatformExternalPlayerService.onSongMoved(from, to) + override fun onSongRemoved(index: Int, song: Song) = this@PlatformExternalPlayerService.onSongRemoved(index) + override fun onSongTransition(song: Song?, manual: Boolean) = this@PlatformExternalPlayerService.onSongTransition(current_song_index) + } + + private val player_listener: Player.Listener = + object : Player.Listener { + private var last_seek_position: Long? = null + + override fun onMediaItemTransition(mediaItem: ExoMediaItem?, reason: Int) { + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK && player.currentMediaItemIndex != current_song_index) { + server.seekToSong(player.currentMediaItemIndex) + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying == target_playing || player.playbackState != Player.STATE_READY) { + return + } + + if (isPlaying) { + server.play() + } + else { + server.pause() + } + } + + override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) { + if (newPosition.positionMs == target_seek) { + return + } + + if (reason == Player.DISCONTINUITY_REASON_SEEK && newPosition.positionMs != last_seek_position) { + last_seek_position = newPosition.positionMs + pause() + server.seekTo(newPosition.positionMs) + + if (player.playbackState == Player.STATE_READY) { + onPlaybackReady() + } + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY) { + onPlaybackReady() + } + } + } + + override fun onCreate() { + super.onCreate() + + player.addListener(player_listener) + + server._context = context + server.addListener(server_listener) + server.onCreate() + } + + override fun onDestroy() { + super.onDestroy() + server.onDestroy() + } + + private fun onSongAdded(index: Int, song: Song) { coroutine_scope.launch(Dispatchers.Main) { + super.addSong(song, index) + }} + private fun onPlayingChanged(is_playing: Boolean) { coroutine_scope.launch(Dispatchers.Main) { + target_playing = is_playing + if (is_playing) super.play() + else super.pause() + }} + private fun onSeeked(position_ms: Long) { coroutine_scope.launch(Dispatchers.Main) { + target_seek = position_ms + super.seekTo(position_ms) + }} + private fun onSongMoved(from: Int, to: Int) { coroutine_scope.launch(Dispatchers.Main) { + super.moveSong(from, to) + }} + private fun onSongRemoved(index: Int) { coroutine_scope.launch(Dispatchers.Main) { + super.removeSong(index) + }} + private fun onSongTransition(index: Int) { coroutine_scope.launch(Dispatchers.Main) { + if (index < 0 || index == player.currentMediaItemIndex) { + return@launch + } + try { + super.seekToSong(index) + } + catch (e: Throwable) { + throw RuntimeException("seekToSong($index) failed", e) + } + }} + + private fun onPlaybackReady() { + server.notifyReadyToPlay(super.duration_ms) + } + + override val load_state: PlayerServiceLoadState get() = server.load_state + override val state: SpMsPlayerState get() = server.state + override val is_playing: Boolean get() = server.is_playing + override val song_count: Int get() = server.song_count + override val current_song_index: Int get() = server.current_song_index + override val current_position_ms: Long get() = server.current_position_ms + override val duration_ms: Long get() = server.duration_ms + override val has_focus: Boolean get() = server.has_focus + override val radio_instance: RadioInstance get() = server.radio_instance + override var repeat_mode: SpMsPlayerRepeatMode + get() = server.repeat_mode + set(value) { server.repeat_mode = value } + override var volume: Float + get() = server.volume + set(value) { server.volume = value } + + override fun play() = server.play() + override fun pause() = server.pause() + override fun playPause() = server.playPause() + override fun seekTo(position_ms: Long) = server.seekTo(position_ms) + override fun seekToSong(index: Int) = server.seekToSong(index) + override fun seekToNext() = server.seekToNext() + override fun seekToPrevious() = server.seekToPrevious() + override fun getSong(): Song? = server.getSong() + override fun getSong(index: Int): Song? = server.getSong(index) + override fun addSong(song: Song, index: Int) = server.addSong(song, index) + override fun moveSong(from: Int, to: Int) = server.moveSong(from, to) + override fun removeSong(index: Int) = server.removeSong(index) + override fun addListener(listener: PlayerListener) = server.addListener(listener) + override fun removeListener(listener: PlayerListener) = server.removeListener(listener) + + actual companion object: InternalPlayerServiceCompanion(PlatformExternalPlayerService::class), PlayerServiceCompanion { + override fun isServiceRunning(context: AppContext): Boolean = true + override fun playsAudio(): Boolean = true + + override fun connect( + context: AppContext, + launch_arguments: ProgramArguments, + instance: PlayerService?, + onConnected: (PlayerService) -> Unit, + onDisconnected: () -> Unit + ): Any { + if (context.settings.platform.EXTERNAL_SERVER_MODE_UI_ONLY.get()) { + require(instance is ExternalPlayerService?) + val service: ExternalPlayerService = + if (instance != null) instance.also { it.setContext(context) } + else HeadlessExternalPlayerService().also { + it.setContext(context) + it.onCreate() + } + onConnected(service) + return service + } + else { + return super.connect(context, launch_arguments, instance, onConnected, onDisconnected) + } + } + + override fun disconnect(context: AppContext, connection: Any) { + if (connection is ExternalPlayerService) { + connection.onDestroy() + } + else { + super.disconnect(context, connection) + } + } + } +} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformInternalPlayerService.android.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformInternalPlayerService.android.kt new file mode 100644 index 000000000..da09ac6c1 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformInternalPlayerService.android.kt @@ -0,0 +1,22 @@ +package com.toasterofbread.spmp.platform.playerservice + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.toasterofbread.spmp.platform.AppContext +import ProgramArguments + +actual class PlatformInternalPlayerService: ForegroundPlayerService(play_when_ready = true), PlayerService { + actual companion object: InternalPlayerServiceCompanion(PlatformInternalPlayerService::class), PlayerServiceCompanion { + override fun playsAudio(): Boolean = true + } + + @Composable + actual override fun Visualiser( + colour: Color, + modifier: Modifier, + opacity: Float, + ) { + super.Visualiser(colour, modifier, opacity) + } +} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformPlayerService.android.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformPlayerService.android.kt deleted file mode 100644 index 900a54412..000000000 --- a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformPlayerService.android.kt +++ /dev/null @@ -1,851 +0,0 @@ -package com.toasterofbread.spmp.platform.playerservice - -import com.toasterofbread.spmp.shared.R -import android.app.ActivityManager -import android.app.PendingIntent -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.media.AudioDeviceCallback -import android.media.AudioDeviceInfo -import android.media.AudioManager -import android.media.MediaRouter -import android.media.audiofx.LoudnessEnhancer -import android.net.Uri -import android.os.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asAndroidBitmap -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import androidx.core.net.toUri -import androidx.media3.common.* -import androidx.media3.common.audio.SonicAudioProcessor -import androidx.media3.common.util.BitmapLoader -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DataSpec -import androidx.media3.datasource.DefaultDataSource -import androidx.media3.datasource.ResolvingDataSource -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.RenderersFactory -import androidx.media3.exoplayer.audio.* -import androidx.media3.exoplayer.audio.DefaultAudioSink.DefaultAudioProcessorChain -import androidx.media3.exoplayer.mediacodec.MediaCodecSelector -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy -import androidx.media3.extractor.mkv.MatroskaExtractor -import androidx.media3.extractor.mp4.FragmentedMp4Extractor -import androidx.media3.session.* -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture -import com.google.common.util.concurrent.MoreExecutors -import dev.toastbits.composekit.platform.PlatformPreferences -import dev.toastbits.composekit.platform.PlatformPreferencesListener -import com.toasterofbread.spmp.platform.visualiser.MusicVisualiser -import com.toasterofbread.spmp.platform.visualiser.FFTAudioProcessor -import dev.toastbits.ytmkt.model.external.ThumbnailProvider -import com.toasterofbread.spmp.model.mediaitem.loader.MediaItemThumbnailLoader -import com.toasterofbread.spmp.model.mediaitem.song.Song -import com.toasterofbread.spmp.model.mediaitem.song.SongLikedStatusListener -import com.toasterofbread.spmp.model.mediaitem.song.SongRef -import com.toasterofbread.spmp.model.mediaitem.song.updateLiked -import com.toasterofbread.spmp.platform.AppContext -import com.toasterofbread.spmp.platform.PlayerListener -import com.toasterofbread.spmp.platform.PlayerServiceCommand -import com.toasterofbread.spmp.platform.processMediaDataSpec -import com.toasterofbread.spmp.resources.getStringTODO -import dev.toastbits.ytmkt.endpoint.SetSongLikedEndpoint -import dev.toastbits.ytmkt.formats.VideoFormatsEndpoint -import dev.toastbits.ytmkt.model.external.SongLikedStatus -import com.toasterofbread.spmp.model.radio.RadioInstance -import com.toasterofbread.spmp.model.radio.RadioState -import kotlinx.coroutines.* -import spms.socketapi.shared.SpMsPlayerRepeatMode -import spms.socketapi.shared.SpMsPlayerState -import java.io.IOException -import java.util.concurrent.Executors -import kotlin.math.roundToInt - -private const val A13_MEDIA_NOTIFICATION_ASPECT = 2.9f / 5.7f - -fun getMediaNotificationImageMaxOffset(image: Bitmap): IntOffset { - val dimensions: IntSize = getMediaNotificationImageSize(image) - return IntOffset( - (image.width - dimensions.width) / 2, - (image.height - dimensions.height) / 2 - ) -} - -fun getMediaNotificationImageSize(image: Bitmap, square: Boolean = false): IntSize { - val aspect: Float = - if (!square && Build.VERSION.SDK_INT >= 33) A13_MEDIA_NOTIFICATION_ASPECT - else 1f - - if (image.width > image.height) { - return IntSize( - image.height, - (image.height * aspect).roundToInt() - ) - } - else { - return IntSize( - image.width, - (image.width * aspect).roundToInt() - ) - } -} - -private fun formatMediaNotificationImage( - image: Bitmap, - song: Song, - context: AppContext, -): Bitmap { - val offset: IntOffset = song.NotificationImageOffset.get(context.database) ?: IntOffset.Zero - val square: Boolean = offset.x == 0 && offset.y == 0 - val dimensions: IntSize = getMediaNotificationImageSize(image, square = square) - - return Bitmap.createBitmap( - image, - (((image.width - dimensions.width) / 2) + offset.x).coerceIn(0, image.width - dimensions.width), - (((image.height - dimensions.height) / 2) + offset.y).coerceIn(0, image.height - dimensions.height), - dimensions.width, - dimensions.height - ) -} - -private class PlayerBinder(val service: PlatformPlayerService): Binder() - -@androidx.annotation.OptIn(UnstableApi::class) -actual class PlatformPlayerService: MediaSessionService(), PlayerService { - actual val load_state: PlayerServiceLoadState = PlayerServiceLoadState(false) - actual val connection_error: Throwable? = null - - actual override val context: AppContext get() = _context - private lateinit var _context: AppContext - - private val coroutine_scope: CoroutineScope = CoroutineScope(Dispatchers.Main) - private lateinit var player: ExoPlayer - private lateinit var media_session: MediaSession - private lateinit var audio_sink: AudioSink - private var loudness_enhancer: LoudnessEnhancer? = null - - private var current_song: Song? = null - private var paused_by_device_disconnect: Boolean = false - private var device_connection_changed_playing_status: Boolean = false - - private val song_liked_listener: SongLikedStatusListener = SongLikedStatusListener { song, liked_status -> - if (song == current_song) { - updatePlayerCustomActions(liked_status) - } - } - - private fun LoudnessEnhancer.update(song: Song?) { - if (song == null || !context.settings.streaming.ENABLE_AUDIO_NORMALISATION.get()) { - enabled = false - return - } - - val loudness_db: Float? = song.LoudnessDb.get(context.database) - if (loudness_db == null) { - setTargetGain(0) - } - else { - setTargetGain((loudness_db * 100).toInt()) - } - - enabled = true - } - - private val player_listener: Player.Listener = object : Player.Listener { - private fun createLoudnessEnhancer(): LoudnessEnhancer { - loudness_enhancer?.also { - return it - } - - try { - loudness_enhancer = LoudnessEnhancer(player.audioSessionId) - return loudness_enhancer!! - } - catch (e: Throwable) { - throw RuntimeException("Creating loudness enhancer failed ${player.audioSessionId}", e) - } - } - - override fun onMediaItemTransition(media_item: MediaItem?, reason: Int) { - val song: Song? = media_item?.getSong() - if (song?.id == current_song?.id) { - return - } - - current_song = song - updatePlayerCustomActions() - - createLoudnessEnhancer().update(song) - } - - override fun onAudioSessionIdChanged(audioSessionId: Int) { - loudness_enhancer?.release() - createLoudnessEnhancer().apply { - update(current_song) - enabled = true - } - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - if (device_connection_changed_playing_status) { - device_connection_changed_playing_status = false - } - else { - paused_by_device_disconnect = false - } - } - } - - private val prefs_listener: PlatformPreferencesListener = - PlatformPreferencesListener { _, key -> - when (key) { - context.settings.streaming.ENABLE_AUDIO_NORMALISATION.key -> { - loudness_enhancer?.update(current_song) - } - context.settings.streaming.ENABLE_SILENCE_SKIPPING.key -> { - audio_sink.skipSilenceEnabled = context.settings.streaming.ENABLE_SILENCE_SKIPPING.get() - } - } - } - - private val audio_device_callback = object : AudioDeviceCallback() { - private fun isBluetoothAudio(device: AudioDeviceInfo): Boolean { - if (!device.isSink) { - return false - } - return device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP - } - private fun isWiredAudio(device: AudioDeviceInfo): Boolean { - if (!device.isSink) { - return false - } - return device.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || - device.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || - (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && device.type == AudioDeviceInfo.TYPE_USB_HEADSET) - } - - override fun onAudioDevicesAdded(addedDevices: Array) { - if (player.isPlaying || !paused_by_device_disconnect) { - return - } - - val resume_on_bt: Boolean = context.settings.player.RESUME_ON_BT_CONNECT.get() - val resume_on_wired: Boolean = context.settings.player.RESUME_ON_WIRED_CONNECT.get() - - for (device in addedDevices) { - if ((resume_on_bt && isBluetoothAudio(device)) || (resume_on_wired && isWiredAudio(device))) { - player.play() - break - } - } - } - - override fun onAudioDevicesRemoved(removedDevices: Array) { - if (!player.isPlaying && player.playbackState == Player.STATE_READY) { - return - } - - val pause_on_bt: Boolean = context.settings.player.PAUSE_ON_BT_DISCONNECT.get() - val pause_on_wired: Boolean = context.settings.player.PAUSE_ON_WIRED_DISCONNECT.get() - - for (device in removedDevices) { - if ((pause_on_bt && isBluetoothAudio(device)) || (pause_on_wired && isWiredAudio(device))) { - device_connection_changed_playing_status = true - paused_by_device_disconnect = true - player.pause() - break - } - } - } - } - - actual override fun addListener(listener: PlayerListener) { - listener.addToPlayer(player) - } - actual override fun removeListener(listener: PlayerListener) { - listener.removeFromPlayer(player) - } - - // If there's a better way to provide information to MediaControllers, I'd like to know - actual companion object { - val fft_audio_processor: FFTAudioProcessor = FFTAudioProcessor() - - private val listeners: MutableList = mutableListOf() - private var player_instance: PlatformPlayerService? by mutableStateOf(null) - - fun setInstance(value: PlatformPlayerService?) { - player_instance?.also { - for (listener in listeners) { - it.removeListener(listener) - } - } - - player_instance = value - - value?.also { - for (listener in listeners) { - it.addListener(listener) - } - } - } - - actual fun addListener(listener: PlayerListener) { - listeners.add(listener) - player_instance?.addListener(listener) - } - actual fun removeListener(listener: PlayerListener) { - listeners.remove(listener) - player_instance?.removeListener(listener) - } - - private fun AppContext.getAndroidContext(): Context = - ctx.applicationContext - - actual fun connect( - context: AppContext, - instance: PlatformPlayerService?, - onConnected: (PlatformPlayerService) -> Unit, - onDisconnected: () -> Unit, - ): Any { - val ctx: Context = context.getAndroidContext() - - val service_connection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - onConnected((service as PlayerBinder).service) - } - - override fun onServiceDisconnected(name: ComponentName?) { - onDisconnected() - } - } - - ctx.startService(Intent(ctx, PlatformPlayerService::class.java)) - ctx.bindService(Intent(ctx, PlatformPlayerService::class.java), service_connection, Context.BIND_AUTO_CREATE) - - return service_connection - } - - actual fun disconnect(context: AppContext, connection: Any) { - context.getAndroidContext().unbindService(connection as ServiceConnection) - } - - actual fun isServiceRunning(context: AppContext): Boolean { - val manager: ActivityManager = context.ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - for (service in manager.getRunningServices(Int.MAX_VALUE)) { - if (service.service.className == PlatformPlayerService::class.java.name) { - return true - } - } - return false - } - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - return START_NOT_STICKY - } - - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { - return media_session - } - - override fun onBind(intent: Intent?): IBinder? { - try { - val binder = super.onBind(intent) - if (binder != null) { - return binder - } - } - catch (_: Throwable) {} - - return PlayerBinder(this) - } - - actual override fun onCreate() { - super.onCreate() - - _context = AppContext(this, coroutine_scope) - _context.getPrefs().addListener(prefs_listener) - - initialiseSessionAndPlayer() - - _service_player = object : PlayerServicePlayer(this) { - override fun onUndoStateChanged() { - for (listener in listeners) { - listener.onUndoStateChanged() - } - } - } - - val audio_manager = getSystemService(AUDIO_SERVICE) as AudioManager? - audio_manager?.registerAudioDeviceCallback(audio_device_callback, null) - - setMediaNotificationProvider( - DefaultMediaNotificationProvider(this).apply { - setSmallIcon(R.drawable.ic_spmp) - } - ) - - SongLikedStatusListener.addListener(song_liked_listener) - - setInstance(this) - } - - actual override fun onDestroy() { - _context.getPrefs().removeListener(prefs_listener) - coroutine_scope.cancel() - service_player.release() - player.release() - media_session.release() - loudness_enhancer?.release() - SongLikedStatusListener.removeListener(song_liked_listener) - - val audio_manager = getSystemService(AUDIO_SERVICE) as AudioManager? - audio_manager?.unregisterAudioDeviceCallback(audio_device_callback) - - clearListener() - super.onDestroy() - } - - override fun onTaskRemoved(intent: Intent?) { - super.onTaskRemoved(intent) - - if ( - (!player.isPlaying && convertState(player.playbackState) != SpMsPlayerState.BUFFERING) - || ( - context.settings.behaviour.STOP_PLAYER_ON_APP_CLOSE.get() - && intent?.component?.packageName == packageName - ) - ) { - stopSelf() - onDestroy() - } - } - - private fun updatePlayerCustomActions(song_liked: SongLikedStatus? = null) { - coroutine_scope.launch(Dispatchers.Main) { - val actions: MutableList = mutableListOf() - - val liked: SongLikedStatus? = song_liked ?: current_song?.Liked?.get(context.database) - if (liked != null) { - actions.add( - CommandButton.Builder() - .setDisplayName( - when (liked) { - SongLikedStatus.NEUTRAL -> getStringTODO("Like") - SongLikedStatus.LIKED -> getStringTODO("Remove like") - SongLikedStatus.DISLIKED -> getStringTODO("Remove dislike") - } - ) - .setSessionCommand( - PlayerServiceCommand.SetLiked( - when (liked) { - SongLikedStatus.NEUTRAL -> SongLikedStatus.LIKED - SongLikedStatus.LIKED, SongLikedStatus.DISLIKED -> SongLikedStatus.NEUTRAL - } - ).getSessionCommand() - ) - .setIconResId( - when (liked) { - SongLikedStatus.NEUTRAL -> R.drawable.ic_thumb_up_off - SongLikedStatus.LIKED -> R.drawable.ic_thumb_up - SongLikedStatus.DISLIKED -> R.drawable.ic_thumb_down - } - ) - .build() - ) - } - - media_session.setCustomLayout(actions) - } - } - - private fun initialiseSessionAndPlayer() { - audio_sink = DefaultAudioSink.Builder(context.ctx) - .setAudioProcessorChain( - DefaultAudioProcessorChain( - arrayOf(fft_audio_processor), - SilenceSkippingAudioProcessor(), - SonicAudioProcessor() - ) - ) - .build() - - audio_sink.skipSilenceEnabled = context.settings.streaming.ENABLE_SILENCE_SKIPPING.get() - - val renderers_factory = RenderersFactory { handler: Handler?, _, audioListener: AudioRendererEventListener?, _, _ -> - arrayOf( - MediaCodecAudioRenderer( - this, - MediaCodecSelector.DEFAULT, - handler, - audioListener, - audio_sink - ) - ) - } - - player = ExoPlayer.Builder( - this, - renderers_factory, - DefaultMediaSourceFactory( - createDataSourceFactory(), - { arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) } - ) - .setLoadErrorHandlingPolicy( - object : LoadErrorHandlingPolicy { - override fun getFallbackSelectionFor( - fallbackOptions: LoadErrorHandlingPolicy.FallbackOptions, - loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo, - ): LoadErrorHandlingPolicy.FallbackSelection? { - return null - } - - override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long { - if (loadErrorInfo.exception.cause is VideoFormatsEndpoint.YoutubeMusicPremiumContentException) { - // Returning Long.MAX_VALUE leads to immediate retry, and returning C.TIME_UNSET cancels the notification entirely for some reason - return 10000000 - } - return 1000 * 10 - } - - override fun getMinimumLoadableRetryCount(dataType: Int): Int { - return Int.MAX_VALUE - } - } - ) - ) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) - .build(), - true - ) - .setWakeMode(C.WAKE_MODE_NETWORK) - .setUsePlatformDiagnostics(false) - .build() - - player.addListener(player_listener) - player.playWhenReady = true - player.prepare() - - val controller_future: ListenableFuture = - MediaController.Builder( - this, - SessionToken(this, ComponentName(this, PlatformPlayerService::class.java)) - ).buildAsync() - - controller_future.addListener( - { controller_future.get() }, - MoreExecutors.directExecutor() - ) - - media_session = MediaSession.Builder(this, player) - .setBitmapLoader(object : BitmapLoader { - val executor = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()) - - override fun decodeBitmap(data: ByteArray): ListenableFuture { - throw NotImplementedError() - } - - override fun loadBitmap(uri: Uri, options: BitmapFactory.Options?): ListenableFuture { - return executor.submit { - runBlocking { - val song = SongRef(uri.toString()) - var fail_error: Throwable? = null - - for (quality in ThumbnailProvider.Quality.byQuality()) { - val load_result = MediaItemThumbnailLoader.loadItemThumbnail(song, quality, context) - load_result.fold( - { image -> - return@runBlocking formatMediaNotificationImage( - image.asAndroidBitmap(), - song, - context - ) - }, - { error -> - if (fail_error == null) { - fail_error = error - } - } - ) - } - - throw fail_error!! - } - } - } - }) - .setSessionActivity( - PendingIntent.getActivity( - this, - 1, - packageManager.getLaunchIntentForPackage(packageName), - PendingIntent.FLAG_IMMUTABLE - ) - ) - .setCallback(object : MediaSession.Callback { - override fun onAddMediaItems( - media_session: MediaSession, - controller: MediaSession.ControllerInfo, - media_items: List, - ): ListenableFuture> { - val updated_media_items = media_items.map { item -> - item.buildUpon() - .setUri(item.requestMetadata.mediaUri) - .setMediaId(item.requestMetadata.mediaUri.toString()) - .build() - } - return Futures.immediateFuture(updated_media_items) - } - - override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { - val result = super.onConnect(session, controller) - val session_commands = result.availableSessionCommands - .buildUpon() - - for (command in PlayerServiceCommand.getBaseSessionCommands()) { - session_commands.add(command) - } - - return MediaSession.ConnectionResult.accept(session_commands.build(), result.availablePlayerCommands) - } - - override fun onCustomCommand( - session: MediaSession, - controller: MediaSession.ControllerInfo, - customCommand: SessionCommand, - args: Bundle, - ): ListenableFuture { - val command: PlayerServiceCommand? = PlayerServiceCommand.fromSessionCommand(customCommand, args) - if (command == null) { - return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_BAD_VALUE)) - } - - val result: Bundle = onPlayerServiceCommand(command) - return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS, result)) - } - }) - .build() - } - - private fun createDataSourceFactory(): DataSource.Factory { - return ResolvingDataSource.Factory({ - DefaultDataSource.Factory(this).createDataSource() - }) { data_spec: DataSpec -> - try { - return@Factory runBlocking { - processMediaDataSpec(data_spec, context, context.isConnectionMetered()).also { - loudness_enhancer?.update(current_song) - } - } - } - catch (e: Throwable) { - throw IOException(e) - } - } - } - - private fun onPlayerServiceCommand(command: PlayerServiceCommand): Bundle { - when (command) { - is PlayerServiceCommand.SetLiked -> { - val song = current_song - if (song != null) { - coroutine_scope.launch { - val endpoint: SetSongLikedEndpoint? = context.ytapi.user_auth_state?.SetSongLiked - if (endpoint?.isImplemented() == true) { - song.updateLiked( - command.value, - endpoint, - context - ) - } - } - } - } - } - - return Bundle.EMPTY - } - - private lateinit var _service_player: PlayerServicePlayer - actual override val service_player: PlayerServicePlayer get() = _service_player - actual override val state: SpMsPlayerState get() = convertState(player.playbackState) - actual override val is_playing: Boolean get() = player.isPlaying - actual override val song_count: Int get() = player.mediaItemCount - actual override val current_song_index: Int get() = player.currentMediaItemIndex - actual override val current_position_ms: Long get() = player.currentPosition - actual override val duration_ms: Long get() = player.duration - actual override val radio_instance: RadioInstance get() = service_player.radio_instance - actual override var repeat_mode: SpMsPlayerRepeatMode - get() = SpMsPlayerRepeatMode.entries[player.repeatMode] - set(value) { - player.repeatMode = value.ordinal - } - actual override var volume: Float - get() = player.volume - set(value) { - player.volume = value - } - actual override val has_focus: Boolean - get() = TODO() - - actual override fun isPlayingOverLatentDevice(): Boolean { - val media_router: MediaRouter = (getSystemService(MEDIA_ROUTER_SERVICE) as MediaRouter?) ?: return false - val selected_route: MediaRouter.RouteInfo = media_router.getSelectedRoute(MediaRouter.ROUTE_TYPE_LIVE_AUDIO) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - return selected_route.deviceType == MediaRouter.RouteInfo.DEVICE_TYPE_BLUETOOTH - } - else { - return false - } - } - - actual override fun play() { - player.play() - } - - actual override fun pause() { - player.pause() - } - - actual override fun playPause() { - if (player.isPlaying) { - player.pause() - } - else { - player.play() - } - } - - private val song_seek_undo_stack: MutableList> = mutableListOf() - private fun getSeekPosition(): Pair = Pair(current_song_index, current_position_ms) - - actual override fun seekTo(position_ms: Long) { - val current: Pair = getSeekPosition() - player.seekTo(position_ms) - listeners.forEach { it.onSeeked(position_ms) } - - if (current != getSeekPosition()) { - song_seek_undo_stack.add(current) - } - } - - actual override fun seekToSong(index: Int) { - val current: Pair = getSeekPosition() - player.seekTo(index, 0) - - if (current != getSeekPosition()) { - song_seek_undo_stack.add(current) - } - } - - actual override fun seekToNext() { - val current: Pair = getSeekPosition() - player.seekToNext() - - if (current != getSeekPosition()) { - song_seek_undo_stack.add(current) - } - } - - actual override fun seekToPrevious() { - val current: Pair = getSeekPosition() - player.seekToPrevious() - - if (current != getSeekPosition()) { - song_seek_undo_stack.add(current) - } - } - - actual override fun undoSeek() { - val (index: Int, position_ms: Long) = song_seek_undo_stack.removeLastOrNull() ?: return - - if (index != current_song_index) { - player.seekTo(index, position_ms) - } - else { - player.seekTo(position_ms) - } - } - - actual override fun getSong(): Song? { - return player.currentMediaItem?.getSong() - } - - actual override fun getSong(index: Int): Song? { - if (index !in 0 until song_count) { - return null - } - - return player.getMediaItemAt(index).getSong() - } - - actual override fun addSong(song: Song, index: Int) { - player.addMediaItem(index, song.buildExoMediaItem(context)) - listeners.forEach { it.onSongAdded(index, song) } - - service_player.session_started = true - } - - actual override fun moveSong(from: Int, to: Int) { - player.moveMediaItem(from, to) - listeners.forEach { it.onSongMoved(from, to) } - } - - actual override fun removeSong(index: Int) { - val item = player.getMediaItemAt(index).getSong() - player.removeMediaItem(index) - listeners.forEach { it.onSongRemoved(index, item) } - } - - @Composable - actual override fun Visualiser(colour: Color, modifier: Modifier, opacity: Float) { - val visualiser: MusicVisualiser = remember { MusicVisualiser(fft_audio_processor) } - visualiser.Visualiser(colour, modifier, opacity) - } -} - -@UnstableApi -private fun Song.buildExoMediaItem(context: AppContext): MediaItem = - MediaItem.Builder() - .setRequestMetadata(MediaItem.RequestMetadata.Builder().setMediaUri(id.toUri()).build()) - .setUri(id) - .setCustomCacheKey(id) - .setMediaMetadata( - MediaMetadata.Builder() - .apply { - val db = context.database - - setArtworkUri(id.toUri()) - setTitle(getActiveTitle(db)) - setArtist(Artists.get(db)?.firstOrNull()?.getActiveTitle(db)) - - val album = Album.get(db) - setAlbumTitle(album?.getActiveTitle(db)) - setAlbumArtist(album?.Artists?.get(db)?.firstOrNull()?.getActiveTitle(db)) - } - .build() - ) - .build() - -fun convertState(exo_state: Int): SpMsPlayerState { - return SpMsPlayerState.entries[exo_state - 1] -} - -fun MediaItem.getSong(): Song { - return SongRef(mediaMetadata.artworkUri.toString()) -} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMs.android.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMs.android.kt index 76047e8f5..6c0f7556e 100644 --- a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMs.android.kt +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMs.android.kt @@ -1,8 +1,7 @@ package com.toasterofbread.spmp.platform.playerservice -actual fun getSpMsMachineId(): String { - TODO("Not yet implemented") -} +import com.toasterofbread.spmp.platform.AppContext -actual fun getServerExecutableFilename(): String? = - null +actual fun getSpMsMachineId(context: AppContext): String { + return getSpMsMachineIdFromFile(context.getFilesDir().resolve("spmp_machine_id.txt")) +} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/createDataSourceFactory.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/createDataSourceFactory.kt new file mode 100644 index 000000000..e20f3b38e --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/createDataSourceFactory.kt @@ -0,0 +1,26 @@ +package com.toasterofbread.spmp.platform.playerservice + +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.ResolvingDataSource +import com.toasterofbread.spmp.platform.processMediaDataSpec +import kotlinx.coroutines.runBlocking +import java.io.IOException + +internal fun ForegroundPlayerService.createDataSourceFactory(): DataSource.Factory { + return ResolvingDataSource.Factory({ + DefaultDataSource.Factory(this).createDataSource() + }) { data_spec: DataSpec -> + try { + return@Factory runBlocking { + processMediaDataSpec(data_spec, context, context.isConnectionMetered()).also { + loudness_enhancer?.update(current_song, context) + } + } + } + catch (e: Throwable) { + throw IOException(e) + } + } +} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/initialiseSessionAndPlayer.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/initialiseSessionAndPlayer.kt new file mode 100644 index 000000000..fc2c19f1b --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/initialiseSessionAndPlayer.kt @@ -0,0 +1,219 @@ +package com.toasterofbread.spmp.platform.playerservice + +import android.app.PendingIntent +import android.content.ComponentName +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.audio.SonicAudioProcessor +import androidx.media3.common.util.BitmapLoader +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.RenderersFactory +import androidx.media3.exoplayer.audio.AudioRendererEventListener +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink.DefaultAudioProcessorChain +import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer +import androidx.media3.exoplayer.audio.SilenceSkippingAudioProcessor +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy +import androidx.media3.extractor.mkv.MatroskaExtractor +import androidx.media3.extractor.mp4.FragmentedMp4Extractor +import androidx.media3.session.* +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors +import dev.toastbits.ytmkt.model.external.ThumbnailProvider +import com.toasterofbread.spmp.model.mediaitem.loader.MediaItemThumbnailLoader +import com.toasterofbread.spmp.model.mediaitem.song.SongRef +import com.toasterofbread.spmp.model.settings.category.StreamingSettings +import com.toasterofbread.spmp.platform.PlayerServiceCommand +import dev.toastbits.ytmkt.formats.VideoFormatsEndpoint +import kotlinx.coroutines.runBlocking +import java.util.concurrent.Executors + +internal fun ForegroundPlayerService.initialiseSessionAndPlayer( + play_when_ready: Boolean, + playlist_auto_progress: Boolean, + getNotificationPlayer: (ExoPlayer) -> Player = { it } +) { + audio_sink = DefaultAudioSink.Builder(context.ctx) + .setAudioProcessorChain( + DefaultAudioProcessorChain( + arrayOf(ForegroundPlayerService.fft_audio_processor), + SilenceSkippingAudioProcessor(), + SonicAudioProcessor() + ) + ) + .build() + + audio_sink.skipSilenceEnabled = context.settings.streaming.ENABLE_SILENCE_SKIPPING.get() + + val renderers_factory: RenderersFactory = + RenderersFactory { handler: Handler?, _, audioListener: AudioRendererEventListener?, _, _ -> + arrayOf( + MediaCodecAudioRenderer( + this, + MediaCodecSelector.DEFAULT, + handler, + audioListener, + audio_sink + ) + ) + } + + player = ExoPlayer.Builder( + this, + renderers_factory, + DefaultMediaSourceFactory( + createDataSourceFactory(), + { arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) } + ) + .setLoadErrorHandlingPolicy( + object : LoadErrorHandlingPolicy { + override fun getFallbackSelectionFor( + fallbackOptions: LoadErrorHandlingPolicy.FallbackOptions, + loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo, + ): LoadErrorHandlingPolicy.FallbackSelection? { + return null + } + + override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long { + if (loadErrorInfo.exception.cause is VideoFormatsEndpoint.YoutubeMusicPremiumContentException) { + // Returning Long.MAX_VALUE leads to immediate retry, and returning C.TIME_UNSET cancels the notification entirely for some reason + return 10000000 + } + return 1000 * 10 + } + + override fun getMinimumLoadableRetryCount(dataType: Int): Int { + return Int.MAX_VALUE + } + } + ) + ) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), + true + ) + .setWakeMode(C.WAKE_MODE_NETWORK) + .setUsePlatformDiagnostics(false) + .build() + + val player_listener: InternalPlayerServicePlayerListener = InternalPlayerServicePlayerListener(this) + player.addListener(player_listener) + + player.playWhenReady = play_when_ready + player.pauseAtEndOfMediaItems = !playlist_auto_progress + player.prepare() + + val controller_future: ListenableFuture = + MediaController.Builder( + this, + SessionToken(this, ComponentName(this, this::class.java)) + ).buildAsync() + + controller_future.addListener( + { controller_future.get() }, + MoreExecutors.directExecutor() + ) + + media_session = MediaSession.Builder(this, getNotificationPlayer(player)) + .setBitmapLoader(object : BitmapLoader { + val executor = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()) + + override fun decodeBitmap(data: ByteArray): ListenableFuture { + throw NotImplementedError() + } + + override fun loadBitmap(uri: Uri, options: BitmapFactory.Options?): ListenableFuture { + return executor.submit { + runBlocking { + val song = SongRef(uri.toString()) + var fail_error: Throwable? = null + + for (quality in ThumbnailProvider.Quality.byQuality()) { + val load_result = MediaItemThumbnailLoader.loadItemThumbnail(song, quality, context) + load_result.fold( + { image -> + return@runBlocking formatMediaNotificationImage( + image.asAndroidBitmap(), + song, + context + ) + }, + { error -> + if (fail_error == null) { + fail_error = error + } + } + ) + } + + throw fail_error!! + } + } + } + }) + .setSessionActivity( + PendingIntent.getActivity( + this, + 1, + packageManager.getLaunchIntentForPackage(packageName), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .setCallback(object : MediaSession.Callback { + override fun onAddMediaItems( + media_session: MediaSession, + controller: MediaSession.ControllerInfo, + media_items: List, + ): ListenableFuture> { + val updated_media_items = media_items.map { item -> + item.buildUpon() + .setUri(item.requestMetadata.mediaUri) + .setMediaId(item.requestMetadata.mediaUri.toString()) + .build() + } + return Futures.immediateFuture(updated_media_items) + } + + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { + val result = super.onConnect(session, controller) + val session_commands = result.availableSessionCommands + .buildUpon() + + for (command in PlayerServiceCommand.getBaseSessionCommands()) { + session_commands.add(command) + } + + return MediaSession.ConnectionResult.accept(session_commands.build(), result.availablePlayerCommands) + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle, + ): ListenableFuture { + val command: PlayerServiceCommand? = PlayerServiceCommand.fromSessionCommand(customCommand, args) + if (command == null) { + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_BAD_VALUE)) + } + + val result: Bundle = onPlayerServiceCommand(command) + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS, result)) + } + }) + .build() +} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/updatePlayerCustomActions.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/updatePlayerCustomActions.kt new file mode 100644 index 000000000..4bb6381da --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/playerservice/updatePlayerCustomActions.kt @@ -0,0 +1,47 @@ +package com.toasterofbread.spmp.platform.playerservice + +import com.toasterofbread.spmp.shared.R +import androidx.media3.session.CommandButton +import dev.toastbits.ytmkt.model.external.SongLikedStatus +import com.toasterofbread.spmp.platform.PlayerServiceCommand +import com.toasterofbread.spmp.resources.getStringTODO +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal fun ForegroundPlayerService.updatePlayerCustomActions(song_liked: SongLikedStatus? = null) { + coroutine_scope.launch(Dispatchers.Main) { + val actions: MutableList = mutableListOf() + + val liked: SongLikedStatus? = song_liked ?: current_song?.Liked?.get(context.database) + if (liked != null) { + actions.add( + CommandButton.Builder() + .setDisplayName( + when (liked) { + SongLikedStatus.NEUTRAL -> getStringTODO("Like") + SongLikedStatus.LIKED -> getStringTODO("Remove like") + SongLikedStatus.DISLIKED -> getStringTODO("Remove dislike") + } + ) + .setSessionCommand( + PlayerServiceCommand.SetLiked( + when (liked) { + SongLikedStatus.NEUTRAL -> SongLikedStatus.LIKED + SongLikedStatus.LIKED, SongLikedStatus.DISLIKED -> SongLikedStatus.NEUTRAL + } + ).getSessionCommand() + ) + .setIconResId( + when (liked) { + SongLikedStatus.NEUTRAL -> R.drawable.ic_thumb_up_off + SongLikedStatus.LIKED -> R.drawable.ic_thumb_up + SongLikedStatus.DISLIKED -> R.drawable.ic_thumb_down + } + ) + .build() + ) + } + + media_session.setCustomLayout(actions) + } +} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/ProcessMediaDataSpec.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/processMediaDataSpec.kt similarity index 98% rename from shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/ProcessMediaDataSpec.kt rename to shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/processMediaDataSpec.kt index 97054f11a..6fce71a74 100644 --- a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/ProcessMediaDataSpec.kt +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/processMediaDataSpec.kt @@ -1,7 +1,6 @@ package com.toasterofbread.spmp.platform import android.net.Uri -import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSpec import dev.toastbits.composekit.platform.PlatformFile import com.toasterofbread.spmp.model.mediaitem.db.getPlayCount @@ -16,7 +15,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -@UnstableApi internal suspend fun processMediaDataSpec(data_spec: DataSpec, context: AppContext, metered: Boolean): DataSpec { val song: SongRef = SongRef(data_spec.uri.toString()) diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/splash/ExtraLoadingContent.android.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/splash/ExtraLoadingContent.android.kt deleted file mode 100644 index 6d66b9433..000000000 --- a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/splash/ExtraLoadingContent.android.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.toasterofbread.spmp.platform.splash - -import ProgramArguments -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -actual fun SplashExtraLoadingContent(modifier: Modifier, arguments: ProgramArguments) {} diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/visualiser/LICENSE b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/visualiser/LICENSE new file mode 100644 index 000000000..4849cb7fd --- /dev/null +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/visualiser/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Dániel Zolnai + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/visualiser/MusicVisualiser.android.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/visualiser/MusicVisualiser.android.kt index 190754aa0..68af0e74e 100644 --- a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/visualiser/MusicVisualiser.android.kt +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/visualiser/MusicVisualiser.android.kt @@ -16,7 +16,7 @@ import kotlin.math.cos import kotlin.math.floor import kotlin.math.pow -// https://github.com/dzolnai/Visualiser +// https://github.com/dzolnai/ExoVisualizer // Modified for use with Compose // Taken from: https://en.wikipedia.org/wiki/Preferred_number#Audio_frequencies diff --git a/shared/src/commonMain/kotlin/ProgramArguments.kt b/shared/src/commonMain/kotlin/ProgramArguments.kt index a5d12cf9f..8f72e4b39 100644 --- a/shared/src/commonMain/kotlin/ProgramArguments.kt +++ b/shared/src/commonMain/kotlin/ProgramArguments.kt @@ -1,9 +1,11 @@ import com.toasterofbread.spmp.ProjectBuildConfig import com.toasterofbread.spmp.resources.getString -import spms.socketapi.shared.SPMS_API_VERSION +import dev.toastbits.spms.socketapi.shared.SPMS_API_VERSION +import dev.toastbits.composekit.platform.PlatformContext +import dev.toastbits.composekit.platform.PlatformFile +import java.io.File data class ProgramArguments( - val bin_dir: String? = null, val no_auto_server: Boolean = false, val is_flatpak: Boolean = false ) { @@ -30,12 +32,6 @@ data class ProgramArguments( println(getVersionMessage()) return null } - "--bin-dir" -> { - if (value == null && !iterator.hasNext()) { - onIllegalArgument("No value passed for argument '$name'.") - } - arguments = arguments.copy(bin_dir = value ?: iterator.next()) - } "--disable-auto-server", "-ds" -> { arguments = arguments.copy(no_auto_server = true) } diff --git a/shared/src/commonMain/kotlin/SpMp.kt b/shared/src/commonMain/kotlin/SpMp.kt index 551ccc77e..af30edaba 100644 --- a/shared/src/commonMain/kotlin/SpMp.kt +++ b/shared/src/commonMain/kotlin/SpMp.kt @@ -1,16 +1,21 @@ @file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.background import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp import dev.toastbits.composekit.platform.Platform import dev.toastbits.composekit.platform.PlatformPreferences import dev.toastbits.composekit.utils.common.thenIf @@ -24,9 +29,9 @@ import com.toasterofbread.spmp.resources.getStringOrNull import com.toasterofbread.spmp.resources.initResources import com.toasterofbread.spmp.service.playercontroller.PlayerState import com.toasterofbread.spmp.service.playercontroller.openUri -import com.toasterofbread.spmp.ui.layout.apppage.mainpage.LoadingSplashView import com.toasterofbread.spmp.ui.layout.apppage.mainpage.RootView -import com.toasterofbread.spmp.ui.layout.apppage.mainpage.SplashMode +import com.toasterofbread.spmp.ui.layout.loadingsplash.LoadingSplash +import com.toasterofbread.spmp.ui.layout.loadingsplash.SplashMode import com.toasterofbread.spmp.ui.layout.nowplaying.PlayerExpansionState import com.toasterofbread.spmp.model.appaction.shortcut.LocalShortcutState import com.toasterofbread.spmp.model.appaction.shortcut.ShortcutState @@ -34,10 +39,12 @@ import com.toasterofbread.spmp.ui.theme.ApplicationTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel -import spms.socketapi.shared.SPMS_API_VERSION +import kotlinx.coroutines.launch +import dev.toastbits.spms.socketapi.shared.SPMS_API_VERSION import java.util.logging.Logger import org.jetbrains.compose.resources.FontResource import org.jetbrains.compose.resources.Font +import ProgramArguments val LocalPlayerState: ProvidableCompositionLocal = staticCompositionLocalOf { SpMp.player_state } val LocalProgramArguments: ProvidableCompositionLocal = staticCompositionLocalOf { ProgramArguments() } @@ -67,8 +74,11 @@ object SpMp { initResources(context.getUiLanguage(), context) } - fun initPlayer(composable_coroutine_scope: CoroutineScope): PlayerState { - val player: PlayerState = PlayerState(context, composable_coroutine_scope) + fun initPlayer( + launch_arguments: ProgramArguments, + composable_coroutine_scope: CoroutineScope + ): PlayerState { + val player: PlayerState = PlayerState(context, launch_arguments, composable_coroutine_scope) player.onStart() _player_state = player return player @@ -100,6 +110,8 @@ object SpMp { context.theme.Update() shortcut_state.ObserveState() + val coroutine_scope: CoroutineScope = rememberCoroutineScope() + DisposableEffect(window_fullscreen_toggler) { SpMp.window_fullscreen_toggler = window_fullscreen_toggler onDispose { @@ -127,20 +139,32 @@ object SpMp { ) { var mismatched_server_api_version: Int? by remember { mutableStateOf(null) } val splash_mode: SplashMode? = when (Platform.current) { - Platform.ANDROID -> null - Platform.DESKTOP -> if (!player_state.service_connected) SplashMode.SPLASH else null + Platform.ANDROID -> + if (!player_state.service_connected && player_state.settings.platform.ENABLE_EXTERNAL_SERVER_MODE.get()) SplashMode.SPLASH + else null + Platform.DESKTOP -> + if (!player_state.service_connected) SplashMode.SPLASH + else null } - LoadingSplashView( + LoadingSplash( splash_mode, - player_state.service_loading_message, - player_state.service_connection_error, - arguments, - Modifier + player_state.service_load_state, + requestServiceChange = { service_companion -> + if (!service_companion.isAvailable(player_state.context, arguments)) { + return@LoadingSplash + } + + coroutine_scope.launch { + player_state.requestServiceChange(service_companion) + } + }, + modifier = Modifier .fillMaxSize() .thenIf(splash_mode != null) { pointerInput(Unit) {} - } + }, + content_padding = PaddingValues(30.dp), ) LaunchedEffect(splash_mode) { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/lyrics/SongLyrics.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/lyrics/SongLyrics.kt index 43b130c3b..8f7603bba 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/lyrics/SongLyrics.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/lyrics/SongLyrics.kt @@ -73,18 +73,18 @@ data class SongLyrics( get() = start!! .. end!! } - init { - lazyAssert { - synchronized(lines) { - for (line in lines) { - for (term in line) { - if (sync_type != SyncType.NONE && (term.start == null || term.end == null)) { - return@lazyAssert false - } - } - } - } - return@lazyAssert true - } - } + // init { + // lazyAssert { + // synchronized(lines) { + // for (line in lines) { + // for (term in line) { + // if (sync_type != SyncType.NONE && (term.start == null || term.end == null)) { + // return@lazyAssert false + // } + // } + // } + // } + // return@lazyAssert true + // } + // } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/song/Song.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/song/Song.kt index 63d5acc52..dc21245d1 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/song/Song.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/song/Song.kt @@ -18,7 +18,7 @@ import com.toasterofbread.spmp.model.mediaitem.playlist.RemotePlaylistRef import com.toasterofbread.spmp.model.settings.category.ThemeSettings import com.toasterofbread.spmp.platform.AppContext import com.toasterofbread.spmp.platform.crop -import com.toasterofbread.spmp.platform.playerservice.PlatformPlayerService +import com.toasterofbread.spmp.platform.playerservice.PlayerService import com.toasterofbread.spmp.platform.toImageBitmap import com.toasterofbread.spmp.youtubeapi.lyrics.LyricsReference import com.toasterofbread.spmp.youtubeapi.lyrics.toLyricsReference @@ -179,7 +179,7 @@ interface Song: MediaItem.WithArtists { @Composable fun getLyricsSyncOffset(database: Database, is_topbar: Boolean): State { val player: PlayerState = LocalPlayerState.current - val controller: PlatformPlayerService = player.controller ?: return mutableStateOf(0) + val controller: PlayerService = player.controller ?: return mutableStateOf(0) val internal_offset: Long? by LyricsSyncOffset.observe(database) val settings_delay: Float by player.settings.lyrics.SYNC_DELAY.observe() diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/radio/RadioInstance.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/radio/RadioInstance.kt index bfd6885e9..171922506 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/radio/RadioInstance.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/radio/RadioInstance.kt @@ -54,7 +54,7 @@ abstract class RadioInstance(val context: AppContext) { state = state.copy(current_filter_index = filter_index) } - fun cancelRadio() { + open fun cancelRadio() { cancelCurrentJob() state = RadioState() } @@ -191,5 +191,8 @@ abstract class RadioInstance(val context: AppContext) { return filtered } + + override fun toString(): String = + "RadioInstance(state=$state, is_loading=$is_loading, load_error=$load_error)" } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/radio/RadioState.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/radio/RadioState.kt index a4b4d986f..06c5ac3ae 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/radio/RadioState.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/radio/RadioState.kt @@ -29,7 +29,7 @@ data class RadioState( val current_filter_index: Int? = null ) { fun isContinuationAvailable(): Boolean = - continuation != null || !initial_songs_loaded + continuation != null || (item_uid != null && !initial_songs_loaded) internal suspend fun loadContinuation(context: AppContext): Result = runCatching { if (item_uid == null) { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/Settings.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/Settings.kt index fa58c3d46..f25d7dbf0 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/Settings.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/Settings.kt @@ -22,7 +22,7 @@ class Settings(context: AppContext) { val filter: FilterSettings = FilterSettings(context) val streaming: StreamingSettings = StreamingSettings(context) val shortcut: ShortcutSettings = ShortcutSettings(context) - val desktop: DesktopSettings = DesktopSettings(context) + val platform: PlatformSettings = PlatformSettings(context) val misc: MiscSettings = MiscSettings(context) val deps: DependencySettings = DependencySettings(context) val search: SearchSettings = SearchSettings(context) @@ -44,7 +44,7 @@ class Settings(context: AppContext) { filter, streaming, shortcut, - desktop, + platform, misc, deps, diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/DesktopSettings.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/PlatformSettings.kt similarity index 58% rename from shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/DesktopSettings.kt rename to shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/PlatformSettings.kt index c565f9f75..c27bdf2c0 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/DesktopSettings.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/PlatformSettings.kt @@ -2,15 +2,16 @@ package com.toasterofbread.spmp.model.settings.category import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.DesktopWindows +import androidx.compose.material.icons.outlined.Android import dev.toastbits.composekit.platform.Platform import dev.toastbits.composekit.platform.PreferencesProperty import dev.toastbits.composekit.platform.PlatformPreferences import com.toasterofbread.spmp.ProjectBuildConfig import com.toasterofbread.spmp.resources.getString -import com.toasterofbread.spmp.ui.layout.apppage.settingspage.category.getDesktopCategoryItems +import com.toasterofbread.spmp.ui.layout.apppage.settingspage.category.getPlatformCategoryItems import com.toasterofbread.spmp.platform.AppContext -class DesktopSettings(val context: AppContext): SettingsGroup("DESKTOP", context.getPrefs()) { +class PlatformSettings(val context: AppContext): SettingsGroup("DESKTOP", context.getPrefs()) { val STARTUP_COMMAND: PreferencesProperty by property( getName = { getString("s_key_startup_command") }, getDescription = { getString("s_sub_startup_command") }, @@ -41,19 +42,37 @@ class DesktopSettings(val context: AppContext): SettingsGroup("DESKTOP", context getDescription = { getString("s_sub_server_local_start_automatically") }, getDefaultValue = { true } ) - val SERVER_KILL_CHILD_ON_EXIT: PreferencesProperty by property( - getName = { getString("s_key_server_kill_child_on_exit") }, - getDescription = { null }, - getDefaultValue = { true } + val ENABLE_EXTERNAL_SERVER_MODE: PreferencesProperty by property( + getName = { getString("s_key_enable_external_server_mode") }, + getDescription = { getString("s_sub_enable_external_server_mode") }, + getDefaultValue = { false } + ) + val EXTERNAL_SERVER_MODE_UI_ONLY: PreferencesProperty by property( + getName = { getString("s_key_external_server_mode_ui_only") }, + getDescription = { getString("s_sub_external_server_mode_ui_only") }, + getDefaultValue = { false } ) override val page: CategoryPage? = - if (Platform.DESKTOP.isCurrent()) - SimplePage( - { getString("s_cat_desktop") }, - { getString("s_cat_desc_desktop") }, - { getDesktopCategoryItems(context) }, - { Icons.Outlined.DesktopWindows } - ) - else null + SimplePage( + { + when (Platform.current) { + Platform.ANDROID -> getString("s_cat_android") + Platform.DESKTOP -> getString("s_cat_desktop") + } + }, + { + when (Platform.current) { + Platform.ANDROID -> getString("s_cat_desc_android") + Platform.DESKTOP -> getString("s_cat_desc_desktop") + } + }, + { getPlatformCategoryItems(context) }, + { + when (Platform.current) { + Platform.ANDROID -> Icons.Outlined.Android + Platform.DESKTOP -> Icons.Outlined.DesktopWindows + } + } + ) } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/AppContext.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/AppContext.kt index afcd4decd..dc12728ab 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/AppContext.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/AppContext.kt @@ -1,5 +1,6 @@ package com.toasterofbread.spmp.platform +import ProgramArguments import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/PlayerListener.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/PlayerListener.kt index 24a6478cd..b50ce4d2b 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/PlayerListener.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/PlayerListener.kt @@ -1,8 +1,8 @@ package com.toasterofbread.spmp.platform import com.toasterofbread.spmp.model.mediaitem.song.Song -import spms.socketapi.shared.SpMsPlayerRepeatMode -import spms.socketapi.shared.SpMsPlayerState +import dev.toastbits.spms.socketapi.shared.SpMsPlayerRepeatMode +import dev.toastbits.spms.socketapi.shared.SpMsPlayerState expect abstract class PlayerListener() { open fun onSongTransition(song: Song?, manual: Boolean) diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ClientServerPlayerService.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ClientServerPlayerService.kt index 87057b3c2..7fa7ee64d 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ClientServerPlayerService.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ClientServerPlayerService.kt @@ -3,7 +3,7 @@ package com.toasterofbread.spmp.platform.playerservice import com.toasterofbread.spmp.model.mediaitem.song.Song import com.toasterofbread.spmp.platform.download.DownloadStatus import io.ktor.http.Headers -import spms.socketapi.shared.SpMsClientInfo +import dev.toastbits.spms.socketapi.shared.SpMsClientInfo interface ClientServerPlayerService: PlayerService { data class ServerInfo( diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ExternalPlayerService.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ExternalPlayerService.kt new file mode 100644 index 000000000..6c4effa90 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ExternalPlayerService.kt @@ -0,0 +1,354 @@ +package com.toasterofbread.spmp.platform.playerservice + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Icon +import androidx.compose.material3.AlertDialog +import androidx.compose.animation.Crossfade +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.Icons +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.BorderStroke +import com.toasterofbread.spmp.model.mediaitem.song.Song +import com.toasterofbread.spmp.model.radio.RadioInstance +import com.toasterofbread.spmp.platform.AppContext +import com.toasterofbread.spmp.service.playercontroller.RadioHandler +import com.toasterofbread.spmp.service.playercontroller.PlayerState +import com.toasterofbread.spmp.resources.getString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.Job +import kotlinx.serialization.json.JsonPrimitive +import dev.toastbits.spms.socketapi.shared.SpMsPlayerRepeatMode +import dev.toastbits.spms.socketapi.shared.SpMsPlayerState +import dev.toastbits.composekit.platform.PlatformPreferencesListener +import dev.toastbits.composekit.platform.PlatformPreferences +import io.ktor.client.request.get +import LocalPlayerState +import LocalProgramArguments + +open class ExternalPlayerService(plays_audio: Boolean): SpMsPlayerService(plays_audio = plays_audio), PlayerService { + override val load_state: PlayerServiceLoadState get() = + (local_server_error ?: connect_error)?.let { + socket_load_state.copy(error = it) + } ?: socket_load_state + + private var connect_error: Throwable? by mutableStateOf(null) + private var local_server_error: Throwable? by mutableStateOf(null) + private var local_server_process: Job? by mutableStateOf(null) + + override fun getIpAddress(): String = + if (local_server_process != null) "127.0.0.1" else context.settings.platform.SERVER_IP_ADDRESS.get() + override fun getPort(): Int = + context.settings.platform.SERVER_PORT.get() + + internal lateinit var _context: AppContext + override val context: AppContext get() = _context + + internal fun setContext(context: AppContext) { + _context = context + } + + internal fun notifyReadyToPlay(song_duration_ms: Long) { + require(song_duration_ms > 0) { song_duration_ms } + + val song: Song = getSong() ?: return + sendRequest("readyToPlay", JsonPrimitive(current_song_index), JsonPrimitive(song.id), JsonPrimitive(song_duration_ms)) + } + + private var cancelling_radio: Boolean = false + + override fun onRadioCancelRequested() { + cancelling_radio = true + radio_instance.cancelRadio() + cancelling_radio = false + } + + internal fun onRadioCancelled() { + if (cancelling_radio) { + return + } + sendRequest("cancelRadio") + } + + protected open fun createServicePlayer(): PlayerServicePlayer = + object : PlayerServicePlayer(this) { + override fun onUndoStateChanged() { + for (listener in listeners) { + listener.onUndoStateChanged() + } + } + + override val radio: RadioHandler = + object : RadioHandler(this, context) { + override fun onRadioCancelled() { + super.onRadioCancelled() + this@ExternalPlayerService.onRadioCancelled() + } + } + } + + private lateinit var _service_player: PlayerServicePlayer + override val service_player: PlayerServicePlayer + get() = _service_player + + override val state: SpMsPlayerState + get() = _state + override val is_playing: Boolean + get() = _is_playing + override val song_count: Int + get() = playlist.size + override val current_song_index: Int + get() = _current_song_index + override val current_position_ms: Long + get() { + if (current_song_time < 0) { + return 0 + } + if (!_is_playing) { + return current_song_time + } + return System.currentTimeMillis() - current_song_time + } + override val duration_ms: Long + get() = _duration_ms + override val has_focus: Boolean + get() = true // TODO + override val radio_instance: RadioInstance + get() = service_player.radio_instance + override var repeat_mode: SpMsPlayerRepeatMode + get() = _repeat_mode + set(value) { + if (value == _repeat_mode) { + return + } + sendRequest("setRepeatMode", JsonPrimitive(value.ordinal)) + } + override var volume: Float + get() = _volume + set(value) { + if (value == _volume) { + return + } + sendRequest("setVolume", JsonPrimitive(value)) + } + + override fun isPlayingOverLatentDevice(): Boolean = false // TODO + + override fun play() { + sendRequest("play") + } + + override fun pause() { + sendRequest("pause") + } + + override fun playPause() { + sendRequest("playPause") + } + + private val song_seek_undo_stack: MutableList> = mutableListOf() + private fun getSeekPosition(): Pair = Pair(current_song_index, current_position_ms) + + override fun seekTo(position_ms: Long) { + val current: Pair = getSeekPosition() + sendRequest("seekToTime", JsonPrimitive(position_ms)) + song_seek_undo_stack.add(current) + } + + override fun seekToSong(index: Int) { + val current: Pair = getSeekPosition() + sendRequest("seekToItem", JsonPrimitive(index)) + song_seek_undo_stack.add(current) + } + + override fun seekToNext() { + val current: Pair = getSeekPosition() + sendRequest("seekToNext") + song_seek_undo_stack.add(current) + } + + override fun seekToPrevious() { + val current: Pair = getSeekPosition() + sendRequest("seekToPrevious") + song_seek_undo_stack.add(current) + } + + override fun undoSeek() { + val (index: Int, position_ms: Long) = song_seek_undo_stack.removeLastOrNull() ?: return + + if (index != current_song_index) { + sendRequest("seekToItem", JsonPrimitive(index), JsonPrimitive(position_ms)) + } + else { + sendRequest("seekToTime", JsonPrimitive(position_ms)) + } + } + + override fun getSong(): Song? = playlist.getOrNull(_current_song_index) + + override fun getSong(index: Int): Song? = playlist.getOrNull(index) + + override fun addSong(song: Song, index: Int) { + sendRequest("addItem", JsonPrimitive(song.id), JsonPrimitive(index)) + } + + override fun moveSong(from: Int, to: Int) { + sendRequest("moveItem", JsonPrimitive(from), JsonPrimitive(to)) + } + + override fun removeSong(index: Int) { + sendRequest("removeItem", JsonPrimitive(index)) + } + + @Composable + override fun Visualiser(colour: Color, modifier: Modifier, opacity: Float) { + } + + override fun onCreate() { + _service_player = createServicePlayer() + super.onCreate() + } + + @Composable + override fun LoadScreenExtraContent(item_modifier: Modifier, requestServiceChange: (PlayerServiceCompanion) -> Unit) { + val player: PlayerState = LocalPlayerState.current + val launch_arguments: ProgramArguments = LocalProgramArguments.current + + LaunchedEffect(Unit) { + local_server_error = null + local_server_process = null + } + + val external_server_mode: Boolean by player.settings.platform.ENABLE_EXTERNAL_SERVER_MODE.observe() + + fun startServer(stop_if_running: Boolean, automatic: Boolean) { + if (automatic && launch_arguments.no_auto_server) { + return + } + + local_server_process?.also { process -> + if (stop_if_running) { + local_server_process = null + process.cancel() + } + return + } + + try { + local_server_process = + LocalServer.startLocalServer( + player.context, + player.settings.platform.SERVER_PORT.get() + ) + + if (!automatic && local_server_process == null) { + local_server_error = RuntimeException(getString("loading_splash_local_server_command_not_set")) + } + } + catch (e: Throwable) { + local_server_process = null + local_server_error = e + } + } + + val server_unavailability_reason: String? = remember { LocalServer.getLocalServerUnavailabilityReason() } + var show_unavailability_dialog: Boolean by remember { mutableStateOf(false) } + + if (show_unavailability_dialog) { + AlertDialog( + onDismissRequest = { show_unavailability_dialog }, + confirmButton = { + Button({ show_unavailability_dialog = false }) { + Text(getString("action_close")) + } + }, + title = { + Text(getString("warning_server_unavailable_title")) + }, + text = { + Text(server_unavailability_reason ?: "") + } + ) + } + + if (server_unavailability_reason == null || local_server_process != null) { + Button( + { startServer(stop_if_running = true, automatic = false) }, + colors = + ButtonDefaults.buttonColors( + containerColor = player.theme.accent, + contentColor = player.theme.on_accent + ), + modifier = item_modifier + ) { + Crossfade(local_server_process) { process -> + if (process == null) { + Text(getString("loading_splash_button_start_local_server")) + } + else { + Text(getString("loading_splash_button_stop_local_server")) + } + } + } + } + else if (server_unavailability_reason != null) { + OutlinedButton( + { show_unavailability_dialog = !show_unavailability_dialog }, + border = BorderStroke(Dp.Hairline, player.theme.accent), + modifier = item_modifier + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon(Icons.Default.Info, null) + Text(getString("loading_splash_button_local_server_unavailable")) + } + } + } + + // Crossfade(local_server_error ?: local_server_process as Any?) { state -> + // if (state != null) { + // Column( + // Modifier.padding(top = 20.dp), + // horizontalAlignment = Alignment.CenterHorizontally, + // verticalArrangement = Arrangement.spacedBy(10.dp) + // ) { + // if (state is Throwable) { + // Text(getString("error_on_server_command_execution")) + // ErrorInfoDisplay( + // state, + // show_throw_button = true, + // onDismiss = { local_server_error = null } + // ) + // } + // else if (state is LocalServerProcess) { + // Text(getString("loading_splash_process_running_with_command_\$x").replace("\$x", state.launch_command)) + // } + // } + // } + // } + } +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/LocalServer.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/LocalServer.kt new file mode 100644 index 000000000..a0c1c439f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/LocalServer.kt @@ -0,0 +1,14 @@ +package com.toasterofbread.spmp.platform.playerservice + +import ProgramArguments +import com.toasterofbread.spmp.platform.AppContext +import kotlinx.coroutines.Job + +expect object LocalServer { + fun getLocalServerUnavailabilityReason(): String? + + fun startLocalServer( + context: AppContext, + port: Int + ): Job +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformExternalPlayerService.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformExternalPlayerService.kt new file mode 100644 index 000000000..8c833c278 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformExternalPlayerService.kt @@ -0,0 +1,5 @@ +package com.toasterofbread.spmp.platform.playerservice + +expect class PlatformExternalPlayerService(): PlayerService { + companion object: PlayerServiceCompanion +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformInternalPlayerService.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformInternalPlayerService.kt new file mode 100644 index 000000000..8374fd79c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformInternalPlayerService.kt @@ -0,0 +1,64 @@ +package com.toasterofbread.spmp.platform.playerservice + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.toasterofbread.spmp.model.mediaitem.song.Song +import com.toasterofbread.spmp.platform.AppContext +import com.toasterofbread.spmp.platform.PlayerListener +import com.toasterofbread.spmp.model.radio.RadioInstance +import com.toasterofbread.spmp.model.radio.RadioState +import dev.toastbits.spms.socketapi.shared.SpMsPlayerRepeatMode +import dev.toastbits.spms.socketapi.shared.SpMsPlayerState +import ProgramArguments + +internal const val AUTO_DOWNLOAD_SOFT_TIMEOUT = 1500 // ms + +expect class PlatformInternalPlayerService: PlayerService { + companion object: PlayerServiceCompanion + + override val load_state: PlayerServiceLoadState + override val context: AppContext + override val service_player: PlayerServicePlayer + + override fun onCreate() + override fun onDestroy() + + override val state: SpMsPlayerState + override val is_playing: Boolean + override val song_count: Int + override val current_song_index: Int + override val current_position_ms: Long + override val duration_ms: Long + override val has_focus: Boolean + + override val radio_instance: RadioInstance + + override var repeat_mode: SpMsPlayerRepeatMode + override var volume: Float + + override fun isPlayingOverLatentDevice(): Boolean + + override fun play() + override fun pause() + override fun playPause() + + override fun seekTo(position_ms: Long) + override fun seekToSong(index: Int) + override fun seekToNext() + override fun seekToPrevious() + override fun undoSeek() + + override fun getSong(): Song? + override fun getSong(index: Int): Song? + + override fun addSong(song: Song, index: Int) + override fun moveSong(from: Int, to: Int) + override fun removeSong(index: Int) + + override fun addListener(listener: PlayerListener) + override fun removeListener(listener: PlayerListener) + + @Composable + override fun Visualiser(colour: Color, modifier: Modifier, opacity: Float) +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformPlayerService.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformPlayerService.kt deleted file mode 100644 index bde2a767f..000000000 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformPlayerService.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.toasterofbread.spmp.platform.playerservice - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import com.toasterofbread.spmp.model.mediaitem.song.Song -import com.toasterofbread.spmp.platform.AppContext -import com.toasterofbread.spmp.platform.PlayerListener -import com.toasterofbread.spmp.model.radio.RadioInstance -import com.toasterofbread.spmp.model.radio.RadioState -import spms.socketapi.shared.SpMsPlayerRepeatMode -import spms.socketapi.shared.SpMsPlayerState - -internal const val AUTO_DOWNLOAD_SOFT_TIMEOUT = 1500 // ms - -data class PlayerServiceLoadState( - val loading: Boolean, - val loading_message: String? = null -) - -interface PlayerService { - val context: AppContext - val service_player: PlayerServicePlayer - - fun onCreate() - fun onDestroy() - - val state: SpMsPlayerState - val is_playing: Boolean - val song_count: Int - val current_song_index: Int - val current_position_ms: Long - val duration_ms: Long - val has_focus: Boolean - - val radio_instance: RadioInstance - - var repeat_mode: SpMsPlayerRepeatMode - var volume: Float - - fun isPlayingOverLatentDevice(): Boolean - - fun play() - fun pause() - fun playPause() - - fun seekTo(position_ms: Long) - fun seekToSong(index: Int) - fun seekToNext() - fun seekToPrevious() - fun undoSeek() - - fun getSong(): Song? - fun getSong(index: Int): Song? - - fun addSong(song: Song, index: Int) - fun moveSong(from: Int, to: Int) - fun removeSong(index: Int) - - fun addListener(listener: PlayerListener) - fun removeListener(listener: PlayerListener) - - @Composable - fun Visualiser(colour: Color, modifier: Modifier, opacity: Float) -} - -expect class PlatformPlayerService: PlayerService { - companion object { - fun isServiceRunning(context: AppContext): Boolean - - fun addListener(listener: PlayerListener) - fun removeListener(listener: PlayerListener) - - fun connect( - context: AppContext, - instance: PlatformPlayerService? = null, - onConnected: (PlatformPlayerService) -> Unit, - onDisconnected: () -> Unit - ): Any - - fun disconnect(context: AppContext, connection: Any) - } - - val load_state: PlayerServiceLoadState - val connection_error: Throwable? - - override val context: AppContext - override val service_player: PlayerServicePlayer - - override fun onCreate() - override fun onDestroy() - - override val state: SpMsPlayerState - override val is_playing: Boolean - override val song_count: Int - override val current_song_index: Int - override val current_position_ms: Long - override val duration_ms: Long - override val has_focus: Boolean - - override val radio_instance: RadioInstance - - override var repeat_mode: SpMsPlayerRepeatMode - override var volume: Float - - override fun isPlayingOverLatentDevice(): Boolean - - override fun play() - override fun pause() - override fun playPause() - - override fun seekTo(position_ms: Long) - override fun seekToSong(index: Int) - override fun seekToNext() - override fun seekToPrevious() - override fun undoSeek() - - override fun getSong(): Song? - override fun getSong(index: Int): Song? - - override fun addSong(song: Song, index: Int) - override fun moveSong(from: Int, to: Int) - override fun removeSong(index: Int) - - override fun addListener(listener: PlayerListener) - override fun removeListener(listener: PlayerListener) - - @Composable - override fun Visualiser(colour: Color, modifier: Modifier, opacity: Float) -} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlayerService.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlayerService.kt new file mode 100644 index 000000000..3d93e6aa4 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlayerService.kt @@ -0,0 +1,69 @@ +package com.toasterofbread.spmp.platform.playerservice + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.toasterofbread.spmp.model.mediaitem.song.Song +import com.toasterofbread.spmp.model.radio.RadioInstance +import com.toasterofbread.spmp.platform.AppContext +import com.toasterofbread.spmp.platform.PlayerListener +import dev.toastbits.spms.socketapi.shared.SpMsPlayerRepeatMode +import dev.toastbits.spms.socketapi.shared.SpMsPlayerState + +data class PlayerServiceLoadState( + val loading: Boolean, + val loading_message: String? = null, + val error: Throwable? = null +) + +interface PlayerService { + val context: AppContext + val service_player: PlayerServicePlayer + + fun onCreate() + fun onDestroy() + + val load_state: PlayerServiceLoadState + val state: SpMsPlayerState + val is_playing: Boolean + val song_count: Int + val current_song_index: Int + val current_position_ms: Long + val duration_ms: Long + val has_focus: Boolean + + val radio_instance: RadioInstance + + var repeat_mode: SpMsPlayerRepeatMode + var volume: Float + + fun isPlayingOverLatentDevice(): Boolean + + fun play() + fun pause() + fun playPause() + + fun seekTo(position_ms: Long) + fun seekToSong(index: Int) + fun seekToNext() + fun seekToPrevious() + fun undoSeek() + + fun getSong(): Song? + fun getSong(index: Int): Song? + + fun addSong(song: Song, index: Int) + fun moveSong(from: Int, to: Int) + fun removeSong(index: Int) + + fun addListener(listener: PlayerListener) + fun removeListener(listener: PlayerListener) + + @Composable + fun Visualiser(colour: Color, modifier: Modifier, opacity: Float) + + @Composable + fun PersistentContent(requestServiceChange: (PlayerServiceCompanion) -> Unit) {} + @Composable + fun LoadScreenExtraContent(item_modifier: Modifier, requestServiceChange: (PlayerServiceCompanion) -> Unit) {} +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlayerServiceCompanion.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlayerServiceCompanion.kt new file mode 100644 index 000000000..6c86f1f2a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlayerServiceCompanion.kt @@ -0,0 +1,22 @@ +package com.toasterofbread.spmp.platform.playerservice + +import ProgramArguments +import com.toasterofbread.spmp.platform.AppContext + +interface PlayerServiceCompanion { + fun getUnavailabilityReason(context: AppContext, launch_arguments: ProgramArguments): String? = null + fun isAvailable(context: AppContext, launch_arguments: ProgramArguments): Boolean = getUnavailabilityReason(context, launch_arguments) == null + + fun isServiceRunning(context: AppContext): Boolean + fun playsAudio(): Boolean = false + + fun connect( + context: AppContext, + launch_arguments: ProgramArguments, + instance: PlayerService? = null, + onConnected: (PlayerService) -> Unit, + onDisconnected: () -> Unit + ): Any + + fun disconnect(context: AppContext, connection: Any) +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlayerServicePlayer.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlayerServicePlayer.kt index d3626b727..f348ae261 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlayerServicePlayer.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlayerServicePlayer.kt @@ -39,8 +39,8 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.encodeToString -import spms.socketapi.shared.SpMsPlayerRepeatMode -import spms.socketapi.shared.SpMsPlayerState +import dev.toastbits.spms.socketapi.shared.SpMsPlayerRepeatMode +import dev.toastbits.spms.socketapi.shared.SpMsPlayerState import java.io.IOException import java.util.Timer import java.util.TimerTask @@ -52,11 +52,11 @@ private const val UPDATE_INTERVAL: Long = 30000 // ms private const val SONG_MARK_WATCHED_POSITION = 1000 // ms @Suppress("LeakingThis") -abstract class PlayerServicePlayer(private val service: PlatformPlayerService) { +abstract class PlayerServicePlayer(internal val service: PlayerService) { private val context: AppContext get() = service.context private val coroutine_scope: CoroutineScope = CoroutineScope(Dispatchers.Main) - internal val radio: RadioHandler = RadioHandler(this, context) + internal open val radio: RadioHandler = RadioHandler(this, context) private val persistent_queue: PersistentQueueHandler = PersistentQueueHandler(this, context) private val discord_status: DiscordStatusHandler = DiscordStatusHandler(this, context) private val undo_handler: UndoHandler = UndoHandler(this, service) @@ -79,7 +79,7 @@ abstract class PlayerServicePlayer(private val service: PlatformPlayerService) { abstract fun onUndoStateChanged() - private val prefs_listener = + private val prefs_listener = PlatformPreferencesListener { _, key -> when (key) { context.settings.discord_auth.DISCORD_ACCOUNT_TOKEN.key -> { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMs.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMs.kt index 53827bfac..7f9834c7a 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMs.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMs.kt @@ -1,7 +1,9 @@ package com.toasterofbread.spmp.platform.playerservice import com.toasterofbread.spmp.resources.getString -import spms.socketapi.shared.SpMsClientType +import dev.toastbits.spms.socketapi.shared.SpMsClientType +import com.toasterofbread.spmp.platform.AppContext +import java.io.File fun SpMsClientType.getName(): String = when (this) { @@ -32,6 +34,23 @@ fun SpMsClientType.getInfoUrl(): String = SpMsClientType.SERVER -> getString("spms_client_type_info_url_server") } -expect fun getSpMsMachineId(): String +expect fun getSpMsMachineId(context: AppContext): String -expect fun getServerExecutableFilename(): String? +internal fun getSpMsMachineIdFromFile(id_file: File): String { + if (id_file.exists()) { + return id_file.readText() + } + + if (!id_file.parentFile.exists()) { + id_file.parentFile.mkdirs() + } + + val id_length: Int = 8 + val allowed_chars: List = ('A'..'Z') + ('a'..'z') + ('0'..'9') + + val new_id: String = (1..id_length).map { allowed_chars.random() }.joinToString("") + + id_file.writeText(new_id) + + return new_id +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMsPlayerService.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMsPlayerService.kt index 37ebb50f4..0da6f2950 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMsPlayerService.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMsPlayerService.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import dev.toastbits.composekit.platform.PlatformPreferences import dev.toastbits.composekit.platform.PlatformPreferencesListener +import dev.toastbits.composekit.platform.Platform import dev.toastbits.composekit.utils.common.launchSingle import com.toasterofbread.spmp.model.mediaitem.song.Song import com.toasterofbread.spmp.model.settings.unpackSetData @@ -19,6 +20,7 @@ import io.ktor.http.Headers import io.ktor.util.flattenEntries import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withTimeout import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -30,57 +32,51 @@ import kotlinx.serialization.json.put import kotlinx.serialization.json.encodeToJsonElement import org.zeromq.* import java.net.InetAddress -import spms.socketapi.shared.* +import dev.toastbits.spms.socketapi.shared.* +import dev.toastbits.spms.server.CLIENT_HEARTBEAT_TARGET_PERIOD +import dev.toastbits.spms.server.CLIENT_HEARTBEAT_MAX_PERIOD +import kotlin.time.* -private const val POLL_STATE_INTERVAL: Long = 100 -private const val POLL_TIMEOUT_MS: Long = 10000 +private val SERVER_REPLY_TIMEOUT: Duration = with (Duration) { 1.seconds } + +abstract class SpMsPlayerService(val plays_audio: Boolean): PlatformServiceImpl(), ClientServerPlayerService { + abstract fun getIpAddress(): String + abstract fun getPort(): Int -abstract class SpMsPlayerService: PlatformServiceImpl(), ClientServerPlayerService { override var connected_server: ClientServerPlayerService.ServerInfo? by mutableStateOf(null) private val clients_result_channel: Channel = Channel() var socket_load_state: PlayerServiceLoadState by mutableStateOf(PlayerServiceLoadState(true)) private set - var socket_connection_error: Throwable? by mutableStateOf(null) - private set - private fun getServerPort(): Int = context.settings.desktop.SERVER_PORT.get() - private fun getServerIp(): String = context.settings.desktop.SERVER_IP_ADDRESS.get() + internal abstract fun onRadioCancelRequested() private fun getClientName(): String { - val host: String = InetAddress.getLocalHost().hostName - val os: String = System.getProperty("os.name") - + val os: String = Platform.getOSName() + var host: String = Platform.getHostName() return getString("app_name") + " [$os, $host]" } private val prefs_listener: PlatformPreferencesListener = PlatformPreferencesListener { _, key -> when (key) { - context.settings.desktop.SERVER_IP_ADDRESS.key, - context.settings.desktop.SERVER_PORT.key -> { - restart_connection = true - cancel_connection = true - } context.settings.youtube_auth.YTM_AUTH.key -> { sendYtmAuthToPlayers() } } } - private val zmq: ZContext = ZContext() - private lateinit var socket: ZMQ.Socket + private val zmq: ZContext = ZContext().apply { setLinger(0) } + private var socket: ZMQ.Socket? = null private val json: Json = Json { ignoreUnknownKeys = true } private val queued_messages: MutableList>> = mutableListOf() - private var cancel_connection: Boolean = false - private var restart_connection: Boolean = false private val poll_coroutine_scope: CoroutineScope = CoroutineScope(Dispatchers.IO) - private val connect_coroutine_scope: CoroutineScope = CoroutineScope(Dispatchers.IO) + private val connect_coroutine_scope: CoroutineScope = CoroutineScope(Job()) private val player_status_coroutine_scope: CoroutineScope = CoroutineScope(Dispatchers.IO) - internal abstract val listeners: List + internal val listeners: MutableList = mutableListOf() internal var playlist: MutableList = mutableListOf() private set @@ -94,6 +90,12 @@ abstract class SpMsPlayerService: PlatformServiceImpl(), ClientServerPlayerServi internal var current_song_time: Long = -1 protected fun sendRequest(action: String, vararg params: JsonElement?) { + println("sendRequest $action ${params.toList()} ${connected_server == null}") + + if (connected_server == null) { + return + } + synchronized(queued_messages) { queued_messages.add(Pair(action, params.map { Json.encodeToJsonElement(it) })) } @@ -121,224 +123,302 @@ abstract class SpMsPlayerService: PlatformServiceImpl(), ClientServerPlayerServi override fun onCreate() { context.getPrefs().addListener(prefs_listener) - - socket = zmq.createSocket(SocketType.DEALER) - socket.connectToServer() + connectToServer() } override fun onDestroy() { super.onDestroy() - poll_coroutine_scope.cancel() - connect_coroutine_scope.cancel() + disconnectFromServer() + socket?.close() context.getPrefs().removeListener(prefs_listener) } - private fun onSocketConnectionLost(expired_timeout_ms: Long) { - println("Connection to server timed out after ${expired_timeout_ms}ms, reconnecting...") - socket.connectToServer() + private fun onSocketConnectionLost(attempts: Int, expired_timeout: Duration) { + println("Connection to server timed out after $attempts attempts and $expired_timeout, reconnecting...") + + connected_server?.run { + connected_server = null + connectToServer() + } } - private fun ZMQ.Socket.connectToServer() { - connect_coroutine_scope.launch { - do { - connected_server = null - socket_connection_error = null - cancel_connection = false - restart_connection = false - - val ip: String = getServerIp() - val port: Int = getServerPort() - val protocol: String = "tcp" - val server_url = "$protocol://$ip:$port" - - val handshake: SpMsClientHandshake = - SpMsClientHandshake( - name = getClientName(), - type = SpMsClientType.SPMP_STANDALONE, - machine_id = getSpMsMachineId(), - language = context.getUiLanguage() - ) - - val server_handshake: SpMsServerHandshake? + private fun connectToServer() { + check(connected_server == null) + connect_coroutine_scope.launchSingle { + while (true) { + val socket: ZMQ.Socket = zmq.createSocket(SocketType.DEALER) + this@SpMsPlayerService.socket = socket + try { - server_handshake = tryConnectToServer( - server_url = server_url, - handshake = handshake, - json = json, - shouldCancelConnection = { cancel_connection }, - setLoadState = { socket_load_state = it } - ) + socket.connectSocketToServer(with (Duration) { 5.seconds }) } catch (e: Throwable) { - socket_connection_error = e + e.printStackTrace() + socket.close() continue } - if (server_handshake == null) { - disconnect(server_url) - continue - } + break + } + } + } + + fun disconnectFromServer() { + connect_coroutine_scope.coroutineContext.cancelChildren() + poll_coroutine_scope.coroutineContext.cancelChildren() + player_status_coroutine_scope.coroutineContext.cancelChildren() + connected_server = null + } + + private suspend fun ZMQ.Socket.connectSocketToServer(timeout: Duration) = withContext(Dispatchers.Default) { + connected_server = null + + val ip: String = getIpAddress() + val port: Int = getPort() + val protocol: String = "tcp" + val server_url = "$protocol://$ip:$port" + + val handshake: SpMsClientHandshake = + SpMsClientHandshake( + name = getClientName(), + type = if (plays_audio) SpMsClientType.SPMP_PLAYER else SpMsClientType.SPMP_STANDALONE, + machine_id = getSpMsMachineId(context), + language = context.getUiLanguage() + ) - connected_server = ClientServerPlayerService.ServerInfo( - ip = ip, - port = port, - protocol = protocol, - name = server_handshake.name, - device_name = server_handshake.device_name, - machine_id = server_handshake.machine_id, - spms_api_version = server_handshake.spms_api_version + val server_handshake: SpMsServerHandshake = + withTimeout(timeout) { + tryConnectToServer( + server_url = server_url, + handshake = handshake, + json = json, + setLoadState = { socket_load_state = it } ) + } - var server_state_applied: Boolean = false + connected_server = + ClientServerPlayerService.ServerInfo( + ip = ip, + port = port, + protocol = protocol, + name = server_handshake.name, + device_name = server_handshake.device_name, + machine_id = server_handshake.machine_id, + spms_api_version = server_handshake.spms_api_version + ) - poll_coroutine_scope.launchSingle { - val context: ZMQ.Context = ZMQ.context(1) - val poller: ZMQ.Poller = context.poller() - poller.register(socket, ZMQ.Poller.POLLIN) + var server_state_applied: Boolean = false - var queued_events: MutableList? = mutableListOf() + poll_coroutine_scope.launchSingle { + val context: ZMQ.Context = ZMQ.context(1) + val poller: ZMQ.Poller = context.poller() + poller.register(this@connectSocketToServer, ZMQ.Poller.POLLIN) - while (true) { - delay(POLL_STATE_INTERVAL) + var queued_events: MutableList? = mutableListOf() + var last_heartbeat: TimeMark = TimeSource.Monotonic.markNow() + var last_server_heartbeat: TimeMark = TimeSource.Monotonic.markNow() - if (server_state_applied && queued_events != null) { - applyPlayerEvents(queued_events) - queued_events = null + try { + while (true) { + println("LOOP 1") + if (server_state_applied && queued_events != null) { + println("LOOP 1.1") + applyPlayerEvents(queued_events) + queued_events = null + } + println("LOOP 1.2") + + val poll_result: Boolean = + pollServerState(poller, with (Duration) { 100.milliseconds }) { events -> + queued_events?.also { + it.addAll(events) + return@pollServerState + } + + applyPlayerEvents(events) } - val poll_successful: Boolean = - pollServerState(poller, POLL_TIMEOUT_MS) { events -> - queued_events?.also { - it.addAll(events) - return@also - } + if (poll_result) { + println("LOOP 2") + last_server_heartbeat = TimeSource.Monotonic.markNow() + } + else if (last_server_heartbeat.elapsedNow() > CLIENT_HEARTBEAT_MAX_PERIOD) { + println("LOOP 3") + onSocketConnectionLost(1, CLIENT_HEARTBEAT_MAX_PERIOD) + break + } + else { + println("LOOP 4") + } - applyPlayerEvents(events) + synchronized(queued_messages) { + println("LOOP 4") + val message: ZMsg = ZMsg() + if (queued_messages.isNotEmpty()) { + for (queued in queued_messages) { + message.addSafe(queued.first) + message.addSafe(Json.encodeToString(queued.second)) } + println("LOOP 5") + } + else if (last_heartbeat.elapsedNow() > CLIENT_HEARTBEAT_TARGET_PERIOD) { + message.add(byteArrayOf()) + println("LOOP 6") + } + else { + println("LOOP 7") + return@synchronized + } + + val actions_expecting_result: List>> = + queued_messages.filter { it.first.firstOrNull() == SPMS_EXPECT_REPLY_CHAR } + + queued_messages.clear() + last_heartbeat = TimeSource.Monotonic.markNow() - if (!poll_successful) { - onSocketConnectionLost(POLL_TIMEOUT_MS) - break + println("SENDING $message") + val send_result: Boolean = message.send(this@connectSocketToServer) + check(send_result) { "Sending message to server failed" } + + println("EXPECTING REP $actions_expecting_result") + if (actions_expecting_result.isEmpty()) { + return@synchronized } - } - } - applyServerState(server_handshake.server_state, this) { status -> - socket_load_state = - PlayerServiceLoadState( - true, - getString("desktop_splash_setting_initial_state") + status?.let { " ($it)" }.orEmpty() - ) - } + var results: ZMsg? = null + val wait_start: TimeMark = TimeSource.Monotonic.markNow() - socket_load_state = PlayerServiceLoadState(false) + while (wait_start.elapsedNow() < SERVER_REPLY_TIMEOUT) { + if (poller.poll((SERVER_REPLY_TIMEOUT - wait_start.elapsedNow()).inWholeMilliseconds) > 0) { + results = ZMsg.recvMsg(this@connectSocketToServer) + break + } + } - synchronized(this) { - server_state_applied = true - } + if (results == null) { + println("NO RESULTS") + onSocketConnectionLost(1, SERVER_REPLY_TIMEOUT) + return@launchSingle + } + + last_server_heartbeat = TimeSource.Monotonic.markNow() + + val result_str: String = SpMsSocketApi.decode(results.map { it.data.decodeToString() }).first() + println("RESULT STR $result_str") + if (result_str.isEmpty()) { + throw RuntimeException("Result string is empty") + } + + val parsed_results: List? + try { + parsed_results = json.decodeFromString(result_str) + } + catch (e: Throwable) { + throw RuntimeException("Parsing result failed '$result_str'", e) + } - sendYtmAuthToPlayers() + for ((i, result) in parsed_results.orEmpty().withIndex()) { + val action: Pair> = actions_expecting_result[i] + when (action.first.drop(1)) { + "clients" -> clients_result_channel.trySend(result) + else -> throw NotImplementedError("Action: '$action' Result: '$result'") + } + } + } + } + } + catch (e: Throwable) { + RuntimeException("Exception during poll loop", e).printStackTrace() + throw e } - while (restart_connection) } + + applyServerState(server_handshake.server_state, this) { status -> + socket_load_state = + PlayerServiceLoadState( + true, + getString("loading_splash_setting_initial_state") + status?.let { " ($it)" }.orEmpty() + ) + } + + socket_load_state = PlayerServiceLoadState(false) + + synchronized(this@withContext) { + server_state_applied = true + } + + sendYtmAuthToPlayers() } private suspend fun ZMQ.Socket.pollServerState( poller: ZMQ.Poller, - timeout: Long = -1, + timeout: Duration? = null, onEvents: suspend (List) -> Unit ): Boolean = withContext(Dispatchers.IO) { - val events: ZMsg - if (poller.poll(timeout) > 0) { - events = ZMsg.recvMsg(this@pollServerState) + var events: ZMsg? = null + try { + val wait_start: TimeMark = TimeSource.Monotonic.markNow() + while (true) { + val remaining: Long + if (timeout == null) { + remaining = -1 + } + else { + val elapsed: Duration = wait_start.elapsedNow() + if (elapsed >= timeout) { + break + } + + remaining = (timeout - elapsed).inWholeMilliseconds + } + + if (poller.poll(remaining) > 0) { + events = ZMsg.recvMsg(this@pollServerState) + break + } + } } - else { - println("Polling server timed out after ${timeout}ms") + catch (e: Throwable) { + RuntimeException("Warning: Polling server failed prematurely", e).printStackTrace() return@withContext false } + if (events == null) { + return@withContext false + } + + val decoded_event_strings: List = SpMsSocketApi.decode(events.map { it.data.decodeToString() }) + if (decoded_event_strings.size == 1 && decoded_event_strings.first().contains("REPLY TO ")) { + return@withContext true + } + val decoded_events: List = - SpMsSocketApi.decode(events.map { it.data.decodeToString() }) - .mapNotNull { event: String -> + decoded_event_strings.mapNotNull { event: String -> try { json.decodeFromString(event) } catch (e: Throwable) { - throw RuntimeException("Parsing event failed '$event'", e) + throw RuntimeException("Parsing event failed '$event' (in $decoded_event_strings)", e) } } onEvents(decoded_events) - - val reply: ZMsg = ZMsg() - synchronized(queued_messages) { - if (queued_messages.isEmpty()) { - reply.add(byteArrayOf()) - } - else { - for (message in queued_messages) { - reply.addSafe(message.first) - reply.addSafe(Json.encodeToString(message.second)) - } - } - - val actions_expecting_result: List>> = - queued_messages.filter { it.first.firstOrNull() == SPMS_EXPECT_REPLY_CHAR } - - queued_messages.clear() - - val reply_result: Boolean = reply.send(this@pollServerState) - if (!reply_result || actions_expecting_result.isEmpty()) { - return@withContext reply_result - } - - val results: ZMsg - if (poller.poll(timeout) > 0) { - results = ZMsg.recvMsg(this@pollServerState) - } - else { - println("Getting results timed out after ${timeout}ms") - return@withContext false - } - - val result_str: String = SpMsSocketApi.decode(results.map { it.data.decodeToString() }).first() - if (result_str.isEmpty()) { - throw RuntimeException("Result string is empty") - } - - val parsed_results: List? - try { - parsed_results = json.decodeFromString(result_str) - } - catch (e: Throwable) { - throw RuntimeException("Parsing result failed '$result_str'", e) - } - - for ((i, result) in parsed_results.orEmpty().withIndex()) { - val action: Pair> = actions_expecting_result[i] - when (action.first.drop(1)) { - "clients" -> clients_result_channel.trySend(result) - else -> throw NotImplementedError("Action: '$action' Result: '$result'") - } - } - - return@withContext true - } + return@withContext true } - override suspend fun getPeers(): Result> { + override suspend fun getPeers(): Result> = runCatching { sendRequest(SPMS_EXPECT_REPLY_CHAR + "clients") - val result: SpMsActionReply = clients_result_channel.receive() - if (!result.success) { - return Result.failure(RuntimeException(result.error, result.error_cause?.let { RuntimeException(it) })) - } + val result: SpMsActionReply = + withTimeout(1000) { + clients_result_channel.receive() + } - if (result.result == null) { - return Result.failure(NullPointerException("Result is null")) + if (!result.success) { + throw RuntimeException(result.error, result.error_cause?.let { RuntimeException(it) }) } - return Result.success(Json.decodeFromJsonElement(result.result)) + return Json.decodeFromJsonElement(result.result ?: throw NullPointerException("Result is null")) } override fun onSongFilesAdded(songs: List) { @@ -416,7 +496,7 @@ abstract class SpMsPlayerService: PlatformServiceImpl(), ClientServerPlayerServi SpMsClientHandshake( name = getClientName(), type = SpMsClientType.SPMP_STANDALONE, - machine_id = getSpMsMachineId(), + machine_id = getSpMsMachineId(context), language = context.getUiLanguage() ) @@ -449,7 +529,7 @@ abstract class SpMsPlayerService: PlatformServiceImpl(), ClientServerPlayerServi private suspend fun getLocalPlayers(): Result> = getPeers().fold( { - val machine_id: String = getSpMsMachineId() + val machine_id: String = getSpMsMachineId(context) Result.success( it.filter { peer -> !peer.is_caller && peer.player_port != null && peer.machine_id == machine_id @@ -469,6 +549,14 @@ abstract class SpMsPlayerService: PlatformServiceImpl(), ClientServerPlayerServi sendAuthInfoToPlayers(ytm_auth) } } + + override fun addListener(listener: PlayerListener) { + listeners.add(listener) + } + + override fun removeListener(listener: PlayerListener) { + listeners.remove(listener) + } } private fun ZMsg.addSafe(part: String) { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/UndoHandler.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/UndoHandler.kt index 77a4a6372..96fcf4995 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/UndoHandler.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/UndoHandler.kt @@ -7,11 +7,11 @@ import dev.toastbits.composekit.utils.common.synchronizedBlock import com.toasterofbread.spmp.model.mediaitem.song.Song interface UndoRedoAction { - fun undo(service: PlatformPlayerService) {} - fun redo(service: PlatformPlayerService) {} + fun undo(service: PlayerService) {} + fun redo(service: PlayerService) {} } -internal class UndoHandler(val player: PlayerServicePlayer, val service: PlatformPlayerService) { +internal class UndoHandler(val player: PlayerServicePlayer, val service: PlayerService) { private var current_action: MutableList? = null private var current_action_is_further: Boolean = false private val action_list: MutableList> = mutableListOf() @@ -25,12 +25,12 @@ internal class UndoHandler(val player: PlayerServicePlayer, val service: Platfor assert(index >= 0) { index.toString() } } - override fun redo(service: PlatformPlayerService) { + override fun redo(service: PlayerService) { super.redo(service) service.addSong(song, index) service.service_player.onUndoStateChanged() } - override fun undo(service: PlatformPlayerService) { + override fun undo(service: PlayerService) { service.removeSong(index) service.service_player.onUndoStateChanged() } @@ -41,12 +41,12 @@ internal class UndoHandler(val player: PlayerServicePlayer, val service: Platfor assert(to >= 0) } - override fun redo(service: PlatformPlayerService) { + override fun redo(service: PlayerService) { super.redo(service) service.moveSong(from, to) service.service_player.onUndoStateChanged() } - override fun undo(service: PlatformPlayerService) { + override fun undo(service: PlayerService) { service.moveSong(to, from) service.service_player.onUndoStateChanged() } @@ -57,14 +57,14 @@ internal class UndoHandler(val player: PlayerServicePlayer, val service: Platfor } private lateinit var song: Song - override fun redo(service: PlatformPlayerService) { + override fun redo(service: PlayerService) { super.redo(service) song = service.getSong(index)!! service.removeSong(index) service.service_player.onUndoStateChanged() } - override fun undo(service: PlatformPlayerService) { + override fun undo(service: PlayerService) { service.addSong(song, index) service.service_player.onUndoStateChanged() } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/applyPlayerEvents.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/applyPlayerEvents.kt index 5208dfc8b..a1fb313f7 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/applyPlayerEvents.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/applyPlayerEvents.kt @@ -9,9 +9,9 @@ import kotlinx.serialization.json.int import kotlinx.serialization.json.long import kotlinx.coroutines.withContext import kotlinx.coroutines.Dispatchers -import spms.socketapi.shared.SpMsPlayerEvent -import spms.socketapi.shared.SpMsPlayerRepeatMode -import spms.socketapi.shared.SpMsPlayerState +import dev.toastbits.spms.socketapi.shared.SpMsPlayerEvent +import dev.toastbits.spms.socketapi.shared.SpMsPlayerRepeatMode +import dev.toastbits.spms.socketapi.shared.SpMsPlayerState internal suspend fun SpMsPlayerService.applyPlayerEvents(events: List) = withContext(Dispatchers.IO) { var item_transition_event: SpMsPlayerEvent? = null @@ -39,7 +39,12 @@ private fun SpMsPlayerService.applyEvent(event: SpMsPlayerEvent) { when (event.type) { SpMsPlayerEvent.Type.ITEM_TRANSITION -> { - _current_song_index = event.properties["index"]!!.int + val target_index: Int = event.properties["index"]!!.int + if (target_index == _current_song_index) { + return + } + + _current_song_index = target_index _duration_ms = -1 updateCurrentSongPosition(0) @@ -49,6 +54,80 @@ private fun SpMsPlayerService.applyEvent(event: SpMsPlayerEvent) { it.onEvents() } } + SpMsPlayerEvent.Type.SEEKED -> { + val position_ms: Long = event.properties["position_ms"]!!.long + updateCurrentSongPosition(position_ms) + listeners.forEach { + it.onSeeked(position_ms) + it.onEvents() + } + } + SpMsPlayerEvent.Type.ITEM_ADDED -> { + val song: SongData = SongData(event.properties["item_id"]!!.content) + val index: Int = event.properties["index"]!!.int + + if (index <= _current_song_index) { + _current_song_index++ + } + + playlist.add(minOf(playlist.size, index), song) + listeners.forEach { + it.onSongAdded(index, song) + it.onEvents() + } + service_player.session_started = true + } + SpMsPlayerEvent.Type.ITEM_REMOVED -> { + val index: Int = event.properties["index"]!!.int + if (index !in playlist.indices) { + return + } + + val song: Song = playlist.removeAt(index) + val transitioning: Boolean = index == _current_song_index + + if (index <= _current_song_index) { + _current_song_index-- + } + + listeners.forEach { + it.onSongRemoved(index, song) + if (transitioning) { + it.onSongTransition(playlist.getOrNull(_current_song_index), false) + } + it.onEvents() + } + } + SpMsPlayerEvent.Type.ITEM_MOVED -> { + val to: Int = event.properties["to"]!!.int + val from: Int = event.properties["from"]!!.int + + val song: Song = playlist.removeAt(from) + playlist.add(to, song) + + if (from == _current_song_index) { + _current_song_index = to + } + + listeners.forEach { + it.onSongMoved(from, to) + it.onEvents() + } + } + SpMsPlayerEvent.Type.CLEARED -> { + for (i in playlist.indices.reversed()) { + val song: Song = playlist.removeAt(i) + listeners.forEach { + it.onSongRemoved(i, song) + } + } + listeners.forEach { + it.onEvents() + } + } + SpMsPlayerEvent.Type.CANCEL_RADIO -> { + onRadioCancelRequested() + } SpMsPlayerEvent.Type.PROPERTY_CHANGED -> { val key: String = event.properties["key"]!!.content val value: JsonPrimitive = event.properties["value"]!! @@ -101,67 +180,6 @@ private fun SpMsPlayerService.applyEvent(event: SpMsPlayerEvent) { else -> throw NotImplementedError(key) } } - SpMsPlayerEvent.Type.SEEKED -> { - val position_ms: Long = event.properties["position_ms"]!!.long - updateCurrentSongPosition(position_ms) - listeners.forEach { - it.onSeeked(position_ms) - it.onEvents() - } - } - SpMsPlayerEvent.Type.ITEM_ADDED -> { - val song: SongData = SongData(event.properties["item_id"]!!.content) - val index: Int = event.properties["index"]!!.int - playlist.add(minOf(playlist.size, index), song) - listeners.forEach { - it.onSongAdded(index, song) - it.onEvents() - } - service_player.session_started = true - } - SpMsPlayerEvent.Type.ITEM_REMOVED -> { - val index: Int = event.properties["index"]!!.int - if (index in playlist.indices) { - val song: Song = playlist.removeAt(index) - listeners.forEach { - it.onSongRemoved(index, song) - it.onEvents() - } - } - } - SpMsPlayerEvent.Type.ITEM_MOVED -> { - val to: Int = event.properties["to"]!!.int - val from: Int = event.properties["from"]!!.int - - val song: Song = playlist.removeAt(from) - playlist.add(to, song) - - if (from == _current_song_index) { - _current_song_index = to - } - - listeners.forEach { - it.onSongMoved(from, to) - - if (from == _current_song_index) { - it.onSongTransition(song, true) - } - - it.onEvents() - } - } - SpMsPlayerEvent.Type.CLEARED -> { - for (i in playlist.indices.reversed()) { - val song: Song = playlist.removeAt(i) - listeners.forEach { - it.onSongRemoved(i, song) - } - } - listeners.forEach { - it.onEvents() - } - } - SpMsPlayerEvent.Type.READY_TO_PLAY -> {} } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/applyServerState.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/applyServerState.kt index af2154295..33044e72c 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/applyServerState.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/applyServerState.kt @@ -4,17 +4,24 @@ import com.toasterofbread.spmp.model.mediaitem.MediaItemData import com.toasterofbread.spmp.model.mediaitem.song.Song import com.toasterofbread.spmp.model.mediaitem.song.SongRef import kotlinx.coroutines.* -import spms.socketapi.shared.SpMsServerState +import dev.toastbits.spms.socketapi.shared.SpMsServerState internal suspend fun SpMsPlayerService.applyServerState( state: SpMsServerState, coroutine_scope: CoroutineScope, onProgress: (String?) -> Unit = {} ) = withContext(Dispatchers.Default) { - assert(playlist.isEmpty()) - onProgress(null) + if (playlist.isNotEmpty()) { + for (i in playlist.size - 1 downTo 0) { + val song: Song = playlist.removeAt(i) + listeners.forEach { + it.onSongRemoved(i, song) + } + } + } + val items: Array = arrayOfNulls(state.queue.size) var completed: Int = 0 @@ -53,6 +60,9 @@ internal suspend fun SpMsPlayerService.applyServerState( } playlist.add(item) + listeners.forEach { + it.onSongAdded(playlist.size - 1, item) + } } } @@ -71,23 +81,20 @@ internal suspend fun SpMsPlayerService.applyServerState( _is_playing = state.is_playing listeners.forEach { it.onPlayingChanged(_is_playing) - it.onEvents() } } if (state.current_item_index != _current_song_index) { _current_song_index = state.current_item_index - val song = playlist.getOrNull(_current_song_index) + val song: Song? = playlist.getOrNull(_current_song_index) listeners.forEach { it.onSongTransition(song, false) - it.onEvents() } } if (state.repeat_mode != _repeat_mode) { _repeat_mode = state.repeat_mode listeners.forEach { it.onRepeatModeChanged(_repeat_mode) - it.onEvents() } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/tryConnectToServer.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/tryConnectToServer.kt index 5664cfa0e..25ce41607 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/tryConnectToServer.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/playerservice/tryConnectToServer.kt @@ -3,52 +3,61 @@ package com.toasterofbread.spmp.platform.playerservice import com.toasterofbread.spmp.resources.getString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.zeromq.ZMQ.Socket import org.zeromq.ZMsg -import spms.socketapi.shared.SpMsClientHandshake -import spms.socketapi.shared.SpMsSocketApi -import spms.socketapi.shared.SpMsServerHandshake +import dev.toastbits.spms.socketapi.shared.SpMsClientHandshake +import dev.toastbits.spms.socketapi.shared.SpMsSocketApi +import dev.toastbits.spms.socketapi.shared.SpMsServerHandshake +import java.util.concurrent.TimeoutException +import kotlin.time.Duration internal suspend fun Socket.tryConnectToServer( server_url: String, handshake: SpMsClientHandshake, json: Json, - shouldCancelConnection: () -> Boolean = { false }, log: (String) -> Unit = { println(it) }, setLoadState: ((PlayerServiceLoadState) -> Unit)? = null -): SpMsServerHandshake? = withContext(Dispatchers.IO) { - check(connect(server_url)) +): SpMsServerHandshake = withContext(Dispatchers.IO) { + val first_loading_message: String = getString("loading_splash_connecting_to_server_at_\$x").replace("\$x", server_url.split("://", limit = 2).last()) + setLoadState?.invoke(PlayerServiceLoadState(true, first_loading_message)) + + log("Connecting to server at $server_url...") + + while (true) { + try { + connect(server_url) + } + catch (e: Throwable) { + delay(1000) + continue + } + + break + } val handshake_message: ZMsg = ZMsg() handshake_message.add(json.encodeToString(handshake)) - check(handshake_message.send(this@tryConnectToServer)) - if (setLoadState != null) { - setLoadState( - PlayerServiceLoadState( - true, - getString("desktop_splash_connecting_to_server_at_\$x").replace("\$x", server_url.split("://", limit = 2).last()) - ) - ) - } + log("Sending handshake message to server at $server_url...") + check(handshake_message.send(this@tryConnectToServer)) log("Waiting for reply from server at $server_url...") - var reply: ZMsg? = null - while (reply == null) { - reply = recvMsg(500) - - if (shouldCancelConnection()) { - return@withContext null - } + var reply: ZMsg? + do { + reply = recvMsg(with (Duration) { 500.milliseconds }) + ensureActive() } + while (reply == null) val joined_reply: List = SpMsSocketApi.decode(reply.map { it.data.decodeToString() }) val server_handshake_data: String = joined_reply.first() - log("Received reply handshake from server with the following content:\n$server_handshake_data") + log("Received reply handshake from server with the following content:\n$joined_reply") val server_handshake: SpMsServerHandshake try { @@ -61,8 +70,8 @@ internal suspend fun Socket.tryConnectToServer( return@withContext server_handshake } -private fun Socket.recvMsg(timeout_ms: Long?): ZMsg? { - receiveTimeOut = timeout_ms?.toInt() ?: -1 +private fun Socket.recvMsg(timeout: Duration?): ZMsg? { + receiveTimeOut = timeout?.inWholeMilliseconds?.toInt() ?: -1 val msg: ZMsg? = ZMsg.recvMsg(this) receiveTimeOut = -1 return msg diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/splash/ExtraLoadingContent.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/splash/ExtraLoadingContent.kt deleted file mode 100644 index b693699de..000000000 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/splash/ExtraLoadingContent.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.toasterofbread.spmp.platform.splash - -import ProgramArguments -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -expect fun SplashExtraLoadingContent(modifier: Modifier = Modifier, arguments: ProgramArguments) diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/service/playercontroller/PlayerState.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/service/playercontroller/PlayerState.kt index 2d48a8b65..4f7bb946f 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/service/playercontroller/PlayerState.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/service/playercontroller/PlayerState.kt @@ -44,8 +44,12 @@ import com.toasterofbread.spmp.platform.AppContext import com.toasterofbread.spmp.platform.FormFactor import com.toasterofbread.spmp.platform.download.DownloadMethodSelectionDialog import com.toasterofbread.spmp.platform.download.DownloadStatus -import com.toasterofbread.spmp.platform.playerservice.PlatformPlayerService +import com.toasterofbread.spmp.platform.playerservice.PlayerService import com.toasterofbread.spmp.platform.playerservice.PlayerServicePlayer +import com.toasterofbread.spmp.platform.playerservice.PlayerServiceLoadState +import com.toasterofbread.spmp.platform.playerservice.PlayerServiceCompanion +import com.toasterofbread.spmp.platform.playerservice.PlatformInternalPlayerService +import com.toasterofbread.spmp.platform.playerservice.PlatformExternalPlayerService import com.toasterofbread.spmp.ui.component.longpressmenu.LongPressMenu import com.toasterofbread.spmp.ui.component.longpressmenu.LongPressMenuData import com.toasterofbread.spmp.ui.component.multiselect.AppPageMultiSelectContext @@ -71,6 +75,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.delay import com.toasterofbread.spmp.ui.layout.contentbar.layoutslot.* import com.toasterofbread.spmp.ui.layout.contentbar.* import com.toasterofbread.spmp.ui.layout.BarColourState @@ -85,19 +90,25 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.requiredWidth import kotlin.math.roundToInt import kotlin.math.absoluteValue +import ProgramArguments +import LocalProgramArguments typealias DownloadRequestCallback = (DownloadStatus?) -> Unit enum class FeedLoadState { PREINIT, NONE, LOADING, CONTINUING } // This is an atrocity -class PlayerState(val context: AppContext, internal val coroutine_scope: CoroutineScope) { +class PlayerState( + val context: AppContext, + val launch_arguments: ProgramArguments, + internal val coroutine_scope: CoroutineScope +) { val database: Database get() = context.database val settings: Settings get() = context.settings val theme: Theme get() = context.theme val app_page: AppPage get() = app_page_state.current_page - private var _player: PlatformPlayerService? by mutableStateOf(null) + private var _player: PlayerService? by mutableStateOf(null) private val app_page_undo_stack: MutableList = mutableStateListOf() @@ -215,13 +226,18 @@ class PlayerState(val context: AppContext, internal val coroutine_scope: Corouti SpMp.addLowMemoryListener(low_memory_listener) context.getPrefs().addListener(prefs_listener) - if (PlatformPlayerService.isServiceRunning(context)) { - connectService(null) + val service_companion: PlayerServiceCompanion = + if (!PlatformInternalPlayerService.isAvailable(context, launch_arguments) || settings.platform.ENABLE_EXTERNAL_SERVER_MODE.get()) + PlatformExternalPlayerService + else PlatformInternalPlayerService + + if (service_companion.isServiceRunning(context)) { + connectService(service_companion, null) } else { coroutine_scope.launch { if (PersistentQueueHandler.isPopulatedQueueSaved(context)) { - connectService(null) + connectService(service_companion, null) } } } @@ -234,13 +250,14 @@ class PlayerState(val context: AppContext, internal val coroutine_scope: Corouti fun release() { service_connection?.also { - PlatformPlayerService.disconnect(context, it) + service_connection_companion?.disconnect(context, it) } service_connection = null + service_connection_companion = null _player = null } - fun interactService(action: (player: PlatformPlayerService) -> Unit) { + fun interactService(action: (player: PlayerService) -> Unit) { synchronized(service_connected_listeners) { _player?.also { action(it) @@ -619,7 +636,7 @@ class PlayerState(val context: AppContext, internal val coroutine_scope: Corouti } } - val controller: PlatformPlayerService? get() = _player + val controller: PlayerService? get() = _player fun withPlayer(action: PlayerServicePlayer.() -> Unit) { _player?.also { action(it.service_player) @@ -633,21 +650,27 @@ class PlayerState(val context: AppContext, internal val coroutine_scope: Corouti @Composable fun withPlayerComposable(action: @Composable PlayerServicePlayer.() -> Unit) { - connectService(null) + LaunchedEffect(Unit) { + connectService(onConnected = null) + } + _player?.service_player?.also { action(it) } } - val service_connected: Boolean get() = _player?.load_state?.loading == false - val service_loading_message: String? get() = _player?.load_state?.takeIf { it.loading }?.loading_message - val service_connection_error: Throwable? get() = _player?.connection_error + val service_connected: Boolean get() = _player?.load_state?.let { !it.loading && it.error == null } ?: false + val service_load_state: PlayerServiceLoadState? get() = _player?.load_state - private var service_connecting = false - private var service_connected_listeners = mutableListOf<(PlatformPlayerService) -> Unit>() + private var service_connecting: Boolean = false + private var service_connected_listeners: MutableList<(PlayerService) -> Unit> = mutableListOf() private var service_connection: Any? = null + private var service_connection_companion: PlayerServiceCompanion? = null - private fun connectService(onConnected: ((PlatformPlayerService) -> Unit)?) { + private fun connectService( + service_companion: PlayerServiceCompanion = service_connection_companion!!, + onConnected: ((PlayerService) -> Unit)? + ) { synchronized(service_connected_listeners) { if (service_connecting) { if (onConnected != null) { @@ -661,9 +684,12 @@ class PlayerState(val context: AppContext, internal val coroutine_scope: Corouti return } + service_connection_companion = service_companion + service_connecting = true - service_connection = PlatformPlayerService.connect( + service_connection = service_companion.connect( context, + launch_arguments, _player, { service -> synchronized(service_connected_listeners) { @@ -685,6 +711,26 @@ class PlayerState(val context: AppContext, internal val coroutine_scope: Corouti } } + suspend fun requestServiceChange(service_companion: PlayerServiceCompanion) = withContext(Dispatchers.Default) { + synchronized(service_connected_listeners) { + service_connection?.also { connection -> + service_connection_companion!!.disconnect(context, connection) + service_connection_companion = null + service_connection = null + + _player?.also { + launch(Dispatchers.Main) { + it.onDestroy() + } + _player = null + } + } + + service_connecting = false + connectService(service_companion, onConnected = null) + } + } + val status: PlayerStatus = PlayerStatus() fun isRunningAndFocused(): Boolean { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/service/playercontroller/PlayerStatus.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/service/playercontroller/PlayerStatus.kt index a0dd6d1b0..738743163 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/service/playercontroller/PlayerStatus.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/service/playercontroller/PlayerStatus.kt @@ -3,13 +3,16 @@ package com.toasterofbread.spmp.service.playercontroller import androidx.compose.runtime.* import com.toasterofbread.spmp.model.mediaitem.song.Song import com.toasterofbread.spmp.platform.PlayerListener -import com.toasterofbread.spmp.platform.playerservice.PlatformPlayerService -import spms.socketapi.shared.SpMsPlayerRepeatMode +import com.toasterofbread.spmp.platform.playerservice.PlayerService +import dev.toastbits.spms.socketapi.shared.SpMsPlayerRepeatMode class PlayerStatus internal constructor() { - private var player: PlatformPlayerService? = null + private var player: PlayerService? = null + + internal fun setPlayer(new_player: PlayerService) { + player?.removeListener(player_listener) + new_player.addListener(player_listener) - internal fun setPlayer(new_player: PlatformPlayerService) { player = new_player m_playing = playing @@ -92,8 +95,8 @@ class PlayerStatus internal constructor() { "redo_count" to m_redo_count ).toString() - init { - PlatformPlayerService.addListener(object : PlayerListener() { + private val player_listener: PlayerListener = + object : PlayerListener() { init { onEvents() } @@ -127,6 +130,5 @@ class PlayerStatus internal constructor() { } } } - }) - } + } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/service/playercontroller/RadioHandler.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/service/playercontroller/RadioHandler.kt index de8c48eb2..adfb0098a 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/service/playercontroller/RadioHandler.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/service/playercontroller/RadioHandler.kt @@ -4,20 +4,28 @@ import com.toasterofbread.spmp.model.mediaitem.song.Song import com.toasterofbread.spmp.model.radio.RadioInstance import com.toasterofbread.spmp.model.radio.RadioState import com.toasterofbread.spmp.platform.AppContext -import com.toasterofbread.spmp.platform.playerservice.PlatformPlayerService import com.toasterofbread.spmp.platform.playerservice.PlayerServicePlayer import com.toasterofbread.spmp.platform.playerservice.UndoRedoAction +import com.toasterofbread.spmp.platform.playerservice.PlayerService -// Radio continuation will be added if the amount of remaining songs (including current) falls below this // TODO Add setting +// Radio continuation will be added if the amount of remaining songs (including current) falls below this private const val RADIO_MIN_LENGTH: Int = 10 -class RadioHandler(val player: PlayerServicePlayer, val context: AppContext) { - val instance: RadioInstance = object : RadioInstance(context) { - override suspend fun onLoadCompleted(result: RadioInstance.LoadResult, is_continuation: Boolean) { - onRadioLoadCompleted(result, is_continuation) +open class RadioHandler(val player: PlayerServicePlayer, val context: AppContext) { + val instance: RadioInstance = + object : RadioInstance(context) { + override suspend fun onLoadCompleted(result: RadioInstance.LoadResult, is_continuation: Boolean) { + onRadioLoadCompleted(result, is_continuation) + } + + override fun cancelRadio() { + super.cancelRadio() + onRadioCancelled() + } } - } + + open fun onRadioCancelled() {} fun setUndoableRadioState( new_radio_state: RadioState, @@ -30,7 +38,7 @@ class RadioHandler(val player: PlayerServicePlayer, val context: AppContext) { return object : UndoRedoAction { var first_redo: Boolean = true - override fun redo(service: PlatformPlayerService) { + override fun redo(service: PlayerService) { instance.setRadioState( new_radio_state, onCompleted = @@ -61,7 +69,7 @@ class RadioHandler(val player: PlayerServicePlayer, val context: AppContext) { ) } - override fun undo(service: PlatformPlayerService) { + override fun undo(service: PlayerService) { instance.setRadioState(old_radio_state) } } @@ -98,11 +106,11 @@ class RadioHandler(val player: PlayerServicePlayer, val context: AppContext) { ) return@customUndoableAction object : UndoRedoAction { - override fun redo(service: PlatformPlayerService) { + override fun redo(service: PlayerService) { instance.setFilter(filter_index) } - override fun undo(service: PlatformPlayerService) { + override fun undo(service: PlayerService) { instance.setFilter(previous_filter_index) } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/ErrorInfoDisplay.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/ErrorInfoDisplay.kt index d1fee0444..1f7b212a8 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/ErrorInfoDisplay.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/ErrorInfoDisplay.kt @@ -20,11 +20,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.KeyboardArrowDown @@ -52,6 +54,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Density +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import dev.toastbits.composekit.utils.common.thenIf import dev.toastbits.composekit.utils.composable.ShapedIconButton import dev.toastbits.composekit.utils.composable.WidthShrinkText @@ -103,15 +109,21 @@ fun ErrorInfoDisplay( } val player: PlayerState = LocalPlayerState.current + val density: Density = LocalDensity.current var expanded: Boolean by remember { mutableStateOf(start_expanded) } val shape: Shape = RoundedCornerShape(20.dp) CompositionLocalProvider(LocalContentColor provides player.theme.background) { + var width: Dp by remember { mutableStateOf(0.dp) } + Column( modifier .animateContentSize() .background(getAccentColour(player), shape) - .padding(horizontal = 10.dp), + .padding(horizontal = 10.dp) + .onSizeChanged { + width = with (density) { it.width.toDp() } + }, verticalArrangement = Arrangement.Center ) { Row( @@ -183,6 +195,7 @@ fun ErrorInfoDisplay( AnimatedVisibility( expanded, + Modifier.requiredWidth(width), enter = expandVertically(), exit = shrinkVertically() ) { @@ -194,36 +207,18 @@ fun ErrorInfoDisplay( @Composable private fun LongTextDisplay(text: String, wrap_text: Boolean, modifier: Modifier = Modifier) { - val player = LocalPlayerState.current - val split_text = remember(text) { - text.chunked(10000) - } - - LazyColumn(modifier) { - items(split_text) { segment -> - SelectionContainer { - Text( - segment, - color = player.theme.on_background, - softWrap = wrap_text - ) - } - } - - - if (text.none { it == '\n' }) { - item { - Row { - player.context.CopyShareButtons() { - text - } - } - } - } + val player: PlayerState = LocalPlayerState.current + val limited_text: String = remember(text) { text.take(10000) } - item { - Spacer(Modifier.height(50.dp)) - } + SelectionContainer { + Text( + limited_text, + modifier + .verticalScroll(rememberScrollState()) + .padding(bottom = 50.dp), + color = player.theme.on_background, + softWrap = wrap_text + ) } } @@ -287,7 +282,7 @@ private fun ExpandedContent( colors = button_colours, contentPadding = PaddingValues(0.dp), ) { - WidthShrinkText(getString("upload_to_paste_dot_ee"), alignment = TextAlign.Center, style = LocalTextStyle.current.copy(color = player.theme.on_accent)) + Text(getString("upload_to_paste_dot_ee"), textAlign = TextAlign.Center, style = LocalTextStyle.current.copy(color = player.theme.on_accent), softWrap = false) } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenuActionProvider.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenuActionProvider.kt index 7b8e63f97..ad72b80b8 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenuActionProvider.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenuActionProvider.kt @@ -29,7 +29,7 @@ import dev.toastbits.composekit.platform.composable.platformClickable import dev.toastbits.composekit.platform.vibrateShort import dev.toastbits.composekit.utils.common.thenIf import com.toasterofbread.spmp.model.mediaitem.song.Song -import com.toasterofbread.spmp.platform.playerservice.PlatformPlayerService +import com.toasterofbread.spmp.platform.playerservice.PlayerService import com.toasterofbread.spmp.service.playercontroller.LocalPlayerClickOverrides import com.toasterofbread.spmp.ui.component.mediaitempreview.MediaItemPreviewLong import com.toasterofbread.spmp.service.playercontroller.PlayerState @@ -52,7 +52,7 @@ class LongPressMenuActionProvider( onLongClick: ((active_queue_index: Int) -> Unit)? = null ) { val player: PlayerState = LocalPlayerState.current - val service: PlatformPlayerService = LocalPlayerState.current.controller ?: return + val service: PlayerService = LocalPlayerState.current.controller ?: return var active_queue_item: Song? by remember { mutableStateOf(null) } AnimatedVisibility(service.service_player.active_queue_index < player.status.m_song_count) { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/controlpanelpage/ControlPanelServerPage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/controlpanelpage/ControlPanelServerPage.kt index 82cb0a2ae..af2f4e5e9 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/controlpanelpage/ControlPanelServerPage.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/controlpanelpage/ControlPanelServerPage.kt @@ -27,8 +27,8 @@ import com.toasterofbread.spmp.ui.component.multiselect.MediaItemMultiSelectCont import com.toasterofbread.spmp.service.playercontroller.PlayerState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import spms.socketapi.shared.SpMsClientInfo -import spms.socketapi.shared.SpMsClientType +import dev.toastbits.spms.socketapi.shared.SpMsClientInfo +import dev.toastbits.spms.socketapi.shared.SpMsClientType @Composable fun ControlPanelServerPage( @@ -215,7 +215,7 @@ private fun ClientInfoDisplay(client: SpMsClientInfo, modifier: Modifier = Modif val player: PlayerState = LocalPlayerState.current val coroutine_scope: CoroutineScope = rememberCoroutineScope() - val machine_id: String = remember { getSpMsMachineId() } + val machine_id: String = remember { getSpMsMachineId(player.context) } Card( modifier, diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/mainpage/LoadingSplashView.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/mainpage/LoadingSplashView.kt deleted file mode 100644 index 3a27e9aae..000000000 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/mainpage/LoadingSplashView.kt +++ /dev/null @@ -1,195 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") -package com.toasterofbread.spmp.ui.layout.apppage.mainpage - -import LocalPlayerState -import ProgramArguments -import SpMp.isDebugBuild -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.Paint -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import dev.toastbits.composekit.utils.common.blockGestures -import dev.toastbits.composekit.utils.common.thenIf -import dev.toastbits.composekit.utils.common.toFloat -import dev.toastbits.composekit.utils.composable.NullableValueAnimatedVisibility -import com.toasterofbread.spmp.platform.splash.SplashExtraLoadingContent -import com.toasterofbread.spmp.resources.getString -import com.toasterofbread.spmp.service.playercontroller.PlayerState -import com.toasterofbread.spmp.ui.component.ErrorInfoDisplay -import kotlinx.coroutines.delay -import org.jetbrains.compose.resources.* -import spmp.shared.generated.resources.* - -private const val MESSAGE_DELAY: Long = 2000L -enum class SplashMode { - SPLASH, WARNING -} - -@Composable -fun LoadingSplashView( - splash_mode: SplashMode?, - loading_message: String?, - connection_error: Throwable?, - arguments: ProgramArguments, - modifier: Modifier = Modifier -) { - val player: PlayerState = LocalPlayerState.current - - var show_message: Boolean by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - delay(MESSAGE_DELAY) - show_message = true - } - - Crossfade(splash_mode, modifier) { mode -> - when (mode) { - null -> {} - SplashMode.SPLASH -> { - val image: ImageBitmap = imageResource(Res.drawable.ic_splash) - - Column( - Modifier.fillMaxSize().background(player.theme.background).padding(10.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically) - ) { - var launched: Boolean by remember { mutableStateOf(false) } - val image_alpha: Float by animateFloatAsState(if (launched) 1f else 0f, tween(2000)) - - LaunchedEffect(Unit) { - launched = true - } - - Box( - Modifier - .size( - with(LocalDensity.current) { - minOf(image.width.toDp(), image.height.toDp(), 200.dp) - } - ) - .weight(1f, false) - .alpha(image_alpha) - .drawWithContent { - drawIntoCanvas { canvas -> - val first_filter: ColorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) - val second_filter: ColorFilter = ColorFilter.tint(player.theme.accent, BlendMode.Modulate) - - canvas.saveLayer( - Rect(0f, 0f, size.width, size.height), - Paint().apply { - colorFilter = second_filter - } - ) - drawImage( - image, - dstSize = IntSize(size.width.toInt(), size.height.toInt()), - colorFilter = first_filter - ) - canvas.restore() - } - } - ) - - AnimatedVisibility(show_message) { - Column( - Modifier.width(500.dp).animateContentSize(), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - if (loading_message != null) { - Text(loading_message, Modifier.padding(horizontal = 20.dp), color = player.theme.on_background) - } - LinearProgressIndicator(Modifier.fillMaxWidth(), color = player.theme.accent) - } - } - - val extra_content_alpha: Float by animateFloatAsState(show_message.toFloat()) - SplashExtraLoadingContent( - Modifier - .thenIf(!show_message) { - blockGestures() - } - .graphicsLayer { alpha = extra_content_alpha }, - arguments = arguments - ) - - NullableValueAnimatedVisibility(connection_error) { error -> - ErrorInfoDisplay(error, isDebugBuild(), onDismiss = null) - } - } - } - SplashMode.WARNING -> { - Column( - Modifier.fillMaxSize().background(player.theme.background), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically) - ) { - CompositionLocalProvider(LocalContentColor provides player.theme.on_background) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Icon(Icons.Default.Warning, null, tint = player.theme.on_background) - Text(getString("error_player_service_not_connected"), color = player.theme.on_background) - } - - if (player.context.canOpenUrl()) { - Button( - { - player.context.openUrl(getString("report_issue_url")) - }, - colors = ButtonDefaults.buttonColors( - containerColor = player.theme.accent, - contentColor = player.theme.on_accent - ) - ) { - Text(getString("report_error")) - } - } - } - } - } - } - } -} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/DesktopCategory.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/DesktopCategory.kt deleted file mode 100644 index 637ce81f1..000000000 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/DesktopCategory.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.toasterofbread.spmp.ui.layout.apppage.settingspage.category - -import androidx.compose.ui.Modifier -import dev.toastbits.composekit.settings.ui.item.GroupSettingsItem -import dev.toastbits.composekit.settings.ui.item.InfoTextSettingsItem -import dev.toastbits.composekit.settings.ui.item.SettingsItem -import dev.toastbits.composekit.platform.PreferencesProperty -import dev.toastbits.composekit.settings.ui.item.TextFieldSettingsItem -import dev.toastbits.composekit.settings.ui.item.ToggleSettingsItem -import com.toasterofbread.spmp.resources.getString -import com.toasterofbread.spmp.ui.layout.apppage.mainpage.appTextField -import com.toasterofbread.spmp.platform.AppContext - -internal fun getDesktopCategoryItems(context: AppContext): List { - return listOf( - GroupSettingsItem( - getString("s_group_desktop_system") - ), - - TextFieldSettingsItem( - context.settings.desktop.STARTUP_COMMAND, - getFieldModifier = { Modifier.appTextField() } - ), - - ToggleSettingsItem( - context.settings.desktop.FORCE_SOFTWARE_RENDERER, - ), - - GroupSettingsItem( - getString("s_group_server") - ) - ) + getServerGroupItems(context) -} - -fun getServerGroupItems(context: AppContext): List { - // (I will never learn regex) - // https://stackoverflow.com/a/36760050 - val ip_regex: Regex = "^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}\$".toRegex() - // https://stackoverflow.com/a/12968117 - val port_regex: Regex = "^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])\$".toRegex() - - check(ip_regex.matches("127.0.0.1")) - check(port_regex.matches("1111")) - - return listOf( - InfoTextSettingsItem( - getString("s_info_server") - ), - - TextFieldSettingsItem( - context.settings.desktop.SERVER_IP_ADDRESS, - getStringError = { input -> - if (!ip_regex.matches(input)) { - return@TextFieldSettingsItem getString("settings_value_not_ipv4") - } - return@TextFieldSettingsItem null - }, - getFieldModifier = { Modifier.appTextField() } - ), - - TextFieldSettingsItem( - context.settings.desktop.SERVER_PORT.getConvertedProperty( - fromProperty = { it.toString() }, - toProperty = { it.toIntOrNull() ?: 0 } - ), - getStringError = { input -> - if (!port_regex.matches(input)) { - return@TextFieldSettingsItem getString("settings_value_not_port") - } - return@TextFieldSettingsItem null - }, - getFieldModifier = { Modifier.appTextField() } - ), - - TextFieldSettingsItem( - context.settings.desktop.SERVER_LOCAL_COMMAND, - getFieldModifier = { Modifier.appTextField() } - ), - - ToggleSettingsItem( - context.settings.desktop.SERVER_LOCAL_START_AUTOMATICALLY - ), - - ToggleSettingsItem( - context.settings.desktop.SERVER_KILL_CHILD_ON_EXIT - ) - ) -} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/PlatformCategory.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/PlatformCategory.kt new file mode 100644 index 000000000..0acf9002b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/PlatformCategory.kt @@ -0,0 +1,143 @@ +package com.toasterofbread.spmp.ui.layout.apppage.settingspage.category + +import LocalPlayerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import dev.toastbits.composekit.settings.ui.item.GroupSettingsItem +import dev.toastbits.composekit.settings.ui.item.InfoTextSettingsItem +import dev.toastbits.composekit.settings.ui.item.SettingsItem +import dev.toastbits.composekit.platform.PreferencesProperty +import dev.toastbits.composekit.platform.Platform +import dev.toastbits.composekit.settings.ui.item.TextFieldSettingsItem +import dev.toastbits.composekit.settings.ui.item.ToggleSettingsItem +import com.toasterofbread.spmp.resources.getString +import com.toasterofbread.spmp.ui.layout.apppage.mainpage.appTextField +import com.toasterofbread.spmp.platform.AppContext +import com.toasterofbread.spmp.platform.playerservice.PlatformInternalPlayerService +import com.toasterofbread.spmp.platform.playerservice.PlatformExternalPlayerService +import com.toasterofbread.spmp.service.playercontroller.PlayerState +import LocalProgramArguments +import ProgramArguments + +internal fun getPlatformCategoryItems(context: AppContext): List { + val platform_items: List = + when (Platform.current) { + Platform.ANDROID -> getAndroidGroupItems(context) + Platform.DESKTOP -> getDesktopGroupItems(context) + } + + return platform_items + getServerGroupItems(context) +} + +private fun getAndroidGroupItems(context: AppContext): List = + listOf() + +private fun getDesktopGroupItems(context: AppContext): List = + listOf( + GroupSettingsItem( + getString("s_group_desktop_system") + ), + + TextFieldSettingsItem( + context.settings.platform.STARTUP_COMMAND, + getFieldModifier = { Modifier.appTextField() } + ), + + ToggleSettingsItem( + context.settings.platform.FORCE_SOFTWARE_RENDERER, + ), + + GroupSettingsItem( + getString("s_group_server") + ) + ) + +fun getServerGroupItems(context: AppContext): List { + // (I will never learn regex) + // https://stackoverflow.com/a/36760050 + val ip_regex: Regex = "^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}\$".toRegex() + // https://regexr.com/3au3g + val domain_regex: Regex = "(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]".toRegex() + // https://stackoverflow.com/a/12968117 + val port_regex: Regex = "^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])\$".toRegex() + + check(ip_regex.matches("127.0.0.1")) + check(!ip_regex.matches("0.0")) + check(!ip_regex.matches("a.b.c.d")) + + check(domain_regex.matches("domain.name")) + check(!domain_regex.matches("http://domain.name")) + check(!domain_regex.matches("domain.name:port")) + check(!domain_regex.matches("domain.name/path")) + + check(port_regex.matches("1111")) + check(!port_regex.matches("a")) + + return listOfNotNull( + InfoTextSettingsItem( + getString("s_info_server") + ), + + ToggleSettingsItem( + context.settings.platform.ENABLE_EXTERNAL_SERVER_MODE, + getEnabled = { + getLocalServerUnavailabilityReason() == null + }, + getValueOverride = { + if (getLocalServerUnavailabilityReason() != null) { + true + } + else { + null + } + }, + getSubtitleOverride = { + getLocalServerUnavailabilityReason() + } + ), + + ToggleSettingsItem(context.settings.platform.EXTERNAL_SERVER_MODE_UI_ONLY).takeIf { PlatformExternalPlayerService.playsAudio() }, + + TextFieldSettingsItem( + context.settings.platform.SERVER_IP_ADDRESS, + getStringError = { input -> + if (!ip_regex.matches(input) && !domain_regex.matches(input)) { + return@TextFieldSettingsItem getString("settings_value_not_ipv4_or_domain") + } + return@TextFieldSettingsItem null + }, + getFieldModifier = { Modifier.appTextField() } + ), + + TextFieldSettingsItem( + context.settings.platform.SERVER_PORT.getConvertedProperty( + fromProperty = { it.toString() }, + toProperty = { it.toIntOrNull() ?: 0 } + ), + getStringError = { input -> + if (!port_regex.matches(input)) { + return@TextFieldSettingsItem getString("settings_value_not_port") + } + return@TextFieldSettingsItem null + }, + getFieldModifier = { Modifier.appTextField() } + ), + + TextFieldSettingsItem( + context.settings.platform.SERVER_LOCAL_COMMAND, + getFieldModifier = { Modifier.appTextField() } + ).takeIf { Platform.DESKTOP.isCurrent() }, + + ToggleSettingsItem( + context.settings.platform.SERVER_LOCAL_START_AUTOMATICALLY + ).takeIf { Platform.DESKTOP.isCurrent() } + ) +} + +@Composable +private fun getLocalServerUnavailabilityReason(): String? { + val player: PlayerState = LocalPlayerState.current + val launch_arguments: ProgramArguments = LocalProgramArguments.current + return remember { PlatformInternalPlayerService.getUnavailabilityReason(player.context, launch_arguments) } +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/loadingsplash/ExtraLoadingContent.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/loadingsplash/ExtraLoadingContent.kt new file mode 100644 index 000000000..eca5c3a97 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/loadingsplash/ExtraLoadingContent.kt @@ -0,0 +1,91 @@ +package com.toasterofbread.spmp.ui.layout.loadingsplash + +import LocalPlayerState +import SpMp +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.toastbits.composekit.utils.composable.ShapedIconButton +import com.toasterofbread.spmp.model.settings.Settings +import com.toasterofbread.spmp.resources.getString +import com.toasterofbread.spmp.ui.layout.apppage.settingspage.category.getServerGroupItems +import com.toasterofbread.spmp.ui.component.ErrorInfoDisplay +import com.toasterofbread.spmp.service.playercontroller.PlayerState +import com.toasterofbread.spmp.platform.playerservice.LocalServer +import dev.toastbits.composekit.utils.composable.ShapedIconButton +import dev.toastbits.composekit.settings.ui.item.SettingsItem +import LocalProgramArguments +import ProgramArguments + +private const val LOCAL_SERVER_AUTOSTART_DELAY_MS: Long = 100 + +@Composable +fun SplashExtraLoadingContent(item_modifier: Modifier) { + val player: PlayerState = LocalPlayerState.current + var show_config_dialog: Boolean by remember { mutableStateOf(false) } + + val button_colours: ButtonColors = + ButtonDefaults.buttonColors( + containerColor = player.theme.accent, + contentColor = player.theme.on_accent + ) + + Button( + { show_config_dialog = true }, + colors = button_colours, + modifier = item_modifier + ) { + Text(getString("loading_splash_button_configure_connection")) + } + + if (player.context.canOpenUrl()) { + ShapedIconButton( + { + player.context.openUrl(getString("server_info_url")) + }, + colours = IconButtonDefaults.iconButtonColors( + containerColor = player.theme.accent, + contentColor = player.theme.on_accent + ), + modifier = item_modifier + ) { + Icon(Icons.Default.Info, null) + } + } + + if (show_config_dialog) { + val settings_items: List = remember { getServerGroupItems(player.context) } + + AlertDialog( + onDismissRequest = { show_config_dialog = false }, + confirmButton = { + Button( + { show_config_dialog = false }, + colors = button_colours + ) { + Text(getString("action_close")) + } + }, + title = { + Text(getString("loading_splash_title_configure_server_connection")) + }, + text = { + LazyColumn(verticalArrangement = Arrangement.spacedBy(20.dp)) { + items(settings_items) { item -> + item.Item(player.app_page_state.Settings.settings_interface, { _, _ -> }, {}, Modifier) + } + } + } + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/loadingsplash/LoadingSplash.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/loadingsplash/LoadingSplash.kt new file mode 100644 index 000000000..3502fc6b7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/loadingsplash/LoadingSplash.kt @@ -0,0 +1,240 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") +package com.toasterofbread.spmp.ui.layout.loadingsplash + +import LocalPlayerState +import SpMp.isDebugBuild +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.background +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import com.toasterofbread.spmp.platform.playerservice.PlayerServiceLoadState +import com.toasterofbread.spmp.platform.playerservice.PlayerServiceCompanion +import com.toasterofbread.spmp.resources.getString +import com.toasterofbread.spmp.ui.component.ErrorInfoDisplay +import com.toasterofbread.spmp.service.playercontroller.PlayerState +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.* +import spmp.shared.generated.resources.* +import dev.toastbits.composekit.utils.common.toFloat +import dev.toastbits.composekit.utils.common.thenIf +import dev.toastbits.composekit.utils.common.blockGestures +import dev.toastbits.composekit.utils.composable.wave.OverlappingWaves +import dev.toastbits.composekit.utils.composable.wave.getDefaultOverlappingWavesLayers +import dev.toastbits.composekit.utils.composable.wave.WaveLayer +import dev.toastbits.composekit.utils.composable.NullableValueAnimatedVisibility + +private const val MESSAGE_DISPLAY_DELAY: Long = 1000L +enum class SplashMode { + SPLASH, WARNING +} + +@Composable +fun LoadingSplash( + splash_mode: SplashMode?, + load_state: PlayerServiceLoadState?, + requestServiceChange: (PlayerServiceCompanion) -> Unit, + modifier: Modifier = Modifier, + content_padding: PaddingValues = PaddingValues(), +) { + val player: PlayerState = LocalPlayerState.current + + var show_message: Boolean by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + delay(MESSAGE_DISPLAY_DELAY) + show_message = true + } + + player.controller?.PersistentContent(requestServiceChange = requestServiceChange) + + BoxWithConstraints(modifier.background(player.theme.background)) { + val wave_layers: List = remember { + getDefaultOverlappingWavesLayers(7, 0.35f) + } + val waves_height: Dp = (maxHeight * 0.3f).coerceAtLeast(100.dp) + + AnimatedVisibility( + show_message || splash_mode != SplashMode.SPLASH, + enter = fadeIn(), + exit = fadeOut() + ) { + OverlappingWaves( + { player.theme.accent.copy(alpha = 0.2f) }, + BlendMode.Screen, + modifier + .fillMaxWidth(1f) + .requiredHeight(waves_height) + .offset(y = (maxHeight - waves_height) / 2) + .align(Alignment.BottomCenter), + layers = wave_layers, + speed = 0.3f + ) + } + + Crossfade(splash_mode, Modifier.padding(content_padding)) { mode -> + when (mode) { + null -> {} + SplashMode.SPLASH -> { + val image: ImageBitmap = imageResource(Res.drawable.ic_splash) + + FlowRow( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically) + ) { + var launched: Boolean by remember { mutableStateOf(false) } + val image_alpha: Float by animateFloatAsState(if (launched) 1f else 0f, tween(2000)) + + LaunchedEffect(Unit) { + launched = true + } + + Box( + Modifier + .align(Alignment.CenterVertically) + .size(250.dp) + .aspectRatio(1f) + // .weight(1f, false) + .alpha(image_alpha) + .drawWithContent { + drawIntoCanvas { canvas -> + val first_filter: ColorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) + val second_filter: ColorFilter = ColorFilter.tint(player.theme.accent, BlendMode.Modulate) + + canvas.saveLayer( + Rect(0f, 0f, size.width, size.height), + Paint().apply { + colorFilter = second_filter + } + ) + drawImage( + image, + dstSize = IntSize(size.width.toInt(), size.height.toInt()), + colorFilter = first_filter + ) + canvas.restore() + } + } + ) + + AnimatedVisibility( + show_message, + Modifier.align(Alignment.CenterVertically) + ) { + Column( + Modifier.width(IntrinsicSize.Max), + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + NullableValueAnimatedVisibility(load_state?.loading_message) { message -> + if (message != null) { + Text( + message, + color = player.theme.on_background, + modifier = Modifier.wrapContentWidth() + ) + } + } + + AnimatedVisibility(load_state?.loading == true) { + LinearProgressIndicator( + Modifier.fillMaxWidth().height(2.dp), + color = player.theme.accent + ) + } + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally) + ) { + player.controller?.LoadScreenExtraContent( + Modifier.align(Alignment.CenterVertically), + requestServiceChange = requestServiceChange + ) + + SplashExtraLoadingContent(Modifier.align(Alignment.CenterVertically)) + } + + NullableValueAnimatedVisibility(load_state?.error) { error -> + ErrorInfoDisplay( + error, + isDebugBuild(), + Modifier.fillMaxWidth(), + onDismiss = null, + expanded_content_modifier = Modifier.height(300.dp).fillMaxWidth() + ) + } + } + } + } + } + SplashMode.WARNING -> { + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically) + ) { + CompositionLocalProvider(LocalContentColor provides player.theme.on_background) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon(Icons.Default.Warning, null, tint = player.theme.on_background) + Text(getString("error_player_service_not_connected"), color = player.theme.on_background) + } + + if (player.context.canOpenUrl()) { + Button( + { + player.context.openUrl(getString("report_issue_url")) + }, + colors = ButtonDefaults.buttonColors( + containerColor = player.theme.accent, + contentColor = player.theme.on_accent + ) + ) { + Text(getString("report_error")) + } + } + } + } + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/container/PlayerOverscroll.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/container/PlayerOverscroll.kt index dcbbdd0ad..0688ff854 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/container/PlayerOverscroll.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/container/PlayerOverscroll.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalDensity import dev.toastbits.composekit.platform.vibrateShort import com.toasterofbread.spmp.model.settings.category.* -import com.toasterofbread.spmp.platform.playerservice.PlatformPlayerService +import com.toasterofbread.spmp.platform.playerservice.PlayerService import com.toasterofbread.spmp.service.playercontroller.PlayerState import com.toasterofbread.spmp.ui.layout.nowplaying.container.npAnchorToDp import kotlinx.coroutines.delay @@ -42,7 +42,7 @@ internal fun Modifier.playerOverscroll( } val player: PlayerState = LocalPlayerState.current - val controller: PlatformPlayerService? = player.controller + val controller: PlayerService? = player.controller val density: Density = LocalDensity.current var player_alpha: Float by remember { mutableStateOf(1f) } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/overlay/lyrics/LyricsSyncMenu.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/overlay/lyrics/LyricsSyncMenu.kt index dde1f851d..b35e7d0fa 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/overlay/lyrics/LyricsSyncMenu.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/overlay/lyrics/LyricsSyncMenu.kt @@ -2,14 +2,7 @@ package com.toasterofbread.spmp.ui.layout.nowplaying.overlay.lyrics import LocalPlayerState import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Done @@ -29,7 +22,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.draw.alpha import com.toasterofbread.spmp.model.lyrics.SongLyrics import com.toasterofbread.spmp.model.mediaitem.song.Song -import com.toasterofbread.spmp.platform.playerservice.PlatformPlayerService +import com.toasterofbread.spmp.platform.playerservice.PlayerService import com.toasterofbread.spmp.resources.getString import com.toasterofbread.spmp.ui.component.HorizontalFuriganaText import com.toasterofbread.spmp.service.playercontroller.PlayerState @@ -51,7 +44,7 @@ fun LyricsSyncMenu( require(lyrics.synced) val player: PlayerState = LocalPlayerState.current - val service: PlatformPlayerService? = player.controller + val service: PlayerService? = player.controller LaunchedEffect(line_index) { service?.seekTo( @@ -108,7 +101,7 @@ fun LyricsSyncMenu( } @Composable -private fun PlayerControls(service: PlatformPlayerService?, onSelected: () -> Unit) { +private fun PlayerControls(service: PlayerService?, onSelected: () -> Unit) { val button_modifier = Modifier.size(40.dp) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/queue/QueueItems.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/queue/QueueItems.kt index b7b94214b..159a07b7c 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/queue/QueueItems.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/queue/QueueItems.kt @@ -29,7 +29,7 @@ fun LazyListScope.QueueItems( ) { val items: List = song_items.toList() items(items.size, { items[it].key }) { index -> - val item: QueueTabItem = song_items[index] + val item: QueueTabItem = song_items.getOrNull(index) ?: return@items ReorderableItem(queue_list_state, item.key, item_modifier) { is_dragging -> LaunchedEffect(is_dragging) { if (is_dragging) { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/queue/QueueTab.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/queue/QueueTab.kt index c63b35f89..24a1784e5 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/queue/QueueTab.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/queue/QueueTab.kt @@ -47,7 +47,7 @@ import com.toasterofbread.spmp.model.mediaitem.song.Song import com.toasterofbread.spmp.model.settings.category.NowPlayingQueueRadioInfoPosition import com.toasterofbread.spmp.model.settings.category.NowPlayingQueueWaveBorderMode import com.toasterofbread.spmp.platform.PlayerListener -import com.toasterofbread.spmp.platform.playerservice.PlatformPlayerService +import com.toasterofbread.spmp.platform.playerservice.PlayerService import com.toasterofbread.spmp.service.playercontroller.LocalPlayerClickOverrides import com.toasterofbread.spmp.ui.component.WaveBorder import com.toasterofbread.spmp.ui.component.multiselect.MediaItemMultiSelectContext @@ -147,9 +147,14 @@ internal fun QueueTab( } DisposableEffect(Unit) { - PlatformPlayerService.addListener(queue_listener) + player.interactService { + it.addListener(queue_listener) + } + onDispose { - PlatformPlayerService.removeListener(queue_listener) + player.interactService { + it.removeListener(queue_listener) + } } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/queue/RepeatButton.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/queue/RepeatButton.kt index b6ad40ce9..c27ebcb8c 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/queue/RepeatButton.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/queue/RepeatButton.kt @@ -21,7 +21,7 @@ import dev.toastbits.composekit.utils.common.getContrasted import dev.toastbits.composekit.utils.common.getInnerSquareSizeOfCircle import dev.toastbits.composekit.utils.composable.crossOut import dev.toastbits.composekit.utils.modifier.background -import spms.socketapi.shared.SpMsPlayerRepeatMode +import dev.toastbits.spms.socketapi.shared.SpMsPlayerRepeatMode import com.toasterofbread.spmp.service.playercontroller.PlayerState import kotlin.math.roundToInt diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/NewPipeVideoFormatsEndpoint.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/NewPipeVideoFormatsEndpoint.kt index 2a4114fbe..054866e55 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/NewPipeVideoFormatsEndpoint.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/NewPipeVideoFormatsEndpoint.kt @@ -21,30 +21,34 @@ import io.ktor.http.HttpMethod import io.ktor.http.takeFrom import io.ktor.http.HttpStatusCode import io.ktor.util.toMap +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext class NewPipeVideoFormatsEndpoint(override val api: YtmApi): VideoFormatsEndpoint() { override suspend fun getVideoFormats( id: String, include_non_default: Boolean, filter: ((YoutubeVideoFormat) -> Boolean)? - ): Result> = runCatching { - init(api) + ): Result> = withContext(Dispatchers.IO) { + runCatching { + init(api) - val link_handler: LinkHandler = YoutubeStreamLinkHandlerFactory.getInstance().fromId(id) - val youtube_stream_extractor: StreamExtractor = NewPipe.getService(ServiceList.YouTube.serviceId).getStreamExtractor(link_handler) + val link_handler: LinkHandler = YoutubeStreamLinkHandlerFactory.getInstance().fromId(id) + val youtube_stream_extractor: StreamExtractor = NewPipe.getService(ServiceList.YouTube.serviceId).getStreamExtractor(link_handler) - val stream_info: StreamInfo = StreamInfo.getInfo(youtube_stream_extractor) + val stream_info: StreamInfo = StreamInfo.getInfo(youtube_stream_extractor) - val audio_streams: List = stream_info.audioStreams - .map { it.toYoutubeVideoFormat() } - .filter { filter?.invoke(it) ?: true } + val audio_streams: List = stream_info.audioStreams + .map { it.toYoutubeVideoFormat() } + .filter { filter?.invoke(it) ?: true } - val video_streams: List = stream_info.videoStreams - .map { it.toYoutubeVideoFormat() } - .filter { filter?.invoke(it) ?: true } + val video_streams: List = stream_info.videoStreams + .map { it.toYoutubeVideoFormat() } + .filter { filter?.invoke(it) ?: true } - return@runCatching audio_streams + video_streams + return@runCatching audio_streams + video_streams + } } companion object { diff --git a/shared/src/commonMain/resources/assets/values-fr-FR/strings.xml b/shared/src/commonMain/resources/assets/values-fr-FR/strings.xml index dd117cc14..e06185d2f 100644 --- a/shared/src/commonMain/resources/assets/values-fr-FR/strings.xml +++ b/shared/src/commonMain/resources/assets/values-fr-FR/strings.xml @@ -240,14 +240,14 @@ Le service de lecture ne se connecte pas Un bug s'est peut-être produit Une erreur s'est produite lors de l'appel de la commande du serveur - Connexion au serveur à $x - Configuration de l'état initial - Configurer la connexion - Configurer la connexion au serveur - Démarrer le serveur local - Arrêter le processus - Processus en cours d'exécution avec cette commande : $x - Commande du serveur local non définie + Connexion au serveur à $x + Configuration de l'état initial + Configurer la connexion + Configurer la connexion au serveur + Démarrer le serveur local + Arrêter le processus + Processus en cours d'exécution avec cette commande : $x + Commande du serveur local non définie Lancer Signaler @@ -333,7 +333,6 @@ Hors de la plage ($range) Pas un entier Pas un nombre décimal - Pas une adresse IPv4 valide Pas un port valide Paramètres @@ -585,7 +584,6 @@ Commande à exécuter lors du démarrage manuel du serveur depuis SpMp . La première occurrence de '$@' sera remplacée par les arguments du serveur, sinon ils seront ajoutés à la commande Démarrer automatiquement le serveur local Au démarrage, si aucun serveur n'est trouvé immédiatement, un serveur sera démarré automatiquement en utilisant la commande spécifiée - Arrêter le serveur enfant à la sortie Développement diff --git a/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml b/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml index 90fc5dc46..f0f32335e 100644 --- a/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml +++ b/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml @@ -241,16 +241,19 @@ エラーが発生しました YouTubeフィード解析に失敗しました プレイヤーサービスとの接続がありません バグが発生してる可能性があります + $xのサーバーに接続中にエラーが発生しました サーバーコマンドの実行中にエラーが発生しました - $x のサーバーに接続中 - 初期状態の準備中 - 接続設定 - サーバーの接続設定 - ローカルサーバーを実行 - プロセスを停止 - このコマンドでプロセスが実行しています: $x - ローカルサーバーの実行コマンドが設定されていません + $x のサーバーに接続中 + 初期状態の準備中 + 接続設定 + サーバーの接続設定 + ローカルサーバーを実行 + プロセスを停止 + ローカルサーバー利用不可 + このコマンドでプロセスが実行しています: $x + ローカルサーバーの実行コマンドが設定されていません + このセッションはサーバー無しで実行 スロー 報告する @@ -336,7 +339,7 @@ 範囲外です ($range) 整数ではありません 数ではありません - 正確なIPv4アドレスではありません + 正確なIPv4アドレスまたはドメイン名ではありません 正確なポートではありません 設定 @@ -598,13 +601,18 @@ サーバー SpMpのサーバー部分(SpMs)が設定されたIPアドレスとポートで実行されていなければなりません。https://github.com/toasterofbread/spmp-server + 外部サーバーモード + このアプリ専用のサーバーを内部で実行せず、以下に設定さている外部サーバーに接続する + + 外部サーバー使用時、UIのみ実行 + 有効の場合、外部サーバーに接続中ならこのアプリはオーディオを出力しません + サーバーのIPアドレス サーバーのポート サーバー実行のコマンド SpMp内からサーバーを手動で実行するのに使われるコマンド 「$@」の最初の出現はサーバーへのアーギュメントに置き換えられ、「$@」がなければコマンドに直接追加されます 自動的にサーバーを実行する アプリ開始時にサーバーが見つからなければ、設定されたコマンドで自動的にサーバーを実行します - 終了時に子プロセスのサーバーを停止 開発 @@ -680,6 +688,9 @@ 再生 音質や再生の調節 + アンドロイド + SpMpのアンドロイド版のみの設定 + デスクトップ SpMpのデスクトップ版のみの設定 @@ -741,6 +752,10 @@ SpMs API バージョンの不一致 接続されているサーバーの API バージョン ($theirs) は、SpMp のこのビルドが設計された API バージョン ($ours) と一致していません。 予期しない動作やクラッシュが発生する可能性があります。 + これらのファイルが見つからないので、ローカルサーバは利用不可です: + ローカルサーバーを実行できません + + YouTube手動ログイン ブラウザーで新しいタブを開く diff --git a/shared/src/commonMain/resources/assets/values-ru-RU/strings.xml b/shared/src/commonMain/resources/assets/values-ru-RU/strings.xml index 4e535e362..ed663e3b6 100644 --- a/shared/src/commonMain/resources/assets/values-ru-RU/strings.xml +++ b/shared/src/commonMain/resources/assets/values-ru-RU/strings.xml @@ -242,14 +242,14 @@ Служба плеера не подключается. Возможно, произошла ошибка. Произошла ошибка при вызове команды сервера - Подключение к серверу ($x) - Установка исходного состояния - Настроить подключение - Настроить подключение к серверу - Запустить локальный сервер - Остановить - Процесс, запущенный с помощью команды: $x - Команда для локального сервера не задана + Подключение к серверу ($x) + Установка исходного состояния + Настроить подключение + Настроить подключение к серверу + Запустить локальный сервер + Остановить + Процесс, запущенный с помощью команды: $x + Команда для локального сервера не задана Отправить Сообщить @@ -335,7 +335,6 @@ Вне диапазона ($range) Не цифра Не дробь - Невалидный IPv4 адрес Невалидный порт Настройки @@ -589,7 +588,6 @@ Команда, выполняемая при ручном запуске сервера из SpMp Первое появление '$@' будет заменено аргументами сервера, в противном случае они будут добавлены к команде Запускать локальный сервер автоматически При запуске, если сервер не найден сразу, сервер будет запущен автоматически с помощью указанной команды - Остановить дочерний сервер при выходе Разработка diff --git a/shared/src/commonMain/resources/assets/values-tr-TR/strings.xml b/shared/src/commonMain/resources/assets/values-tr-TR/strings.xml index 025e5b508..178e25d49 100644 --- a/shared/src/commonMain/resources/assets/values-tr-TR/strings.xml +++ b/shared/src/commonMain/resources/assets/values-tr-TR/strings.xml @@ -196,14 +196,14 @@ YouTube özet akışı ayrıştırılamadı Oynatıcı hizmeti bağlanmıyor. Bir hata oluşmuş olabilir Sunucu komutu çağrılırken bir hata oluştu - $x konumundaki sunucuya bağlanılıyor - Başlangıç durumunu ayarlama - Bağlantıyı yapılandır - Sunucu bağlantısını yapılandır - Yerel sunucuyu başlat - İşlemi durdur - Bu komutla çalışan işlem: $x - Yerel sunucu komutu ayarlanmadı + $x konumundaki sunucuya bağlanılıyor + Başlangıç durumunu ayarlama + Bağlantıyı yapılandır + Sunucu bağlantısını yapılandır + Yerel sunucuyu başlat + İşlemi durdur + Bu komutla çalışan işlem: $x + Yerel sunucu komutu ayarlanmadı Gönder Rapor Metni kaydır @@ -278,7 +278,6 @@ Aralık dışı ($range) Tam sayı değil Şamandıra değil - Geçerli bir IPv4 addrefss değil Geçerli bir bağlantı noktası değil Ayarlar Arayüz dili @@ -473,7 +472,6 @@ Sunucuyu SpMp içinden manuel olarak başlatırken çalıştırılacak komut '$@' öğesinin ilk oluşumu sunucu bağımsız değişkenleriyle değiştirilecektir, aksi takdirde komuta ekleneceklerdir Yerel sunucuyu otomatik olarak başlat Başlangıçta, bir sunucu hemen bulunamazsa, belirtilen komut kullanılarak bir sunucu otomatik olarak başlatılır - Çıkışta alt sunucuyu durdur Geliştirme UI hata ayıklama bilgisi Yerelleştirme diff --git a/shared/src/commonMain/resources/assets/values-zh-CN/strings.xml b/shared/src/commonMain/resources/assets/values-zh-CN/strings.xml index 537ae154a..ac93d33d8 100644 --- a/shared/src/commonMain/resources/assets/values-zh-CN/strings.xml +++ b/shared/src/commonMain/resources/assets/values-zh-CN/strings.xml @@ -227,14 +227,14 @@ 播放器服务未连接 可能发生了一个错误 执行服务器命令时发生错误 - 正在连接到服务器 $x - 设置初始状态 - 配置连接 - 配置服务器连接 - 启动本地服务器 - 停止进程 - 进程正在运行此命令: $x - 未设置本地服务器命令 + 正在连接到服务器 $x + 设置初始状态 + 配置连接 + 配置服务器连接 + 启动本地服务器 + 停止进程 + 进程正在运行此命令: $x + 未设置本地服务器命令 抛出 报告 @@ -320,7 +320,6 @@ 超出范围 ($range) 不是整数 不是浮点数 - 不是有效的 IPv4 地址 不是有效的端口 设置 @@ -562,7 +561,6 @@ 从 SpMp 内部手动启动服务器时运行的命令 第一次出现'$@'将被替换为服务器参数,否则它们将被附加到命令 自动启动本地服务器 在启动时,如果立即找不到服务器,将使用指定的命令自动启动服务器 - 退出时停止子服务器 开发 diff --git a/shared/src/commonMain/resources/assets/values-zh-TW/strings.xml b/shared/src/commonMain/resources/assets/values-zh-TW/strings.xml index 29f4b3777..892aa13d3 100644 --- a/shared/src/commonMain/resources/assets/values-zh-TW/strings.xml +++ b/shared/src/commonMain/resources/assets/values-zh-TW/strings.xml @@ -11,10 +11,10 @@ https://spmp.toastbits.dev/docs/latest/server/about/ https://spmp.toastbits.dev/docs/latest/about https://spmp.toastbits.dev/docs/latest/server/about - https://spmp.toastbits.dev/docs/latest/client/installation-flatpak + https://spmp.toastbits.dev/docs/latest/client/installation-flatpak --> 專案 URL - + 使用發行版 $x 使用未行的提交 $x @@ -38,7 +38,6 @@ https://spmp.toastbits.dev/docs/latest/player/about https://spmp.toastbits.dev/docs/latest/server/cli https://spmp.toastbits.dev/docs/latest/server/about/ - 預設 ($x) 系統字體 HC Maru Gothic @@ -266,14 +265,14 @@ 播放器服務未連接 可能發生了錯誤 執行服務器指令時發生錯誤 - 正在連接到伺服器 $x - 設定初始狀態 - 配置連接 - 配置伺服器連接 - 啟動本地伺服器 - 停止行程 - 行程正在運行此指令: $x - 未設定本地伺服器指令 + 正在連接到伺服器 $x + 設定初始狀態 + 配置連接 + 配置伺服器連接 + 啟動本地伺服器 + 停止行程 + 行程正在運行此指令: $x + 未設定本地伺服器指令 拋出 報告 @@ -359,7 +358,6 @@ 超出範圍 ($range) 不是整數 不是浮點數 - 不是有效的 IPv4 地址 不是有效的端口 設定 @@ -507,7 +505,7 @@ 預設圖像圓角處理 預設波浪速度 預設波浪不透明度 - + 在擴展播放機中顯示波浪 電腦專門選項 @@ -624,7 +622,6 @@ 從 SpMp 內部手動啟動伺服器時運行的指令 第一次出現'$@'將被替換為伺服器參數,否則它們將被附加到指令 自動啟動本地伺服器 在啟動時,如果立即找不到伺服器,將使用指定的指令自動啟動伺服器 - 退出時停止子伺服器 開發 @@ -669,7 +666,7 @@ 行為 各種顯示和動作調整 - + 布局 自訂布局安排內容 diff --git a/shared/src/commonMain/resources/assets/values/strings.xml b/shared/src/commonMain/resources/assets/values/strings.xml index b6c2bb2f1..9e4e96b3c 100644 --- a/shared/src/commonMain/resources/assets/values/strings.xml +++ b/shared/src/commonMain/resources/assets/values/strings.xml @@ -267,16 +267,19 @@ A error occurred YouTube feed parsing failed Player service isn't connecting A bug may have occurred + An error occurred while connecting to server at $x An error occurred while calling server command - Connecting to server at $x - Setting initial state - Configure connection - Configure server connection - Start local server - Stop process - Process running with this command: $x - Local server command not set + Connecting to server at $x + Setting initial state + Configure connection + Configure server connection + Start local server + Stop process + Local server unavailable + Process running with this command: $x + Local server command not set + Begin session without server Throw Report @@ -362,7 +365,7 @@ Out of range ($range) Not an integer Not a float - Not a valid IPv4 address + Not a valid IPv4 address or domain Not a valid port Settings @@ -621,13 +624,18 @@ Server The SpMp server component (SpMs) must be running at the specified port IP address and port. https://github.com/toasterofbread/spmp-server - Server IP address + Enable external server mode + Instead of running its own internal server, SpMp will connect to the server defined below + + UI-only when in external server mode + If enabled and connecting to an external server, this app will not play audio + + External server IP address Server port Server command Command to be run when manually starting the server from within SpMp The first occurence of '$@' will be replaced with the server arguments, otherwise they will be appended to the command Start local server automatically On startup, if a server is not found immediately, a server will be started automatically using the specified command - Stop child server on exit Development @@ -703,6 +711,9 @@ Shortcuts Bind keys or mouse buttons to in-app actions + Android + Platform-specific configuration + Desktop Platform-specific configuration @@ -765,6 +776,10 @@ SpMs API version mismatch The connected server's API version ($theirs) doesn't match the API version that this build of SpMp was designed for ($ours). This may cause unexpected behaviour or crashes. + Local server unavailable because the following files are missing: + Cannot start local server + , + YouTube manual log-in Open a new browser tab diff --git a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/AppContext.desktop.kt b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/AppContext.desktop.kt index c47468aed..989852c9a 100644 --- a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/AppContext.desktop.kt +++ b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/AppContext.desktop.kt @@ -7,7 +7,7 @@ import dev.toastbits.composekit.settings.ui.Theme import com.toasterofbread.spmp.db.Database import com.toasterofbread.spmp.model.settings.Settings import com.toasterofbread.spmp.platform.download.PlayerDownloadManager -import com.toasterofbread.spmp.platform.playerservice.PlatformPlayerService +import com.toasterofbread.spmp.platform.playerservice.PlayerService import com.toasterofbread.spmp.youtubeapi.YtmApiType import dev.toastbits.ytmkt.model.YtmApi import kotlinx.coroutines.CoroutineScope @@ -16,7 +16,7 @@ import spmp.shared.generated.resources.Res actual class AppContext( app_name: String, coroutine_scope: CoroutineScope -): PlatformContext(app_name, PlatformPlayerService::class.java, coroutine_scope) { +): PlatformContext(app_name, PlayerService::class.java, coroutine_scope) { override suspend fun getIconImageData(): ByteArray? = Res.readBytes("drawable/ic_spmp.png") diff --git a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/PlayerListener.desktop.kt b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/PlayerListener.desktop.kt index ec5beb52a..319f6f124 100644 --- a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/PlayerListener.desktop.kt +++ b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/PlayerListener.desktop.kt @@ -1,8 +1,8 @@ package com.toasterofbread.spmp.platform import com.toasterofbread.spmp.model.mediaitem.song.Song -import spms.socketapi.shared.SpMsPlayerRepeatMode -import spms.socketapi.shared.SpMsPlayerState +import dev.toastbits.spms.socketapi.shared.SpMsPlayerRepeatMode +import dev.toastbits.spms.socketapi.shared.SpMsPlayerState actual abstract class PlayerListener actual constructor() { actual open fun onSongTransition(song: Song?, manual: Boolean) {} diff --git a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/DesktopMediaSession.kt b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/DesktopMediaSession.kt index 547f50ff3..d3f6cf030 100644 --- a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/DesktopMediaSession.kt +++ b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/DesktopMediaSession.kt @@ -4,13 +4,14 @@ import com.toasterofbread.spmp.model.mediaitem.song.Song import com.toasterofbread.spmp.model.mediaitem.playlist.Playlist import com.toasterofbread.spmp.model.mediaitem.db.getPlayCount import com.toasterofbread.spmp.platform.PlayerListener +import com.toasterofbread.spmp.platform.playerservice.PlayerService import com.toasterofbread.spmp.db.Database import dev.toastbits.mediasession.MediaSession import dev.toastbits.mediasession.MediaSessionMetadata import dev.toastbits.mediasession.MediaSessionPlaybackStatus import dev.toastbits.ytmkt.model.external.ThumbnailProvider -internal fun createDesktopMediaSession(service: PlatformPlayerService): MediaSession? { +internal fun createDesktopMediaSession(service: PlayerService): MediaSession? { val session: MediaSession? = try { MediaSession.create( @@ -90,7 +91,7 @@ internal fun createDesktopMediaSession(service: PlatformPlayerService): MediaSes return session } -private fun MediaSession.onSongChanged(song: Song?, service: PlatformPlayerService) { +private fun MediaSession.onSongChanged(song: Song?, service: PlayerService) { val db: Database = service.context.database val album: Playlist? = song?.Album?.get(db) val album_items: List? = album?.Items?.get(db) diff --git a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/LocalServer.desktop.kt b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/LocalServer.desktop.kt new file mode 100644 index 000000000..8671a8018 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/LocalServer.desktop.kt @@ -0,0 +1,53 @@ +package com.toasterofbread.spmp.platform.playerservice + +import com.toasterofbread.spmp.platform.AppContext +import com.toasterofbread.spmp.resources.getString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import dev.toastbits.spms.server.SpMs + +private const val POLL_INTERVAL: Long = 100 +private const val CLIENT_REPLY_ATTEMPTS: Int = 10 + +actual object LocalServer { + private fun createServer(): SpMs = SpMs(headless = false, enable_gui = false) + + actual fun getLocalServerUnavailabilityReason(): String? { + val server: SpMs = + try { + createServer() + } + catch (e: NoClassDefFoundError) { + val split_message: List = e.cause?.message?.split(" ") ?: emptyList() + val missing_files: List = split_message.filter { it.endsWith(".so") || it.endsWith(".dll") } + + return getString("warning_server_unavailable") + missing_files.joinToString(getString("server_missing_files_splitter")) + } + + server.release() + return null + } + + actual fun startLocalServer( + context: AppContext, + port: Int + ): Job { + val server: SpMs = SpMs(headless = false, enable_gui = false) + + server.bind(port) + + return context.coroutine_scope.launch(Dispatchers.IO) { + try { + while (true) { + server.poll(CLIENT_REPLY_ATTEMPTS) + delay(POLL_INTERVAL) + } + } + finally { + server.release() + } + } + } +} diff --git a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformExternalPlayerService.desktop.kt b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformExternalPlayerService.desktop.kt new file mode 100644 index 000000000..f6e04e1d4 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformExternalPlayerService.desktop.kt @@ -0,0 +1,32 @@ +package com.toasterofbread.spmp.platform.playerservice + +import ProgramArguments +import com.toasterofbread.spmp.platform.AppContext + +actual class PlatformExternalPlayerService: ExternalPlayerService(plays_audio = false), PlayerService { + actual companion object: PlayerServiceCompanion { + override fun isServiceRunning(context: AppContext): Boolean = true + + override fun disconnect(context: AppContext, connection: Any) { + (connection as ExternalPlayerService).onDestroy() + } + + override fun connect( + context: AppContext, + launch_arguments: ProgramArguments, + instance: PlayerService?, + onConnected: (PlayerService) -> Unit, + onDisconnected: () -> Unit, + ): Any { + require(instance is ExternalPlayerService?) + val service: ExternalPlayerService = + if (instance != null) instance.also { it.setContext(context) } + else PlatformExternalPlayerService().also { + it.setContext(context) + it.onCreate() + } + onConnected(service) + return service + } + } +} diff --git a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformInternalPlayerService.desktop.kt b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformInternalPlayerService.desktop.kt new file mode 100644 index 000000000..c561a2adb --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformInternalPlayerService.desktop.kt @@ -0,0 +1,55 @@ +package com.toasterofbread.spmp.platform.playerservice + +import ProgramArguments +import com.toasterofbread.spmp.platform.AppContext +import com.toasterofbread.spmp.platform.PlatformBinder +import dev.toastbits.composekit.platform.PlatformFile +import dev.toastbits.spms.server.SpMs + +private class PlayerServiceBinder(val service: PlatformInternalPlayerService): PlatformBinder() + +actual class PlatformInternalPlayerService: ExternalPlayerService(plays_audio = false) { + private fun launchLocalServer() { + LocalServer.startLocalServer( + context, + context.settings.platform.SERVER_PORT.get() + ) + } + + actual companion object: PlayerServiceCompanion { + override fun isAvailable(context: AppContext, launch_arguments: ProgramArguments): Boolean = + SpMs.isAvailable(headless = false) + + override fun getUnavailabilityReason(context: AppContext, launch_arguments: ProgramArguments): String? = LocalServer.getLocalServerUnavailabilityReason() + + override fun isServiceRunning(context: AppContext): Boolean = true + + override fun connect( + context: AppContext, + launch_arguments: ProgramArguments, + instance: PlayerService?, + onConnected: (PlayerService) -> Unit, + onDisconnected: () -> Unit, + ): Any { + require(instance is PlatformInternalPlayerService?) + val service: PlatformInternalPlayerService = + if (instance != null) + instance.also { + it.setContext(context) + it.launchLocalServer() + } + else + PlatformInternalPlayerService().also { + it.setContext(context) + it.launchLocalServer() + it.onCreate() + } + onConnected(service) + return service + } + + override fun disconnect(context: AppContext, connection: Any) { + (connection as ExternalPlayerService).onDestroy() + } + } +} diff --git a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformPlayerService.desktop.kt b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformPlayerService.desktop.kt deleted file mode 100644 index edcfc23b8..000000000 --- a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/PlatformPlayerService.desktop.kt +++ /dev/null @@ -1,210 +0,0 @@ -package com.toasterofbread.spmp.platform.playerservice - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import com.toasterofbread.spmp.model.mediaitem.song.Song -import com.toasterofbread.spmp.model.radio.RadioInstance -import com.toasterofbread.spmp.model.radio.RadioState -import com.toasterofbread.spmp.platform.AppContext -import com.toasterofbread.spmp.platform.PlatformBinder -import com.toasterofbread.spmp.platform.PlayerListener -import com.toasterofbread.spmp.platform.startPlatformService -import com.toasterofbread.spmp.platform.unbindPlatformService -import spms.socketapi.shared.SpMsPlayerRepeatMode -import spms.socketapi.shared.SpMsPlayerState -import kotlinx.serialization.json.JsonPrimitive -import dev.toastbits.mediasession.MediaSession - -private class PlayerServiceBinder(val service: PlatformPlayerService): PlatformBinder() - -actual class PlatformPlayerService: SpMsPlayerService(), PlayerService { - actual val load_state: PlayerServiceLoadState get() = socket_load_state - actual val connection_error: Throwable? get() = socket_connection_error - actual override val context: AppContext get() = super.context - - private var media_session: MediaSession? = null - - override val listeners: List - get() = Companion.listeners - private lateinit var _service_player: PlayerServicePlayer - actual override val service_player: PlayerServicePlayer - get() = _service_player - - actual override val state: SpMsPlayerState - get() = _state - actual override val is_playing: Boolean - get() = _is_playing - actual override val song_count: Int - get() = playlist.size - actual override val current_song_index: Int - get() = _current_song_index - actual override val current_position_ms: Long - get() { - if (current_song_time < 0) { - return 0 - } - if (!_is_playing) { - return current_song_time - } - return System.currentTimeMillis() - current_song_time - } - actual override val duration_ms: Long - get() = _duration_ms - actual override val has_focus: Boolean - get() = true // TODO - actual override val radio_instance: RadioInstance - get() = service_player.radio_instance - actual override var repeat_mode: SpMsPlayerRepeatMode - get() = _repeat_mode - set(value) { - if (value == _repeat_mode) { - return - } - sendRequest("setRepeatMode", JsonPrimitive(value.ordinal)) - } - actual override var volume: Float - get() = _volume - set(value) { - if (value == _volume) { - return - } - sendRequest("setVolume", JsonPrimitive(value)) - } - - actual override fun isPlayingOverLatentDevice(): Boolean = false // TODO - - actual override fun play() { - sendRequest("play") - } - - actual override fun pause() { - sendRequest("pause") - } - - actual override fun playPause() { - sendRequest("playPause") - } - - private val song_seek_undo_stack: MutableList> = mutableListOf() - private fun getSeekPosition(): Pair = Pair(current_song_index, current_position_ms) - - actual override fun seekTo(position_ms: Long) { - val current: Pair = getSeekPosition() - sendRequest("seekToTime", JsonPrimitive(position_ms)) - song_seek_undo_stack.add(current) - } - - actual override fun seekToSong(index: Int) { - val current: Pair = getSeekPosition() - sendRequest("seekToItem", JsonPrimitive(index)) - song_seek_undo_stack.add(current) - } - - actual override fun seekToNext() { - val current: Pair = getSeekPosition() - sendRequest("seekToNext") - song_seek_undo_stack.add(current) - } - - actual override fun seekToPrevious() { - val current: Pair = getSeekPosition() - sendRequest("seekToPrevious") - song_seek_undo_stack.add(current) - } - - actual override fun undoSeek() { - val (index: Int, position_ms: Long) = song_seek_undo_stack.removeLastOrNull() ?: return - - if (index != current_song_index) { - sendRequest("seekToItem", JsonPrimitive(index), JsonPrimitive(position_ms)) - } - else { - sendRequest("seekToTime", JsonPrimitive(position_ms)) - } - } - - actual override fun getSong(): Song? = playlist.getOrNull(_current_song_index) - - actual override fun getSong(index: Int): Song? = playlist.getOrNull(index) - - actual override fun addSong(song: Song, index: Int) { - sendRequest("addItem", JsonPrimitive(song.id), JsonPrimitive(index)) - } - - actual override fun moveSong(from: Int, to: Int) { - sendRequest("moveItem", JsonPrimitive(from), JsonPrimitive(to)) - } - - actual override fun removeSong(index: Int) { - sendRequest("removeItem", JsonPrimitive(index)) - } - - actual override fun addListener(listener: PlayerListener) { - Companion.addListener(listener) - } - - actual override fun removeListener(listener: PlayerListener) { - Companion.removeListener(listener) - } - - @Composable - actual override fun Visualiser(colour: Color, modifier: Modifier, opacity: Float) {} - - override fun onBind(): PlatformBinder? { - return PlayerServiceBinder(this) - } - - actual override fun onCreate() { - _service_player = object : PlayerServicePlayer(this) { - override fun onUndoStateChanged() { - for (listener in listeners) { - listener.onUndoStateChanged() - } - } - } - - super.onCreate() - - media_session = createDesktopMediaSession(this) - } - - actual override fun onDestroy() { - super.onDestroy() - media_session = null - } - - actual companion object { - private val listeners: MutableList = mutableListOf() - - actual fun isServiceRunning(context: AppContext): Boolean = true - - actual fun addListener(listener: PlayerListener) { - listeners.add(listener) - } - - actual fun removeListener(listener: PlayerListener) { - listeners.remove(listener) - } - - actual fun connect( - context: AppContext, - instance: PlatformPlayerService?, - onConnected: (PlatformPlayerService) -> Unit, - onDisconnected: () -> Unit, - ): Any { - return startPlatformService( - context, - PlatformPlayerService::class.java, - onConnected = { binder -> - onConnected((binder as PlayerServiceBinder).service) - }, - onDisconnected = onDisconnected - ) - } - - actual fun disconnect(context: AppContext, connection: Any) { - unbindPlatformService(context, connection) - } - } -} diff --git a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMs.desktop.kt b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMs.desktop.kt index 395e6aef6..3571247c5 100644 --- a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMs.desktop.kt +++ b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/SpMs.desktop.kt @@ -4,8 +4,9 @@ import org.jetbrains.skiko.OS import org.jetbrains.skiko.hostOs import java.io.File import java.lang.System.getenv +import com.toasterofbread.spmp.platform.AppContext -actual fun getSpMsMachineId(): String { +actual fun getSpMsMachineId(context: AppContext): String { val id_file: File = when (hostOs) { OS.Linux -> File("/tmp/") @@ -13,27 +14,5 @@ actual fun getSpMsMachineId(): String { else -> throw NotImplementedError(hostOs.name) }.resolve("spmp_machine_id.txt") - if (id_file.exists()) { - return id_file.readText() - } - - if (!id_file.parentFile.exists()) { - id_file.parentFile.mkdirs() - } - - val id_length: Int = 8 - val allowed_chars: List = ('A'..'Z') + ('a'..'z') + ('0'..'9') - - val new_id: String = (1..id_length).map { allowed_chars.random() }.joinToString("") - - id_file.writeText(new_id) - - return new_id + return getSpMsMachineIdFromFile(id_file) } - -actual fun getServerExecutableFilename(): String? = - when (hostOs) { - OS.Linux -> "spms.kexe" - OS.Windows -> "spms.exe" - else -> null - } diff --git a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/splash/ExtraLoadingContent.desktop.kt b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/splash/ExtraLoadingContent.desktop.kt deleted file mode 100644 index 8e8664def..000000000 --- a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/splash/ExtraLoadingContent.desktop.kt +++ /dev/null @@ -1,298 +0,0 @@ -package com.toasterofbread.spmp.platform.splash - -import LocalPlayerState -import ProgramArguments -import SpMp -import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import dev.toastbits.composekit.platform.PlatformContext -import dev.toastbits.composekit.settings.ui.item.SettingsItem -import dev.toastbits.composekit.utils.composable.ShapedIconButton -import dev.toastbits.composekit.platform.PlatformFile -import com.toasterofbread.spmp.model.settings.Settings -import com.toasterofbread.spmp.platform.playerservice.getServerExecutableFilename -import com.toasterofbread.spmp.platform.AppContext -import com.toasterofbread.spmp.resources.getString -import com.toasterofbread.spmp.ui.component.ErrorInfoDisplay -import com.toasterofbread.spmp.service.playercontroller.PlayerState -import com.toasterofbread.spmp.ui.layout.apppage.settingspage.category.getServerGroupItems -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.jetbrains.skiko.OS -import org.jetbrains.skiko.hostOs -import java.io.File -import java.io.BufferedReader - -private const val LOCAL_SERVER_AUTOSTART_DELAY_MS: Long = 100 - -private fun ProgramArguments.getServerExecutable(context: PlatformContext): PlatformFile? { - val server_executable_filename: String? = getServerExecutableFilename() - val server_executable: PlatformFile? = - if (server_executable_filename != null && bin_dir != null) - PlatformFile.fromFile( - File(bin_dir).resolve(server_executable_filename), - context - ).takeIf { it.is_file } - else null - return server_executable -} - -@Composable -actual fun SplashExtraLoadingContent(modifier: Modifier, arguments: ProgramArguments) { - val player: PlayerState = LocalPlayerState.current - val button_colours: ButtonColors = ButtonDefaults.buttonColors( - containerColor = player.theme.accent, - contentColor = player.theme.on_accent - ) - - var show: Boolean by remember { mutableStateOf(false) } - var show_config_dialog: Boolean by remember { mutableStateOf(false) } - var local_server_error: Throwable? by remember { mutableStateOf(null) } - var local_server_process: Pair? by remember { mutableStateOf(null) } - - fun startServer(stop_if_running: Boolean, automatic: Boolean) { - if (automatic && arguments.no_auto_server) { - return - } - - local_server_process?.also { process -> - if (stop_if_running) { - local_server_process = null - process.second.destroy() - } - return - } - - try { - local_server_process = startLocalServer( - player.context, - player.settings.desktop.SERVER_PORT.get(), - arguments.getServerExecutable(player.context) - ) { result, output -> - if (local_server_process != null) { - local_server_process = null - local_server_error = RuntimeException("Local server failed ($result)\n$output") - } - } - - if (!automatic && local_server_process == null) { - local_server_error = RuntimeException(getString("desktop_splash_local_server_command_not_set")) - } - } - catch (e: Throwable) { - local_server_process = null - local_server_error = e - } - } - - LaunchedEffect(Unit) { - if (player.settings.desktop.SERVER_LOCAL_START_AUTOMATICALLY.get()) { - delay(LOCAL_SERVER_AUTOSTART_DELAY_MS) - startServer(stop_if_running = false, automatic = true) - delay(500) - } - show = true - } - - if (!show) { - return - } - - Column(modifier.animateContentSize().fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Button( - { startServer(stop_if_running = true, automatic = false) }, - colors = button_colours - ) { - Crossfade(local_server_process) { process -> - if (process == null) { - Text(getString("desktop_splash_button_start_server")) - } - else { - Text(getString("desktop_splash_button_stop_process")) - } - } - } - - Button( - { show_config_dialog = true }, - colors = button_colours - ) { - Text(getString("desktop_splash_button_configure_connection")) - } - - if (player.context.canOpenUrl()) { - ShapedIconButton( - { - player.context.openUrl(getString("server_info_url")) - }, - colours = IconButtonDefaults.iconButtonColors( - containerColor = player.theme.accent, - contentColor = player.theme.on_accent - ) - ) { - Icon(Icons.Default.Info, null) - } - } - } - - Crossfade(local_server_error ?: local_server_process as Any?) { state -> - if (state != null) { - Column( - Modifier.padding(top = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - if (state is Throwable) { - Text(getString("error_on_server_command_execution")) - ErrorInfoDisplay( - state, - show_throw_button = true, - onDismiss = { local_server_error = null }, - modifier = Modifier.fillMaxWidth() - ) - } - else if (state is Pair<*, *>) { - Text(getString("desktop_splash_process_running_with_command_\$x").replace("\$x", state.first as String)) - } - } - } - } - } - - if (show_config_dialog) { - val settings_items: List = remember { getServerGroupItems(player.context) } - - AlertDialog( - onDismissRequest = { show_config_dialog = false }, - confirmButton = { - Button( - { show_config_dialog = false }, - colors = button_colours - ) { - Text(getString("action_close")) - } - }, - dismissButton = { - Button( - { show_config_dialog = false }, - colors = button_colours - ) { - Text(getString("action_close")) - } - }, - title = { - Text(getString("desktop_splash_title_configure_server_connection")) - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { - for (item in settings_items) { - item.Item(player.app_page_state.Settings.settings_interface, { _, _ -> }, {}, Modifier) - } - } - } - ) - } -} - -@OptIn(DelicateCoroutinesApi::class) -@Suppress("NewApi") -private fun startLocalServer( - context: AppContext, - port: Int, - server_executable: PlatformFile?, - onExit: (Int, String) -> Unit, -): Pair? { - var command: String = context.settings.desktop.SERVER_LOCAL_COMMAND.get().trim() - if (command.isEmpty()) { - val executable_path: String - - if (server_executable != null) { - executable_path = server_executable.absolute_path - } - else { - val packaged_server_filename: String = getServerExecutableFilename() ?: return null - val packaged_server: File = - File(System.getProperty("compose.application.resources.dir")) - .resolve(packaged_server_filename) - - if (packaged_server.isFile) { - executable_path = packaged_server.absolutePath - } - else { - return null - } - } - - command = - when (hostOs) { - OS.Windows -> "\"" + executable_path + "\"" - else -> executable_path.replace(" ", "\\ ") - } - } - - val args: String = "--port $port --no-media-session" - - val args_index: Int = command.indexOf("\$@") - if (args_index != -1) { - command = command.substring(0, args_index) + args + command.substring(args_index + 2) - } - else { - command += ' ' + args - } - - val builder: ProcessBuilder = ProcessBuilder(command.split(' ')) - // builder.inheritIO() - - val process: Process = builder.start() - - Runtime.getRuntime().addShutdownHook(Thread { - if (context.settings.desktop.SERVER_KILL_CHILD_ON_EXIT.get()) { - process.destroy() - } - }) - - GlobalScope.launch { - withContext(Dispatchers.IO) { - val reader: BufferedReader = process.getErrorStream().bufferedReader() - val output: StringBuilder = StringBuilder() - - while (true) { - val line: String = reader.readLine() ?: break - output.appendLine(line) - } - - val result: Int = process.waitFor() - onExit(result, output.toString()) - } - } - - return Pair(command, process) -} diff --git a/spmp-server b/spmp-server deleted file mode 160000 index a4bcc534f..000000000 --- a/spmp-server +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a4bcc534f32c8a22d4acd504b5742aa2d8cb2151