Quellcode durchsuchen

retarded sonet project damage

Milan Jurkulak vor 1 Woche
Ursprung
Commit
3044dc8c21
20 geänderte Dateien mit 372 neuen und 750 gelöschten Zeilen
  1. 2 3
      .aider.conf.yml
  2. 0 25
      androidApp/src/main/kotlin/com/mjdev/android/MainActivity.kt (příklad integrace)
  3. 0 25
      androidApp/src/main/kotlin/com/mjdev/android/MjdevApplication.kt
  4. 14 0
      androidApp/src/main/res/values/themes.xml
  5. 0 25
      desktopApp/src/main/kotlin/com/mjdev/desktop/MainActivity.kt (příklad integrace)
  6. 0 171
      shared/src/androidMain/kotlin/com/mjdev/audio/VolumeController.kt
  7. 3 0
      shared/src/androidMain/kotlin/org/mjdev/desktop/context/DesktopContext.kt
  8. 49 0
      shared/src/androidMain/kotlin/org/mjdev/desktop/managers/volume/VolumeManager.kt
  9. 56 0
      shared/src/androidMain/kotlin/org/mjdev/desktop/system/audio/VolumeController.kt
  10. 0 79
      shared/src/commonMain/kotlin/com/mjdev/audio/VolumeController.kt
  11. 0 12
      shared/src/commonMain/kotlin/com/mjdev/audio/VolumeControllerFactory.kt
  12. 0 150
      shared/src/commonMain/kotlin/com/mjdev/ui/audio/VolumeControl.kt
  13. 68 5
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/sound/SoundSettingsPage.kt
  14. 24 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/sound/SoundSettingsPageDefaults.kt
  15. 4 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/context/IDesktopContext.kt
  16. 35 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/managers/volume/IVolumeManager.kt
  17. 20 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/system/audio/VolumeController.kt
  18. 52 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/volume/VolumeManager.kt
  19. 45 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/system/audio/VolumeController.kt
  20. 0 255
      shared/src/linuxMain/kotlin/com/mjdev/audio/VolumeController.kt

+ 2 - 3
.aider.conf.yml

@@ -22,8 +22,7 @@ attribute-committer: false
 ## Code quality
 auto-lint: true
 lint-cmd:
-  - "kotlin: ./gradlew lintKotlin"
-  - "kotlin: ./gradlew detekt"
+  - "kotlin: ./gradlew ktlintCheck"
 ## Repository map - context for KMP structure
 map-tokens: 2048
 map-refresh: auto
@@ -33,4 +32,4 @@ pretty: true
 stream: true
 ## Cache for faster responses
 cache-prompts: true
-cache-keepalive-pings: 2
+cache-keepalive-pings: 2

+ 0 - 25
androidApp/src/main/kotlin/com/mjdev/android/MainActivity.kt (příklad integrace)

@@ -1,25 +0,0 @@
-package com.mjdev.android
-
-import androidx.compose.material3.Surface
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import com.mjdev.ui.audio.VolumeControl
-import com.mjdev.ui.audio.CompactVolumeControl
-
-@Composable
-fun AndroidScreen() {
-    Surface {
-        TopAppBar(
-            title = { Text("MJDev Android") },
-            actions = {
-                CompactVolumeControl()
-            }
-        )
-        
-        // Hlavní obsah
-        VolumeControl(
-            modifier = Modifier.padding(16.dp),
-            showPercentage = true
-        )
-    }
-}

+ 0 - 25
androidApp/src/main/kotlin/com/mjdev/android/MjdevApplication.kt

@@ -1,25 +0,0 @@
-package com.mjdev.android
-
-import android.app.Application
-import com.mjdev.audio.VolumeController
-
-/**
- * Hlavní Application třída pro Android modul.
- * Inicializuje VolumeController s application contextem.
- */
-class MjdevApplication : Application() {
-
-    companion object {
-        @Suppress("UNUSED_PROPERTY")
-        var instance: MjdevApplication? = null
-            private set
-    }
-
-    override fun onCreate() {
-        super.onCreate()
-        instance = this
-        
-        // Inicializace VolumeController
-        VolumeController.init(this)
-    }
-}

+ 14 - 0
androidApp/src/main/res/values/themes.xml

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!--
+        Základní téma aplikace použité v AndroidManifest.xml (android:theme="@style/Theme.Mjdev").
+        Parent je platformní Theme.Material.Light.NoActionBar - nezávisí na Material3/AppCompat,
+        takže nemůže spadnout na chybějící resource kvůli chybějící knihovně.
+        UI samotné je celé v Compose, takže toto téma slouží jen jako téma okna/aktivity
+        (barva pozadí, žádná action bar lišta) před tím, než Compose obsah převezme vykreslování.
+    -->
+    <style name="Theme.Mjdev" parent="android:Theme.Material.Light.NoActionBar">
+        <item name="android:windowBackground">@android:color/white</item>
+        <item name="android:statusBarColor">@android:color/black</item>
+    </style>
+</resources>

+ 0 - 25
desktopApp/src/main/kotlin/com/mjdev/desktop/MainActivity.kt (příklad integrace)

@@ -1,25 +0,0 @@
-package com.mjdev.desktop
-
-import androidx.compose.material3.Surface
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import com.mjdev.ui.audio.VolumeControl
-import com.mjdev.ui.audio.CompactVolumeControl
-
-@Composable
-fun DesktopScreen() {
-    Surface {
-        TopAppBar(
-            title = { Text("MJDev Desktop") },
-            actions = {
-                CompactVolumeControl()
-            }
-        )
-        
-        // Hlavní obsah
-        VolumeControl(
-            modifier = Modifier.padding(16.dp),
-            showPercentage = true
-        )
-    }
-}

+ 0 - 171
shared/src/androidMain/kotlin/com/mjdev/audio/VolumeController.kt

@@ -1,171 +0,0 @@
-package com.mjdev.audio
-
-import android.content.Context
-import android.media.AudioManager
-import android.os.Build
-import androidx.core.content.ContextCompat
-
-/**
- * Android implementace VolumeController pomocí AudioManager.
- * Ovládá STREAM_MUSIC (media volume) jako výchozí.
- */
-actual class VolumeController private constructor(
-    private val context: Context,
-    private val audioManager: AudioManager,
-    private val streamType: Int = AudioManager.STREAM_MUSIC
-) {
-
-    private var volumeListener: ((Float) -> Unit)? = null
-    private var muteListener: ((Boolean) -> Unit)? = null
-
-    init {
-        // Registrace listeneru pro změny hlasitosti
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
-            audioManager.registerAudioPolicyCallback(object : AudioManager.AudioPolicyCallback() {
-                override fun onAudioPolicyChanged(policy: AudioManager.AudioPolicy) {
-                    notifyVolumeChange()
-                    notifyMuteChange()
-                }
-            }, null)
-        } else {
-            // Starší API - polling nebo ContentObserver
-            startLegacyListener()
-        }
-    }
-
-    private fun startLegacyListener() {
-        // Pro starší API používáme ContentObserver na Settings.System.VOLUME_SETTINGS
-        // Zde zjednodušeně - v reálné aplikaci by se použil ContentObserver
-        Thread({
-            var lastVolume = volume
-            var lastMuted = isMuted
-            while (true) {
-                Thread.sleep(500)
-                if (volume != lastVolume) {
-                    lastVolume = volume
-                    notifyVolumeChange()
-                }
-                if (isMuted != lastMuted) {
-                    lastMuted = isMuted
-                    notifyMuteChange()
-                }
-            }
-        }).apply {
-            isDaemon = true
-            start()
-        }
-    }
-
-    private fun notifyVolumeChange() {
-        volumeListener?.invoke(volume)
-    }
-
-    private fun notifyMuteChange() {
-        muteListener?.invoke(isMuted)
-    }
-
-    actual protected override fun getVolume(): Float {
-        val maxVolume = audioManager.getStreamMaxVolume(streamType)
-        val currentVolume = audioManager.getStreamVolume(streamType)
-        return if (maxVolume > 0) currentVolume.toFloat() / maxVolume else 0f
-    }
-
-    actual protected override fun getIsMuted(): Boolean {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-            return audioManager.isStreamMute(streamType)
-        }
-        // Fallback pro starší API
-        return audioManager.getStreamVolume(streamType) == 0
-    }
-
-    actual protected override fun getOnVolumeChanged(): ((Float) -> Unit)? = volumeListener
-    actual protected override fun setOnVolumeChanged(listener: ((Float) -> Unit)?) {
-        volumeListener = listener
-    }
-    actual protected override fun getOnMuteChanged(): ((Boolean) -> Unit)? = muteListener
-    actual protected override fun setOnMuteChanged(listener: ((Boolean) -> Unit)?) {
-        muteListener = listener
-    }
-
-    actual override fun setVolume(volume: Float) {
-        val clamped = volume.coerceIn(0f, 1f)
-        val maxVolume = audioManager.getStreamMaxVolume(streamType)
-        val targetVolume = (clamped * maxVolume).roundToInt().coerceIn(0, maxVolume)
-        
-        audioManager.setStreamVolume(
-            streamType,
-            targetVolume,
-            AudioManager.FLAG_SHOW_UI or AudioManager.FLAG_PLAY_SOUND
-        )
-        volumeListener?.invoke(clamped)
-    }
-
-    actual override fun toggleMute() {
-        val newMute = !isMuted
-        setMuted(newMute)
-    }
-
-    actual override fun setMuted(muted: Boolean) {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-            audioManager.adjustStreamVolume(
-                streamType,
-                if (muted) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE,
-                AudioManager.FLAG_SHOW_UI
-            )
-        } else {
-            // Starší API - nastavíme hlasitost na 0 pro mute
-            if (muted) {
-                audioManager.setStreamVolume(streamType, 0, AudioManager.FLAG_SHOW_UI)
-            } else {
-                // Obnovíme předchozí hlasitost - v reálné aplikaci by se ukládala
-                audioManager.setStreamVolume(
-                    streamType,
-                    audioManager.getStreamMaxVolume(streamType) / 2,
-                    AudioManager.FLAG_SHOW_UI
-                )
-            }
-        }
-        muteListener?.invoke(muted)
-    }
-
-    actual companion object {
-        private var INSTANCE: VolumeController? = null
-
-        actual fun getInstance(): VolumeController {
-            // Pro Android potřebujeme Context - předpokládáme, že je dostupný přes Application
-            return INSTANCE ?: synchronized(this) {
-                INSTANCE ?: createInstance().also { INSTANCE = it }
-            }
-        }
-
-        private fun createInstance(): VolumeController {
-            // Context musí být poskytnut zvenčí - v reálné aplikaci přes Application singleton
-            val context = getApplicationContext()
-                ?: throw IllegalStateException("Application context not available. Call VolumeController.init(context) first.")
-            
-            val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
-            return VolumeController(context, audioManager)
-        }
-    }
-
-    /**
-     * Inicializace s Application contextem - musí být voláno při startu aplikace.
-     */
-    fun init(context: Context) {
-        val appContext = context.applicationContext
-        val audioManager = appContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
-        INSTANCE = VolumeController(appContext, audioManager)
-    }
-
-    private fun getApplicationContext(): Context? {
-        // V reálné aplikaci by to byl Application singleton
-        // Zde placeholder - musí být implementován v androidApp modulu
-        return try {
-            Class.forName("com.mjdev.android.MjdevApplication")
-                .getField("instance")
-                .get(null) as Context
-        } catch (e: Exception) {
-            null
-        }
-    }
-}

+ 3 - 0
shared/src/androidMain/kotlin/org/mjdev/desktop/context/DesktopContext.kt

@@ -44,6 +44,8 @@ import org.mjdev.desktop.managers.processes.ProcessManager
 import org.mjdev.desktop.managers.theme.IThemeManager
 import org.mjdev.desktop.managers.theme.ThemeManager
 import org.mjdev.desktop.managers.translations.ITranslator
+import org.mjdev.desktop.managers.volume.IVolumeManager
+import org.mjdev.desktop.managers.volume.VolumeManager
 import kotlin.reflect.KClass
 import kotlin.reflect.full.companionObject
 
@@ -144,6 +146,7 @@ class DesktopContext(
         IThemeManager::class -> ThemeManager(this)
         IProcessManager::class -> ProcessManager(this)
         IKeyManager::class -> KeysManager(this)
+        IVolumeManager::class -> VolumeManager(this)
         else ->
             cls.companionObject
                 ?.members

+ 49 - 0
shared/src/androidMain/kotlin/org/mjdev/desktop/managers/volume/VolumeManager.kt

@@ -0,0 +1,49 @@
+package org.mjdev.desktop.managers.volume
+
+import android.media.AudioManager
+import org.mjdev.desktop.context.DesktopContext
+import org.mjdev.desktop.context.IDesktopContext
+
+/**
+ * Android implementation of [IVolumeManager], backed by [AudioManager].
+ * Obtains the Android [android.content.Context] from [DesktopContext.androidContext],
+ * the same way other android-specific managers reach platform APIs.
+ */
+class VolumeManager(
+    private val context: IDesktopContext,
+) : IVolumeManager {
+    private val audioManager: AudioManager?
+        get() =
+            (context as? DesktopContext)
+                ?.androidContext
+                ?.getSystemService(AudioManager::class.java)
+
+    override val volume: Float
+        get() {
+            val manager = audioManager ?: return 0f
+            val current = manager.getStreamVolume(AudioManager.STREAM_MUSIC)
+            val max = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
+            return if (max == 0) 0f else current.toFloat() / max.toFloat()
+        }
+
+    override val isMuted: Boolean
+        get() = audioManager?.isStreamMute(AudioManager.STREAM_MUSIC) ?: false
+
+    override fun setVolume(volume: Float) {
+        val manager = audioManager ?: return
+        val max = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
+        val target = (volume.coerceIn(0f, 1f) * max).toInt()
+        manager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
+    }
+
+    override fun toggleMute(): Boolean {
+        val manager = audioManager ?: return false
+        val newState = !isMuted
+        manager.adjustStreamVolume(
+            AudioManager.STREAM_MUSIC,
+            if (newState) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE,
+            0,
+        )
+        return newState
+    }
+}

+ 56 - 0
shared/src/androidMain/kotlin/org/mjdev/desktop/system/audio/VolumeController.kt

@@ -0,0 +1,56 @@
+package org.mjdev.desktop.system.audio
+
+import android.content.Context
+import android.media.AudioManager
+
+/**
+ * Android implementation of [VolumeController], backed by [AudioManager].
+ * Must be initialized once with an application [Context] via [initialize]
+ * before any volume operation is performed, e.g. from Application.onCreate().
+ */
+actual object VolumeController {
+    // Application context set once at startup, used to obtain the system AudioManager.
+    private var appContext: Context? = null
+
+    // Lazily resolved AudioManager instance targeting the music stream.
+    private val audioManager: AudioManager?
+        get() = appContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
+
+    /**
+     * Initializes the controller with an application context.
+     * Must be called once before any volume operation on Android.
+     */
+    fun initialize(context: Context) {
+        appContext = context.applicationContext
+    }
+
+    actual fun getVolume(): Float {
+        val manager = audioManager ?: return 0f
+        val current = manager.getStreamVolume(AudioManager.STREAM_MUSIC)
+        val max = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
+        return if (max == 0) 0f else current.toFloat() / max.toFloat()
+    }
+
+    actual fun setVolume(volume: Float) {
+        val manager = audioManager ?: return
+        val max = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
+        val target = (volume.coerceIn(0f, 1f) * max).toInt()
+        manager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
+    }
+
+    actual fun isMuted(): Boolean {
+        val manager = audioManager ?: return false
+        return manager.isStreamMute(AudioManager.STREAM_MUSIC)
+    }
+
+    actual fun toggleMute(): Boolean {
+        val manager = audioManager ?: return false
+        val newState = !isMuted()
+        manager.adjustStreamVolume(
+            AudioManager.STREAM_MUSIC,
+            if (newState) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE,
+            0,
+        )
+        return newState
+    }
+}

+ 0 - 79
shared/src/commonMain/kotlin/com/mjdev/audio/VolumeController.kt

@@ -1,79 +0,0 @@
-package com.mjdev.audio
-
-/**
- * Rozhraní pro jednotné ovládání systémové hlasitosti napříč platformami.
- * Implementace pro Linux desktop (PulseAudio/PipeWire) a Android (AudioManager).
- */
-expect class VolumeController private constructor() {
-
-    /**
-     * Aktuální hlasitost v rozsahu 0.0 - 1.0
-     */
-    val volume: Float
-        get() = getVolume()
-
-    /**
-     * Zda je zvuk ztlumen
-     */
-    val isMuted: Boolean
-        get() = getIsMuted()
-
-    /**
-     * Callback při změně hlasitosti
-     */
-    var onVolumeChanged: ((Float) -> Unit)? = null
-        get() = getOnVolumeChanged()
-        set(value) = setOnVolumeChanged(value)
-
-    /**
-     * Callback při změně mute stavu
-     */
-    var onMuteChanged: ((Boolean) -> Unit)? = null
-        get() = getOnMuteChanged()
-        set(value) = setOnMuteChanged(value)
-
-    // Platform-specific implementations
-    protected expect fun getVolume(): Float
-    protected expect fun getIsMuted(): Boolean
-    protected expect fun getOnVolumeChanged(): ((Float) -> Unit)?
-    protected expect fun setOnVolumeChanged(listener: ((Float) -> Unit)?)
-    protected expect fun getOnMuteChanged(): ((Boolean) -> Unit)?
-    protected expect fun setOnMuteChanged(listener: ((Boolean) -> Unit)?)
-
-    /**
-     * Nastaví hlasitost (0.0 - 1.0)
-     */
-    expect fun setVolume(volume: Float)
-
-    /**
-     * Přepne mute stav
-     */
-    expect fun toggleMute()
-
-    /**
-     * Nastaví mute stav
-     */
-    expect fun setMuted(muted: Boolean)
-
-    /**
-     * Zvýší hlasitost o krok (default 5%)
-     */
-    fun volumeUp(step: Float = 0.05f) {
-        setVolume((volume + step).coerceIn(0f, 1f))
-    }
-
-    /**
-     * Sníží hlasitost o krok (default 5%)
-     */
-    fun volumeDown(step: Float = 0.05f) {
-        setVolume((volume - step).coerceIn(0f, 1f))
-    }
-
-    companion object {
-        /**
-         * Singleton instance pro danou platformu
-         */
-        @Suppress("UNUSED_PARAMETER")
-        expect fun getInstance(): VolumeController
-    }
-}

+ 0 - 12
shared/src/commonMain/kotlin/com/mjdev/audio/VolumeControllerFactory.kt

@@ -1,12 +0,0 @@
-package com.mjdev.audio
-
-/**
- * Factory pro získání instance VolumeController.
- * Používá expect/actual mechanismus pro platform-specific implementaci.
- */
-object VolumeControllerFactory {
-    /**
-     * Vrátí singleton instanci VolumeController pro aktuální platformu.
-     */
-    fun getController(): VolumeController = VolumeController.getInstance()
-}

+ 0 - 150
shared/src/commonMain/kotlin/com/mjdev/ui/audio/VolumeControl.kt

@@ -1,150 +0,0 @@
-package com.mjdev.ui.audio
-
-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.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.VolumeHigh
-import androidx.compose.material.icons.filled.VolumeMute
-import androidx.compose.material.icons.filled.VolumeOff
-import androidx.compose.material.icons.filled.VolumeUp
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.Slider
-import androidx.compose.material3.SliderDefaults
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-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.graphics.Color
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.mjdev.audio.VolumeController
-import com.mjdev.audio.VolumeControllerFactory
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.tasks.await
-
-/**
- * Compose komponenta pro ovládání systémové hlasitosti.
- * Funguje na Linux desktop i Android.
- */
-@Composable
-fun VolumeControl(
-    modifier: Modifier = Modifier,
-    showPercentage: Boolean = true,
-    step: Float = 0.05f
-) {
-    val controller = remember { VolumeControllerFactory.getController() }
-    
-    var volume by remember { mutableStateOf(controller.volume) }
-    var isMuted by remember { mutableStateOf(controller.isMuted) }
-    
-    // Aktualizace stavu z controlleru
-    androidx.compose.runtime.LaunchedEffect(controller) {
-        controller.onVolumeChanged = { newVolume ->
-            volume = newVolume
-        }
-        controller.onMuteChanged = { newMuted ->
-            isMuted = newMuted
-        }
-    }
-
-    val icon = when {
-        isMuted || volume == 0f -> Icons.Default.VolumeMute
-        volume < 0.33f -> Icons.Default.VolumeOff
-        volume < 0.66f -> Icons.Default.VolumeUp
-        else -> Icons.Default.VolumeHigh
-    }
-
-    Column(
-        modifier = modifier
-            .fillMaxSize()
-            .padding(16.dp),
-        horizontalAlignment = Alignment.CenterHorizontally
-    ) {
-        Row(
-            modifier = Modifier.fillMaxSize(),
-            verticalAlignment = Alignment.CenterVertically
-        ) {
-            IconButton(onClick = { controller.toggleMute() }) {
-                Icon(
-                    imageVector = icon,
-                    contentDescription = if (isMuted) "Unmute" else "Mute",
-                    tint = if (isMuted) Color.Gray else Color.Unspecified
-                )
-            }
-            
-            androidx.compose.foundation.layout.Spacer(modifier = androidx.compose.foundation.layout.padding(16.dp))
-            
-            Slider(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .padding(16.dp),
-                value = volume,
-                onValueChange = { newVolume ->
-                    volume = newVolume
-                    controller.setVolume(newVolume)
-                },
-                enabled = !isMuted,
-                colors = SliderDefaults.colors(
-                    thumbColor = if (isMuted) Color.Gray else Color.Unspecified,
-                    activeTrackColor = if (isMuted) Color.Gray else Color.Unspecified,
-                    inactiveTrackColor = if (isMuted) Color.Gray.copy(alpha = 0.5f) else Color.Unspecified
-                )
-            )
-            
-            if (showPercentage) {
-                Text(
-                    text = "${(volume * 100).roundToInt()}%",
-                    fontSize = 14.sp,
-                    color = if (isMuted) Color.Gray else Color.Unspecified
-                )
-            }
-        }
-    }
-}
-
-/**
- * Kompaktní verze pro toolbar/status bar
- */
-@Composable
-fun CompactVolumeControl(
-    modifier: Modifier = Modifier,
-    onClick: (() -> Unit)? = null
-) {
-    val controller = remember { VolumeControllerFactory.getController() }
-    
-    var volume by remember { mutableStateOf(controller.volume) }
-    var isMuted by remember { mutableStateOf(controller.isMuted) }
-    
-    androidx.compose.runtime.LaunchedEffect(controller) {
-        controller.onVolumeChanged = { volume = it }
-        controller.onMuteChanged = { isMuted = it }
-    }
-
-    val icon = when {
-        isMuted || volume == 0f -> Icons.Default.VolumeMute
-        volume < 0.33f -> Icons.Default.VolumeOff
-        volume < 0.66f -> Icons.Default.VolumeUp
-        else -> Icons.Default.VolumeHigh
-    }
-
-    IconButton(
-        onClick = onClick ?? { controller.toggleMute() },
-        modifier = modifier
-    ) {
-        Icon(
-            imageVector = icon,
-            contentDescription = if (isMuted) "Unmute" else "Mute",
-            tint = if (isMuted) Color.Gray else Color.Unspecified
-        )
-    }
-}

+ 68 - 5
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/sound/SoundSettingsPage.kt

@@ -1,8 +1,24 @@
 package org.mjdev.desktop.components.controlcenter.pages.sound
 
-import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Arrangement
+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.material.icons.Icons
+import androidx.compose.material.icons.filled.VolumeOff
+import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
+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.tooling.preview.Preview
 import org.mjdev.desktop.components.controlcenter.base.ControlCenterPage
@@ -10,16 +26,63 @@ import org.mjdev.desktop.context.IDesktopContext
 import org.mjdev.desktop.extensions.Compose.preview
 import org.mjdev.desktop.icons.settings.SettingsSound
 
+// Original placeholder implementation, kept for history:
+// fun SoundSettingsPage(context: IDesktopContext) = ControlCenterPage(
+//     context = context,
+//     icon = SettingsSound,
+//     name = "Sound",
+//     condition = { true }, // todo sound manager
+// ) {
+//     Box(
+//         modifier = Modifier.fillMaxSize(),
+//     )
+// }
+
+/**
+ * Control center page allowing the user to control the system output volume
+ * and toggle mute, backed by [IDesktopContext.volumeManager].
+ */
 @Suppress("FunctionName")
 fun SoundSettingsPage(context: IDesktopContext) = ControlCenterPage(
     context = context,
     icon = SettingsSound,
     name = "Sound",
-    condition = { true }, // todo sound manager
+    condition = { true },
 ) {
-    Box(
-        modifier = Modifier.fillMaxSize(),
-    )
+    var volume by remember { mutableStateOf(context.volumeManager.volume) }
+    var muted by remember { mutableStateOf(context.volumeManager.isMuted) }
+    Column(
+        modifier = Modifier
+            .fillMaxSize()
+            .padding(SoundSettingsPageDefaults.contentPadding),
+        verticalArrangement = Arrangement.spacedBy(SoundSettingsPageDefaults.itemSpacing),
+    ) {
+        Text(text = SoundSettingsPageDefaults.volumeLabel)
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            IconButton(
+                onClick = {
+                    muted = context.volumeManager.toggleMute()
+                },
+            ) {
+                Icon(
+                    imageVector = if (muted) Icons.Filled.VolumeOff else Icons.Filled.VolumeUp,
+                    contentDescription = SoundSettingsPageDefaults.muteToggleDescription,
+                )
+            }
+            Slider(
+                modifier = Modifier.fillMaxWidth(),
+                value = volume,
+                onValueChange = { newValue ->
+                    volume = newValue
+                    context.volumeManager.setVolume(newValue)
+                },
+                valueRange = SoundSettingsPageDefaults.volumeRange,
+            )
+        }
+    }
 }
 
 @Preview

+ 24 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/sound/SoundSettingsPageDefaults.kt

@@ -0,0 +1,24 @@
+package org.mjdev.desktop.components.controlcenter.pages.sound
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * Constants used by [SoundSettingsPage], avoiding magic numbers and hardcoded strings.
+ */
+object SoundSettingsPageDefaults {
+    /** Padding applied around the sound settings page content. */
+    val contentPadding: Dp = 16.dp
+
+    /** Vertical spacing between items in the sound settings page. */
+    val itemSpacing: Dp = 12.dp
+
+    /** Valid range for the volume slider. */
+    val volumeRange: ClosedFloatingPointRange<Float> = 0f..1f
+
+    /** Label displayed above the volume slider. */
+    const val volumeLabel: String = "Volume"
+
+    /** Accessibility description for the mute toggle button. */
+    const val muteToggleDescription: String = "Toggle mute"
+}

+ 4 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/context/IDesktopContext.kt

@@ -39,6 +39,7 @@ import org.mjdev.desktop.managers.process.IProcessManager
 import org.mjdev.desktop.managers.remote.IRemoteDesktopManager
 import org.mjdev.desktop.managers.theme.IThemeManager
 import org.mjdev.desktop.managers.translations.ITranslator
+import org.mjdev.desktop.managers.volume.IVolumeManager
 import kotlin.coroutines.CoroutineContext
 import kotlin.coroutines.EmptyCoroutineContext
 import kotlin.reflect.KClass
@@ -77,6 +78,9 @@ abstract class IDesktopContext : IDisposable {
     open val keysManager: IKeyManager by this
     open val remoteDesktop: IRemoteDesktopManager by this
 
+    // System output volume / mute control - see IVolumeManager for platform implementations.
+    open val volumeManager: IVolumeManager by this
+
     abstract var isFirstStart: Boolean
     abstract var isInstalled: Boolean
 

+ 35 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/managers/volume/IVolumeManager.kt

@@ -0,0 +1,35 @@
+package org.mjdev.desktop.managers.volume
+
+import org.mjdev.desktop.managers.base.IDelegate
+
+/**
+ * Manager abstraction for controlling the system output volume and mute state.
+ * Follows the same pattern as [org.mjdev.desktop.managers.os.IOSManager] - a plain
+ * interface implemented per-platform and wired via [org.mjdev.desktop.managers.base.ManagerCache].
+ */
+interface IVolumeManager : IDelegate {
+    /** Current volume level in range 0f..1f. */
+    val volume: Float
+
+    /** True if audio output is currently muted. */
+    val isMuted: Boolean
+
+    /** Sets volume level, clamped to 0f..1f. */
+    fun setVolume(volume: Float)
+
+    /** Toggles mute state and returns the new state. */
+    fun toggleMute(): Boolean
+
+    companion object {
+        /** No-op fallback used when no platform manager is available. */
+        val EMPTY =
+            object : IVolumeManager {
+                override val volume: Float = 0f
+                override val isMuted: Boolean = false
+
+                override fun setVolume(volume: Float) {}
+
+                override fun toggleMute(): Boolean = false
+            }
+    }
+}

+ 20 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/system/audio/VolumeController.kt

@@ -0,0 +1,20 @@
+package org.mjdev.desktop.system.audio
+
+/**
+ * Platform volume controller abstraction.
+ * Implemented per-platform (desktop via `pactl`, Android via `AudioManager`)
+ * without any external or remote library dependency.
+ */
+expect object VolumeController {
+    /** Returns current volume level in range 0f..1f. */
+    fun getVolume(): Float
+
+    /** Sets volume level, clamped to 0f..1f. */
+    fun setVolume(volume: Float)
+
+    /** Returns true if audio output is currently muted. */
+    fun isMuted(): Boolean
+
+    /** Toggles mute state and returns the new state. */
+    fun toggleMute(): Boolean
+}

+ 52 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/volume/VolumeManager.kt

@@ -0,0 +1,52 @@
+package org.mjdev.desktop.managers.volume
+
+import org.mjdev.desktop.context.IDesktopContext
+import java.io.BufferedReader
+
+/**
+ * Desktop (Linux/JVM) implementation of [IVolumeManager].
+ * Uses `pactl` (PulseAudio), a system tool already required by the desktop
+ * runtime dependencies - no external library is introduced.
+ */
+class VolumeManager(
+    private val context: IDesktopContext,
+) : IVolumeManager {
+    companion object {
+        // Default PulseAudio sink used when no specific sink is selected.
+        private const val DEFAULT_SINK = "@DEFAULT_SINK@"
+
+        // Regex used to parse the percentage value out of `pactl get-sink-volume` output.
+        private val VOLUME_REGEX = Regex("(\\d+)%")
+    }
+
+    override val volume: Float
+        get() = runCatching {
+            val process = ProcessBuilder("pactl", "get-sink-volume", DEFAULT_SINK).start()
+            val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
+            process.waitFor()
+            VOLUME_REGEX.find(output)?.groupValues?.get(1)?.toFloatOrNull()?.div(100f)
+        }.getOrNull() ?: 0f
+
+    override val isMuted: Boolean
+        get() = runCatching {
+            val process = ProcessBuilder("pactl", "get-sink-mute", DEFAULT_SINK).start()
+            val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
+            process.waitFor()
+            output.contains("yes", ignoreCase = true)
+        }.getOrNull() ?: false
+
+    override fun setVolume(volume: Float) {
+        val clamped = volume.coerceIn(0f, 1f)
+        val percent = (clamped * 100).toInt()
+        runCatching {
+            ProcessBuilder("pactl", "set-sink-volume", DEFAULT_SINK, "$percent%").start().waitFor()
+        }
+    }
+
+    override fun toggleMute(): Boolean {
+        runCatching {
+            ProcessBuilder("pactl", "set-sink-mute", DEFAULT_SINK, "toggle").start().waitFor()
+        }
+        return isMuted
+    }
+}

+ 45 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/system/audio/VolumeController.kt

@@ -0,0 +1,45 @@
+package org.mjdev.desktop.system.audio
+
+import java.io.BufferedReader
+
+/**
+ * Desktop (Linux/JVM) implementation of [VolumeController].
+ * Uses `pactl` (PulseAudio), a system tool already required by the desktop
+ * runtime dependencies - no external library is introduced.
+ */
+actual object VolumeController {
+    // Default PulseAudio sink used when no specific sink is selected.
+    private const val DEFAULT_SINK = "@DEFAULT_SINK@"
+
+    // Regex used to parse the percentage value out of `pactl get-sink-volume` output.
+    private val VOLUME_REGEX = Regex("(\\d+)%")
+
+    actual fun getVolume(): Float = runCatching {
+        val process = ProcessBuilder("pactl", "get-sink-volume", DEFAULT_SINK).start()
+        val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
+        process.waitFor()
+        VOLUME_REGEX.find(output)?.groupValues?.get(1)?.toFloatOrNull()?.div(100f)
+    }.getOrNull() ?: 0f
+
+    actual fun setVolume(volume: Float) {
+        val clamped = volume.coerceIn(0f, 1f)
+        val percent = (clamped * 100).toInt()
+        runCatching {
+            ProcessBuilder("pactl", "set-sink-volume", DEFAULT_SINK, "$percent%").start().waitFor()
+        }
+    }
+
+    actual fun isMuted(): Boolean = runCatching {
+        val process = ProcessBuilder("pactl", "get-sink-mute", DEFAULT_SINK).start()
+        val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
+        process.waitFor()
+        output.contains("yes", ignoreCase = true)
+    }.getOrNull() ?: false
+
+    actual fun toggleMute(): Boolean {
+        runCatching {
+            ProcessBuilder("pactl", "set-sink-mute", DEFAULT_SINK, "toggle").start().waitFor()
+        }
+        return isMuted()
+    }
+}

+ 0 - 255
shared/src/linuxMain/kotlin/com/mjdev/audio/VolumeController.kt

@@ -1,255 +0,0 @@
-package com.mjdev.audio
-
-import java.lang.ProcessBuilder
-
-/**
- * Linux implementace VolumeController pomocí pactl (PulseAudio) nebo wpctl (PipeWire).
- * Automaticky detekuje dostupný audio server.
- */
-actual class VolumeController private constructor() {
-
-    private var volumeListener: ((Float) -> Unit)? = null
-    private var muteListener: ((Boolean) -> Unit)? = null
-    private val isPipeWire: Boolean
-    private val sinkName: String
-
-    init {
-        // Detekce PipeWire vs PulseAudio
-        isPipeWire = checkPipeWire()
-        sinkName = getDefaultSink()
-        startListener()
-    }
-
-    private fun checkPipeWire(): Boolean {
-        return try {
-            ProcessBuilder("wpctl", "status").start().waitFor() == 0
-        } catch (e: Exception) {
-            false
-        }
-    }
-
-    private fun getDefaultSink(): String {
-        return if (isPipeWire) {
-            // PipeWire - získá výchozí sink
-            try {
-                ProcessBuilder("wpctl", "status", "-n")
-                    .redirectErrorStream(true)
-                    .start()
-                    .inputStream.bufferedReader()
-                    .readText()
-                    .lines()
-                    .firstOrNull { it.contains("*") && it.contains("Sink") }
-                    ?.split("\\s+".toRegex())
-                    ?.getOrNull(1)
-                    ?.let { "@DEFAULT_AUDIO_SINK@" } // PipeWire používá speciální název
-                    ?: "@DEFAULT_AUDIO_SINK@"
-            } catch (e: Exception) {
-                "@DEFAULT_AUDIO_SINK@"
-            }
-        } else {
-            // PulseAudio - získá výchozí sink
-            try {
-                ProcessBuilder("pactl", "get-default-sink")
-                    .redirectErrorStream(true)
-                    .start()
-                    .inputStream.bufferedReader()
-                    .readText()
-                    .trim()
-                    .takeIf { it.isNotEmpty() }
-                    ?: "@DEFAULT_SINK@"
-            } catch (e: Exception) {
-                "@DEFAULT_SINK@"
-            }
-        }
-    }
-
-    private fun startListener() {
-        // Spustí listener pro změny hlasitosti v pozadí
-        Thread({
-            if (isPipeWire) {
-                listenPipeWire()
-            } else {
-                listenPulseAudio()
-            }
-        }).apply {
-            isDaemon = true
-            start()
-        }
-    }
-
-    private fun listenPulseAudio() {
-        ProcessBuilder("pactl", "subscribe")
-            .redirectErrorStream(true)
-            .start()
-            .inputStream.bufferedReader()
-            .use { reader ->
-                reader.forEachLine { line ->
-                    if (line.contains("sink") && line.contains("change")) {
-                        notifyVolumeChange()
-                        notifyMuteChange()
-                    }
-                }
-            }
-    }
-
-    private fun listenPipeWire() {
-        // PipeWire events přes pw-cli nebo wpctl
-        ProcessBuilder("pw-cli", "subscribe", "params", "all")
-            .redirectErrorStream(true)
-            .start()
-            .inputStream.bufferedReader()
-            .use { reader ->
-                reader.forEachLine { line ->
-                    if (line.contains("volume") || line.contains("mute")) {
-                        notifyVolumeChange()
-                        notifyMuteChange()
-                    }
-                }
-            }
-    }
-
-    private fun notifyVolumeChange() {
-        val vol = getVolume()
-        volumeListener?.invoke(vol)
-    }
-
-    private fun notifyMuteChange() {
-        val muted = getIsMuted()
-        muteListener?.invoke(muted)
-    }
-
-    actual protected override fun getVolume(): Float {
-        return try {
-            if (isPipeWire) {
-                // wpctl get-volume @DEFAULT_AUDIO_SINK@
-                val output = ProcessBuilder("wpctl", "get-volume", sinkName)
-                    .redirectErrorStream(true)
-                    .start()
-                    .inputStream.bufferedReader()
-                    .readText()
-                    .trim()
-                // Formát: "Volume: 0.75" nebo "Volume: 0.75 [MUTED]"
-                val volumeStr = output
-                    .removePrefix("Volume: ")
-                    .split(" ")
-                    .first()
-                volumeStr.toFloat()
-            } else {
-                // pactl get-sink-volume @DEFAULT_SINK@
-                val output = ProcessBuilder("pactl", "get-sink-volume", sinkName)
-                    .redirectErrorStream(true)
-                    .start()
-                    .inputStream.bufferedReader()
-                    .readText()
-                    .trim()
-                // Formát: "Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB"
-                val percentStr = output
-                    .split("/")
-                    .getOrNull(1)
-                    ?.trim()
-                    ?.removeSuffix("%")
-                    ?: "100"
-                (percentStr.toFloat() / 100f).coerceIn(0f, 1f)
-            }
-        } catch (e: Exception) {
-            1.0f // default
-        }
-    }
-
-    actual protected override fun getIsMuted(): Boolean {
-        return try {
-            if (isPipeWire) {
-                val output = ProcessBuilder("wpctl", "get-volume", sinkName)
-                    .redirectErrorStream(true)
-                    .start()
-                    .inputStream.bufferedReader()
-                    .readText()
-                output.contains("[MUTED]")
-            } else {
-                val output = ProcessBuilder("pactl", "get-sink-mute", sinkName)
-                    .redirectErrorStream(true)
-                    .start()
-                    .inputStream.bufferedReader()
-                    .readText()
-                    .trim()
-                output.contains("yes")
-            }
-        } catch (e: Exception) {
-            false
-        }
-    }
-
-    actual protected override fun getOnVolumeChanged(): ((Float) -> Unit)? = volumeListener
-    actual protected override fun setOnVolumeChanged(listener: ((Float) -> Unit)?) {
-        volumeListener = listener
-    }
-    actual protected override fun getOnMuteChanged(): ((Boolean) -> Unit)? = muteListener
-    actual protected override fun setOnMuteChanged(listener: ((Boolean) -> Unit)?) {
-        muteListener = listener
-    }
-
-    actual override fun setVolume(volume: Float) {
-        val clamped = volume.coerceIn(0f, 1f)
-        try {
-            if (isPipeWire) {
-                // wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.75
-                ProcessBuilder("wpctl", "set-volume", sinkName, clamped.toString())
-                    .start()
-                    .waitFor()
-            } else {
-                // pactl set-sink-volume @DEFAULT_SINK@ 75%
-                val percent = (clamped * 100).toInt()
-                ProcessBuilder("pactl", "set-sink-volume", sinkName, "${percent}%")
-                    .start()
-                    .waitFor()
-            }
-            volumeListener?.invoke(clamped)
-        } catch (e: Exception) {
-            e.printStackTrace()
-        }
-    }
-
-    actual override fun toggleMute() {
-        try {
-            if (isPipeWire) {
-                ProcessBuilder("wpctl", "set-mute", sinkName, "toggle")
-                    .start()
-                    .waitFor()
-            } else {
-                ProcessBuilder("pactl", "set-sink-mute", sinkName, "toggle")
-                    .start()
-                    .waitFor()
-            }
-            muteListener?.invoke(getIsMuted())
-        } catch (e: Exception) {
-            e.printStackTrace()
-        }
-    }
-
-    actual override fun setMuted(muted: Boolean) {
-        try {
-            if (isPipeWire) {
-                ProcessBuilder("wpctl", "set-mute", sinkName, if (muted) "1" else "0")
-                    .start()
-                    .waitFor()
-            } else {
-                ProcessBuilder("pactl", "set-sink-mute", sinkName, if (muted) "1" else "0")
-                    .start()
-                    .waitFor()
-            }
-            muteListener?.invoke(muted)
-        } catch (e: Exception) {
-            e.printStackTrace()
-        }
-    }
-
-    actual companion object {
-        private var INSTANCE: VolumeController? = null
-
-        actual fun getInstance(): VolumeController {
-            return INSTANCE ?: synchronized(this) {
-                INSTANCE ?: VolumeController().also { INSTANCE = it }
-            }
-        }
-    }
-}