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