Pārlūkot izejas kodu

remote compose tabs

Milan Jurkulak 1 nedēļu atpakaļ
vecāks
revīzija
1875c12a18
24 mainītis faili ar 1949 papildinājumiem un 187 dzēšanām
  1. 35 0
      .aider.conf.yml
  2. 1 0
      .gitignore
  3. 56 0
      CLAUDE.md
  4. 65 0
      CONVENTIONS.md
  5. 11 76
      androidApp/src/main/AndroidManifest.xml
  6. 25 0
      androidApp/src/main/kotlin/com/mjdev/android/MainActivity.kt (příklad integrace)
  7. 25 0
      androidApp/src/main/kotlin/com/mjdev/android/MjdevApplication.kt
  8. 2 35
      build.gradle.kts
  9. 25 0
      desktopApp/src/main/kotlin/com/mjdev/desktop/MainActivity.kt (příklad integrace)
  10. 0 0
      screenshots/Deepin-control-center.jpg
  11. 0 0
      screenshots/Deepin_23.webp
  12. BIN
      screenshots/extix-deepin-19.8-desktop-small.jpg
  13. 0 0
      screenshots/image.webp
  14. BIN
      screenshots/linux-deepin-showmetech-14.png
  15. 21 0
      shared/build.gradle.kts (přidat do dependencies)
  16. 171 0
      shared/src/androidMain/kotlin/com/mjdev/audio/VolumeController.kt
  17. 79 0
      shared/src/commonMain/kotlin/com/mjdev/audio/VolumeController.kt
  18. 12 0
      shared/src/commonMain/kotlin/com/mjdev/audio/VolumeControllerFactory.kt
  19. 150 0
      shared/src/commonMain/kotlin/com/mjdev/ui/audio/VolumeControl.kt
  20. 241 6
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/remote/RemoteView.kt
  21. 152 16
      shared/src/commonMain/kotlin/org/mjdev/desktop/managers/remote/IRemoteDesktopManager.kt
  22. 293 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/tabs/remote/RemoteTabPlugin.kt
  23. 330 54
      shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/remote/RemoteDesktopManager.kt
  24. 255 0
      shared/src/linuxMain/kotlin/com/mjdev/audio/VolumeController.kt

+ 35 - 0
.aider.conf.yml

@@ -0,0 +1,35 @@
+# .aider.conf.yml - Project config for KMP/CMP development
+## Model settings - OpenRouter
+model: openrouter/nvidia/nemotron-3-ultra-550b-a55b:free
+weak-model: openrouter/nvidia/nemotron-3-ultra-550b-a55b:free
+## API keys (nebo použij env var OPENROUTER_API_KEY)
+# api-key:
+#   - openrouter=tvuj_api_key_zde
+## Always load coding conventions
+read:
+  - CONVENTIONS.md
+  - CLAUDE.md
+## Always load coding conventions (read-only)
+#read-only:
+#  - CONVENTIONS.md
+#  - CLAUDE.md
+## Git behavior
+auto-commits: false
+dirty-commits: false
+attribute-author: false
+attribute-committer: false
+## Code quality
+auto-lint: true
+lint-cmd:
+  - "kotlin: ./gradlew lintKotlin"
+  - "kotlin: ./gradlew detekt"
+## Repository map - context for KMP structure
+map-tokens: 2048
+map-refresh: auto
+## UI preferences
+dark-mode: true
+pretty: true
+stream: true
+## Cache for faster responses
+cache-prompts: true
+cache-keepalive-pings: 2

+ 1 - 0
.gitignore

@@ -70,3 +70,4 @@ composeApp/src/commonMain/resources/keys/*.key
 ### ideas & fixes
 /ideas/
 /fix-it/
+.aider*

+ 56 - 0
CLAUDE.md

@@ -143,3 +143,59 @@ Output `packages/` directory structure: `packages/main-release/{deb,rpm,appimage
 Format: `<type>(<scope>): <description>` — types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf`. Description under 50 characters, imperative mood, lowercase.
 
 Branch naming: `<type>/<issue-number>-<short-description>` — types: `feature`, `bugfix`, `hotfix`, `release`, `support`.
+
+- Do not modify files before explaining the root cause.
+- Do not perform unrelated refactoring.
+- Preserve existing public APIs unless the task requires a change.
+- Prefer small immutable classes and enums over hardcoded string values.
+- Prefer expression bodies where they improve clarity.
+- Avoid the not-null assertion operator.
+- Keep functions small.
+- If a function has more than one parameter, place parameters on separate lines.
+- Run Gradle checks after every code change.
+- Never edit generated files manually.
+- Never change secrets, signing configuration, or production infrastructure without approval.
+- Always show git diff before considering the task complete.
+- To fix issues use crash@psippr.org, for every issue made identification number and branch
+- same as from crash@psippr.org use files: ai-non-finished.txt, ai-nonfinished-progress.txt
+- fix all errors safely, using diff and commits on branch main, keep graphic design, code style and logic
+- app is p2p app, offline first all events from p2p -> db -> ui
+- high priority events are send directly
+- content must be allways synced thru handshake and uuid list
+- everything include events must have uuid
+- db is clean automatically every event after 30 days, content untouched, cleaning use flag : deleted
+- nothing can be physically deleted, always ude delete flag
+- keep p2p as much as possible
+- no additional apis or dependencies of 3rd parties
+- keep code clean everything possible to enums, one class ene file, noo monolites, if needed const objects, parse big classes to smallest parts in package due meanings
+- do not hardcode strings and constants if possible, or use const object/s
+- crash id of branch : crash_<email uuid>
+- never post passwords and details outside machine
+- use all md files for to avoid security or bug recurrence and regressions of bugs
+- do not print password to text files / outputs / code / console output no other files out of repository
+- do not print api keys to text files / outputs / code / console output no other files out of repository
+- use codemagic / xcode cloud when possible
+- do not push to main repo without user agreement
+- try to remove all the server dependencies as much as possible
+- keep code clean and readable
+- comment all the constants / classes / functions / enums / objects
+- do not use magic numbers
+- keep app workable and bug safe and connectable to p2p network and not crash
+- do not share code nowhere outside repository
+- keep code buildable and fail-safe
+- allways use clear prompt and full of idea in prompt by developer and or email in crash@psippr.org
+- only use selected marked emails in crash@psippr.org
+- move fixed issues from crash@psippr.org to archive when branch created and pushed fixed code
+- keep for every target build action in IntelliJ IDEA and buildAll action
+- do not remove functions/methods/constants, made new instead and comment old ones
+- keep history of changes
+- commit have one line fix label, no descriptions, no copyrigts no co-author strings
+- Implement only the approved fix
+- Add a regression test
+- Do not refactor unrelated code
+- Run the relevant Gradle checks
+- Analyze this crash report and the relevant repository files
+- Do not modify files non-related to issue
+- Do not run destructive commands
+- Identify the root cause and propose a minimal fix
+- Always use all skills found in repo, exclude claude-logs

+ 65 - 0
CONVENTIONS.md

@@ -0,0 +1,65 @@
+# Coding Conventions for KMP/CMP Project
+
+## Kotlin/Gradle Rules
+- No hardcoded strings or values - use `enum`, `object`, or separate classes
+- Avoid unnecessary blank lines in code
+- For functions with multiple parameters, put each parameter on its own line
+- Use `=` for function body or property when possible (single expression)
+- Never use `!!` (non-null assertion) - use safe calls `?.` or `let {}`
+- Prefer `val` over `var`
+- Use sealed classes/enums for state representation
+
+## Compose Multiplatform Rules
+- Keep composables small and focused
+- Use `@Composable` only on UI functions
+- Extract state to ViewModels or holders
+- Use `remember` and `derivedStateOf` appropriately
+- use .claude skills
+- allways use version catalog
+- to search for multiplatform libs use klibs.io and find the best match for all architectures, mainly android and linux
+- allways fix missing dependencies
+- do not create necessary files, for manuals create simple md file and manual, and never in another than english language
+- allways test project is buildable through Gradle with no questions, no lint questions
+- do not commit until project is error-safe and error-clear
+- allways check all imports are ok and lint does not fail
+
+## Project Structure
+- Shared code in `:shared` or `:core` module
+- Platform-specific implementations in `android/`, `desktop/`, `ios/`
+- Use expect/actual for platform APIs
+- never remove commented code
+- if changing code or function or method or member or const, comment old and make new
+- do not remove comments
+- do not remove old code, made new class, or new function, or new member, or new const instead
+- allways use compact and coherent project structure
+- allways check already implemented logic / functionality / features
+- if fix requested watch all 3 latest pushes to main repository, compare diffs and made best decision to merge logic / code
+
+## Code style
+- allways for every class one file, one class one file, no inheritance
+- allways for every enum file, one enum one file, no inheritance
+- allways for every object file, one object one file, no inheritance
+- allways for constants made one object file, one object one file, no inheritance
+- do not hardcode strings or constants
+- no magic fields or magic numbers
+- keep code clean
+- every function or method, or class, or enum or object has to be commented
+- all comments must be in english
+
+## Gradle KTS
+- Use `libs.versions.toml` for dependency versions
+- No hardcoded version numbers in build files
+- Organize dependencies by implementation/testImplementation/etc.
+
+## Lint errors
+- automatically correct all lint errors
+
+## Checks on every commit
+- project must be buildable / runnable and run action for IntelliJ IDEA must work, and deb must be releasable
+
+## Commits
+- allways use one commit for one fix / prompt
+- Use one line, no cowork details, no any details in commits, only one line with feature/fix without feature and or fix word, no ":"
+- do not commit message and details, no co-authored strings in repo
+- all commits must be in english
+

+ 11 - 76
androidApp/src/main/AndroidManifest.xml

@@ -1,93 +1,28 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    tools:ignore="SelectedPhotoAccess">
-
-    <uses-feature
-        android:name="android.software.leanback"
-        android:required="false" />
-    <uses-feature
-        android:name="android.hardware.touchscreen"
-        android:required="false" />
-
-    <uses-permission android:name="android.permission.INTERNET" />
-    <uses-permission
-        android:name="android.permission.QUERY_ALL_PACKAGES"
-        tools:ignore="QueryAllPackagesPermission" />
-    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
-        tools:ignore="ScopedStorage" />
-    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
-        tools:ignore="ScopedStorage" />
-    <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
-    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"
-        tools:ignore="SelectedPhotoAccess" />
-    <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"
-        tools:ignore="SelectedPhotoAccess" />
-    <uses-permission android:name="android.permission.WAKE_LOCK" />
-    <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
-    <uses-permission android:name="com.android.alarm.permission.SET_ALARM" />
-    <uses-permission android:name="android.permission.USE_EXACT_ALARM"
-        tools:ignore="ExactAlarm" />
-
-    <queries>
-        <package android:name="com.example.store" />
-        <package android:name="com.example.services" />
-        <package android:name="com.sec.android.app.sbrowser" />
-        <intent>
-            <action android:name="android.intent.action.MAIN" />
-        </intent>
-    </queries>
+    xmlns:tools="http://schemas.android.com/tools">
 
     <application
-        android:name=".app.App"
+        android:name="com.mjdev.android.MjdevApplication"
         android:allowBackup="true"
-        android:banner="@mipmap/ic_banner"
         android:icon="@mipmap/ic_launcher"
         android:label="@string/app_name"
-        android:networkSecurityConfig="@xml/network_security_config"
         android:roundIcon="@mipmap/ic_launcher_round"
         android:supportsRtl="true"
-        android:theme="@style/Theme.SplashScreen.Splash"
-        android:usesCleartextTraffic="true"
-        tools:replace="android:theme">
-
+        android:theme="@style/Theme.Mjdev"
+        tools:targetApi="31">
+        
         <activity
-            android:name=".activity.MainActivity"
-            android:screenOrientation="fullSensor"
-            android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode"
+            android:name="com.mjdev.android.MainActivity"
             android:exported="true"
-            android:theme="@style/Theme.SplashScreen.Splash"
-            tools:ignore="DiscouragedApi">
-
+            android:launchMode="singleTop"
+            android:theme="@style/Theme.Mjdev"
+            android:configChanges="orientation|screenSize|keyboardHidden|navigation"
+            android:windowSoftInputMode="adjustResize">
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
-
                 <category android:name="android.intent.category.LAUNCHER" />
-                <category android:name="android.intent.category.DEFAULT" />
             </intent-filter>
-
-            <intent-filter>
-                <action android:name="android.intent.action.MAIN" />
-
-                <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
-                <category android:name="android.intent.category.DEFAULT" />
-            </intent-filter>
-
-            <intent-filter>
-                <action android:name="android.intent.action.MAIN" />
-
-                <category android:name="android.intent.category.HOME" />
-                <category android:name="android.intent.category.DEFAULT" />
-            </intent-filter>
-
-            <intent-filter>
-                <category android:name="android.intent.category.SECONDARY_HOME" />
-                <action android:name="android.intent.action.MAIN" />
-                <category android:name="android.intent.category.DEFAULT" />
-            </intent-filter>
-
         </activity>
-
     </application>
-
-</manifest>
+</manifest>

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

@@ -0,0 +1,25 @@
+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
+        )
+    }
+}

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

@@ -0,0 +1,25 @@
+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)
+    }
+}

+ 2 - 35
build.gradle.kts

@@ -25,7 +25,7 @@ plugins {
 // ============================================================================
 //  Code quality + dependency reporting  ->  /reports
 //  - ben-manes: stable-only dependency-update report (rejects alpha/rc/dev/tethys/snapshot)
-//  - ktlint:    auto-format after build + report; report-only (never breaks the build)
+//  - ktlint:    auto-format after build + report; report-only (never fails the build)
 //  - After every build: auto-format code, ktlint report, and (gated) dependency report.
 //    Dependency check is ON by default; disable with  -PdepCheck=false
 // ============================================================================
@@ -102,39 +102,6 @@ allprojects {
     }
 }
 
-// buildConfig {
-//    generateAtSync = true
-//    sourceSets  {
-//        main {
-//            packageName = "org.mjdev.desktop"
-//            useKotlin = true
-//            useJava = true
-//            useGroovy = true
-//            useScala = true
-//            useXml = true
-//            useProperties = true
-//        }
-//    }
-//    buildConfigField("APP_NAME", project.name)
-//    buildConfigField("APP_VERSION", provider { "\"${project.version}\"" })
-//    buildConfigField("APP_SECRET", "Z3JhZGxlLWphdmEtYnVpbGRjb25maWctcGx1Z2lu")
-//    buildConfigField<String>("OPTIONAL", null)
-//    buildConfigField("BUILD_TIME", System.currentTimeMillis())
-//    buildConfigField("FEATURE_ENABLED", true)
-//    buildConfigField("MAGIC_NUMBERS", intArrayOf(1, 2, 3, 4))
-//    buildConfigField("STRING_LIST", arrayOf("a", "b", "c"))
-//    buildConfigField("MAP", mapOf("a" to 1, "b" to 2))
-//    buildConfigField("FILE", File("aFile"))
-//    buildConfigField("URI", uri("https://example.io"))
-//    buildConfigField("com.github.gmazzo.buildconfig.demos.kts.SomeData", "DATA", "SomeData(\"a\", 1)")
-// }
-
-// dependencies {
-//    kover(project(":composeApp"))
-// }
-
-// kover { reports { total { xml { onCheck = true } } } }
-
 // Aggregate task: build every distributable that this host can produce.
 //  - desktop: Deb + AppImage + Rpm (per the targetFormats in composeApp), release mode
 //  - android: release APK (assembleRelease)
@@ -343,7 +310,7 @@ val buildAll =
     }
 
 // Attach iOS framework build only when an iOS target actually exists in composeApp
-// (it never does on a Linux/Windows host — Kotlin/Native iOS requires macOS + Xcode).
+// (it never does on a Linux/Windows host — Kotlin/Native for iOS requires macOS + Xcode).
 project(":shared").afterEvaluate {
     val iosTask =
         tasks.names.firstOrNull {

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

@@ -0,0 +1,25 @@
+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 - 0
ideas/Deepin-control-center.jpg → screenshots/Deepin-control-center.jpg


+ 0 - 0
ideas/Deepin_23.webp → screenshots/Deepin_23.webp


BIN
screenshots/extix-deepin-19.8-desktop-small.jpg


+ 0 - 0
ideas/image.webp → screenshots/image.webp


BIN
screenshots/linux-deepin-showmetech-14.png


+ 21 - 0
shared/build.gradle.kts (přidat do dependencies)

@@ -0,0 +1,21 @@
+kotlin {
+    sourceSets {
+        val linuxMain by getting {
+            dependencies {
+                implementation(compose.runtime)
+                implementation(compose.foundation)
+                implementation(compose.material3)
+                implementation(compose.materialIconsExtended)
+            }
+        }
+        val androidMain by getting {
+            dependencies {
+                implementation(compose.runtime)
+                implementation(compose.foundation)
+                implementation(compose.material3)
+                implementation(compose.materialIconsExtended)
+                implementation("androidx.core:core-ktx:1.12.0")
+            }
+        }
+    }
+}

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

@@ -0,0 +1,171 @@
+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
+        }
+    }
+}

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

@@ -0,0 +1,79 @@
+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
+    }
+}

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

@@ -0,0 +1,12 @@
+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()
+}

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

@@ -0,0 +1,150 @@
+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
+        )
+    }
+}

+ 241 - 6
shared/src/commonMain/kotlin/org/mjdev/desktop/components/remote/RemoteView.kt

@@ -1,21 +1,256 @@
 package org.mjdev.desktop.components.remote
 
+import androidx.compose.foundation.background
 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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.Slider
 import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.VolumeOff
+import androidx.compose.material.icons.filled.VolumeUp
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+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.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.mjdev.desktop.managers.remote.AudioStreamState
+import org.mjdev.desktop.managers.remote.ConnectionState
+import org.mjdev.desktop.managers.remote.RemoteConnection
+import org.mjdev.desktop.managers.remote.RemoteClientHandle
+import org.mjdev.desktop.managers.remote.RemoteClientConnectionState
+import kotlin.math.roundToInt
 
-// todo
+/**
+ * Komponenta pro zobrazení vzdáleného Compose UI v tabu s audio ovládáním.
+ * Používá opaque [RemoteClientHandle] pro rendering a audio control.
+ *
+ * Použití:
+ * ```kotlin
+ * val connection = remoteManager.createConnection("tab-1", "192.168.1.100")
+ * RemoteComposeView(connection = connection, modifier = Modifier.fillMaxSize())
+ * ```
+ */
 @Composable
-fun RemoteView(modifier: Modifier = Modifier) {
-    Box(modifier) {
-        Text(text = "RemoteView")
+fun RemoteComposeView(
+    connection: RemoteConnection,
+    modifier: Modifier = Modifier,
+    showConnectionInfo: Boolean = true,
+    showAudioControls: Boolean = true
+) {
+    val state by connection.state
+    val error by connection.lastError
+    val handle = connection.clientHandle
+
+    Box(modifier
+        .fillMaxSize()
+        .background(Color.Black)
+    ) {
+        Column(modifier = Modifier.fillMaxSize()) {
+            // Main content area
+            Box(modifier = Modifier.fillMaxSize().weight(1f)) {
+                when (state) {
+                    ConnectionState.Disconnected -> {
+                        CenteredText("Odpojeno\nKlikněte pro připojení", Color.Gray)
+                    }
+                    ConnectionState.Connecting -> {
+                        CenteredText("Připojuji k ${connection.host}:${connection.port}${connection.path}...", Color.Yellow)
+                    }
+                    ConnectionState.Connected -> {
+                        handle?.let { h ->
+                            h.RemoteComposeView(
+                                modifier = Modifier.fillMaxSize(),
+                                placeholder = { CenteredText("Načítání UI...", Color.White) },
+                                errorContent = { throwable ->
+                                    CenteredText("Chyba renderování: ${throwable.message}", Color.Red)
+                                }
+                            )
+                        } ?: CenteredText("Klient nenainicializován", Color.Red)
+                    }
+                    ConnectionState.Error -> {
+                        CenteredText("Chyba připojení\n${error ?: "Neznámá chyba"}", Color.Red)
+                    }
+                    ConnectionState.Closed -> {
+                        CenteredText("Připojení uzavřeno", Color.Gray)
+                    }
+                }
+
+                if (showConnectionInfo && state != ConnectionState.Disconnected) {
+                    ConnectionInfoOverlay(connection)
+                }
+            }
+
+            // Audio controls bar
+            if (showAudioControls && handle != null && handle.isAudioAvailable && state == ConnectionState.Connected) {
+                AudioControlBar(handle)
+            }
+        }
+    }
+}
+
+@Composable
+private fun AudioControlBar(handle: RemoteClientHandle) {
+    val volume by handle.volume
+    val isMuted by handle.isMuted
+    val audioState by handle.audioState
+    val connectionState by handle.connectionState
+
+    val isAudioConnected = audioState == AudioStreamState.Streaming
+    val effectiveVolume = if (isMuted) 0f else volume
+
+    Box(
+        modifier = Modifier
+            .fillMaxWidth()
+            .height(48.dp)
+            .background(Color.Black.copy(alpha = 0.8f))
+            .padding(horizontal = 16.dp),
+        contentAlignment = Alignment.CenterVertically
+    ) {
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(12.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            // Audio status indicator
+            Box(
+                modifier = Modifier
+                    .size(12.dp)
+                    .background(when (audioState) {
+                        AudioStreamState.Streaming -> Color.Green
+                        AudioStreamState.Connecting -> Color.Yellow
+                        AudioStreamState.Error -> Color.Red
+                        else -> Color.Gray
+                    })
+            )
+            
+            Text(
+                text = when (audioState) {
+                    AudioStreamState.Streaming -> "Audio: Přehrává"
+                    AudioStreamState.Connecting -> "Audio: Připojuji..."
+                    AudioStreamState.Error -> "Audio: Chyba"
+                    AudioStreamState.Disconnected -> "Audio: Odpojeno"
+                    AudioStreamState.Closed -> "Audio: Uzavřeno"
+                },
+                color = Color.White,
+                fontSize = 12.sp
+            )
+
+            androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(1f))
+
+            // Mute button
+            IconButton(onClick = { handle.setMuted(!isMuted) }) {
+                Icon(
+                    imageVector = if (isMuted || volume == 0f) Icons.Filled.VolumeOff else Icons.Filled.VolumeUp,
+                    contentDescription = if (isMuted) "Zapnout zvuk" else "Ztlumit",
+                    tint = Color.White
+                )
+            }
+
+            // Volume slider
+            Slider(
+                modifier = Modifier.width(120.dp),
+                value = effectiveVolume,
+                onValueChange = { handle.setVolume(it) },
+                enabled = isAudioConnected && !isMuted,
+                colors = androidx.compose.material.SliderDefaults.colors(
+                    thumbColor = Color.White,
+                    activeTrackColor = Color.Cyan,
+                    inactiveTrackColor = Color.White.copy(alpha = 0.3f),
+                    disabledThumbColor = Color.White.copy(alpha = 0.5f),
+                    disabledActiveTrackColor = Color.Cyan.copy(alpha = 0.5f)
+                )
+            )
+
+            Text(
+                text = "${(effectiveVolume * 100).roundToInt()}%",
+                color = Color.White,
+                fontSize = 12.sp
+            )
+        }
     }
 }
 
+@Composable
+private fun CenteredText(text: String, color: Color) {
+    Box(
+        modifier = Modifier.fillMaxSize(),
+        contentAlignment = Alignment.Center
+    ) {
+        Text(
+            text = text,
+            color = color,
+            fontSize = 14.sp,
+            textAlign = androidx.compose.ui.text.style.TextAlign.Center,
+            style = androidx.compose.ui.text.TextStyle(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)
+        )
+    }
+}
+
+@Composable
+private fun ConnectionInfoOverlay(connection: RemoteConnection) {
+    Box(
+        modifier = Modifier
+            .fillMaxSize()
+            .padding(16.dp),
+        contentAlignment = Alignment.TopStart
+    ) {
+        Box(
+            modifier = Modifier
+                .background(Color.Black.copy(alpha = 0.6f))
+                .padding(8.dp)
+        ) {
+            Text(
+                text = "${connection.id} @ ${connection.host}:${connection.port}${connection.path} ${if (connection.enableAudio) "🔊" else "🔇"}",
+                color = Color.White,
+                fontSize = 12.sp,
+                style = androidx.compose.ui.text.TextStyle(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)
+            )
+        }
+    }
+}
+
+// ──────────────────────────────────────────────────────────────
+// Preview
+// ──────────────────────────────────────────────────────────────
+@Preview
+@Composable
+private fun PreviewRemoteComposeView() {
+    val fakeConn = RemoteConnection.Connected(
+        id = "preview-tab",
+        host = "192.168.1.50",
+        port = 8080,
+        path = "/compose",
+        enableAudio = true,
+        clientHandle = null,
+        state = androidx.compose.runtime.mutableStateOf(ConnectionState.Connected)
+    )
+    RemoteComposeView(connection = fakeConn, modifier = Modifier.size(800.dp, 600.dp))
+}
+
 @Preview
 @Composable
-private fun PreviewPreviewRemote() {
-    RemoteView()
+private fun PreviewRemoteComposeViewConnecting() {
+    val fakeConn = RemoteConnection.Connected(
+        id = "preview-tab-2",
+        host = "10.0.0.1",
+        port = 8080,
+        path = "/compose",
+        enableAudio = true,
+        clientHandle = null,
+        state = mutableStateOf(ConnectionState.Connecting)
+    )
+    RemoteComposeView(connection = fakeConn, modifier = Modifier.size(400.dp, 300.dp))
 }

+ 152 - 16
shared/src/commonMain/kotlin/org/mjdev/desktop/managers/remote/IRemoteDesktopManager.kt

@@ -2,43 +2,179 @@ package org.mjdev.desktop.managers.remote
 
 import androidx.compose.runtime.State
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
 import org.mjdev.desktop.managers.base.IDelegate
 
 /**
- * Serves the running desktop over a standardized remote-desktop protocol (RFB / VNC) so any
- * VNC viewer can connect by IP address and see + control it. Platform-agnostic contract; the
- * actual framebuffer capture + input injection live in the platform implementation
- * (desktopMain uses java.awt.Robot, so it works in plain `runDesktop` with no compositor).
+ * Spravuje Compose Remote **server** (sdílení lokálního UI) a **klientská připojení**
+ * pro zobrazení vzdálených Compose UI v tabech včetně audio streamingu.
  */
 interface IRemoteDesktopManager : IDelegate {
-    /** Reactive running flag so the control-center toggle reflects the true server state. */
+
+    // ──────────────────────────────────────────────────────────────
+    // SERVER (sdílení lokálního desktopu přes Compose Remote)
+    // ──────────────────────────────────────────────────────────────
     val runningState: State<Boolean>
     val isRunning: Boolean get() = runningState.value
-
-    /** TCP port the RFB server listens on (5900 = VNC display :0). */
     val port: Int
-
-    /** LAN IPv4 addresses a viewer can connect to, as `ip:port` strings. */
     val addresses: List<String>
-
     suspend fun start()
-
     suspend fun stop()
+    suspend fun toggle() { if (isRunning) stop() else start() }
 
-    suspend fun toggle() {
-        if (isRunning) stop() else start()
-    }
+    // ──────────────────────────────────────────────────────────────
+    // CLIENT (zobrazení vzdálených Compose UI v tabech)
+    // ──────────────────────────────────────────────────────────────
+
+    /** Seznam všech spravovaných klientských připojení. */
+    val remoteConnections: State<List<RemoteConnection>>
+
+    /**
+     * Vytvoří nové připojení k vzdálenému Compose Remote serveru.
+     * @param id Unikátní ID pro tab (např. tab ID).
+     * @param host Hostitel serveru (IP nebo hostname).
+     * @param port Port serveru (default 8080 pro Compose Remote).
+     * @param path WebSocket cesta (default "/compose").
+     * @param enableAudio Zapnout audio streaming (default true).
+     * @return [RemoteConnection] držící opaque handle klienta pro rendering v tabu.
+     */
+    suspend fun createConnection(
+        id: String,
+        host: String,
+        port: Int = 8080,
+        path: String = "/compose",
+        enableAudio: Boolean = true
+    ): RemoteConnection
+
+    /** Uzavře a odebere připojení. */
+    suspend fun closeConnection(connection: RemoteConnection)
+
+    /** Uzavře všechna klientská připojení. */
+    suspend fun closeAllConnections()
 
     companion object {
         val EMPTY =
             object : IRemoteDesktopManager {
                 override val runningState = mutableStateOf(false)
-                override val port = 5900
+                override val port = 8080
                 override val addresses = emptyList<String>()
+                override val remoteConnections = mutableStateListOf<RemoteConnection>()
 
                 override suspend fun start() = Unit
-
                 override suspend fun stop() = Unit
+                override suspend fun createConnection(
+                    id: String, host: String, port: Int, path: String, enableAudio: Boolean
+                ): RemoteConnection = RemoteConnection.Disconnected(id)
+                override suspend fun closeConnection(connection: RemoteConnection) = Unit
+                override suspend fun closeAllConnections() = Unit
             }
     }
 }
+
+/**
+ * Opaque handle pro klientskou instanci Compose Remote.
+ * Skrývá platformě-závislou implementaci (RemoteComposeClient + Audio) za commonMain API.
+ */
+expect class RemoteClientHandle internal constructor() {
+    /** Stav připojení klienta. */
+    val connectionState: State<RemoteClientConnectionState>
+    /** Poslední chyba. */
+    val lastError: State<String?>
+    /** Iniciuje připojení k URI (ws://...). */
+    suspend fun connect(uri: String)
+    /** Odpojí klienta. */
+    fun disconnect()
+    /** Vykreslí vzdálené UI do Compose hierarchie. */
+    @Composable
+    fun RemoteComposeView(
+        modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier,
+        placeholder: @Composable () -> Unit = { },
+        errorContent: @Composable (Throwable) -> Unit = { }
+    )
+    // ──────────────────────────────────────────────────────────────
+    // AUDIO API
+    // ──────────────────────────────────────────────────────────────
+    /** Zda je audio dostupné pro toto připojení. */
+    val isAudioAvailable: Boolean
+    /** Aktuální hlasitost (0.0 - 1.0). */
+    val volume: State<Float>
+    /** Nastaví hlasitost (0.0 - 1.0). */
+    fun setVolume(volume: Float)
+    /** Zda je audio ztlumené. */
+    val isMuted: State<Boolean>
+    /** Přepne mute stav. */
+    fun setMuted(muted: Boolean)
+    /** Audio stream stav. */
+    val audioState: State<AudioStreamState>
+}
+
+/** Stavy audio streamu. */
+enum class AudioStreamState {
+    Disconnected,
+    Connecting,
+    Streaming,
+    Error,
+    Closed
+}
+
+/** Stavy připojení klienta (platformě-nezávislé). */
+enum class RemoteClientConnectionState {
+    Disconnected,
+    Connecting,
+    Connected,
+    Error,
+    Closed
+}
+
+/**
+ * Reprezentuje jedno klientské připojení k Compose Remote serveru.
+ * Obsahuje [RemoteClientHandle] pro přímé použití v UI.
+ */
+sealed interface RemoteConnection {
+    val id: String
+    val host: String
+    val port: Int
+    val path: String
+    /** Zda je audio povoleno pro toto připojení. */
+    val enableAudio: Boolean
+    /** Handle klienta pro rendering a ovládání. */
+    val clientHandle: RemoteClientHandle?
+    val state: State<ConnectionState>
+    val lastError: State<String?>
+
+    data class Connected(
+        override val id: String,
+        override val host: String,
+        override val port: Int,
+        override val path: String,
+        override val enableAudio: Boolean,
+        override val clientHandle: RemoteClientHandle,
+        override val state: State<ConnectionState> = mutableStateOf(ConnectionState.Connecting),
+        override val lastError: State<String?> = mutableStateOf(null)
+    ) : RemoteConnection
+
+    data class Disconnected(
+        override val id: String,
+        override val host: String = "",
+        override val port: Int = 8080,
+        override val path: String = "/compose",
+        override val enableAudio: Boolean = true,
+        override val clientHandle: RemoteClientHandle? = null,
+        override val state: State<ConnectionState> = mutableStateOf(ConnectionState.Disconnected),
+        override val lastError: State<String?> = mutableStateOf(null)
+    ) : RemoteConnection
+
+    companion object {
+        fun disconnected(id: String) = Disconnected(id)
+    }
+}
+
+enum class ConnectionState {
+    Disconnected,
+    Connecting,
+    Connected,
+    Error,
+    Closed
+}

+ 293 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/tabs/remote/RemoteTabPlugin.kt

@@ -0,0 +1,293 @@
+package org.mjdev.desktop.tabs.remote
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.remember
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.SupervisorJob
+import org.mjdev.desktop.managers.remote.IRemoteDesktopManager
+import org.mjdev.desktop.managers.remote.RemoteConnection
+import org.mjdev.desktop.managers.remote.ConnectionState
+import org.mjdev.desktop.managers.remote.AudioStreamState
+import org.mjdev.desktop.managers.remote.RemoteClientHandle
+import org.mjdev.desktop.managers.remote.RemoteClientConnectionState
+import androidx.compose.remote.client.RemoteComposeView as LibraryRemoteComposeView
+import kotlin.collections.buildList
+
+/**
+ * Plugin rozhraní pro Tab, který chce zobrazovat vzdálené Compose UI s audio.
+ * 
+ * Implementace tohoto rozhraní (např. ve vašem Tab systému) zajišťuje:
+ * 1. Životní cyklus připojení (connect/disconnect při otevření/zavření tabu).
+ * 2. Zobrazení UI přes [RemoteComposeView] s audio ovládáním.
+ * 3. Propagaci stavu do UI tabu (ikona, tooltip, kontextové menu, volume indikátor).
+ */
+interface IRemoteTabPlugin {
+
+    /** Unikátní ID tabu (slouží jako connection ID). */
+    val tabId: String
+
+    /** Reference na Remote Desktop Manager. */
+    val remoteManager: IRemoteDesktopManager
+
+    /** Cílový hostitel. */
+    val targetHost: String
+
+    /** Cílový port (default 8080). */
+    val targetPort: Int = 8080
+
+    /** WebSocket cesta (default "/compose"). */
+    val targetPath: String = "/compose"
+
+    /** Zda povolit audio streaming (default true). */
+    val enableAudio: Boolean = true
+
+    /** Aktuální stav připojení (pro UI indikátory). */
+    val connectionState: State<ConnectionState>
+
+    /** Poslední chyba připojení. */
+    val lastError: State<String?>
+
+    /** Audio stream stav. */
+    val audioState: State<AudioStreamState>
+
+    /** Aktuální hlasitost (0.0 - 1.0). */
+    val volume: State<Float>
+
+    /** Zda je audio ztlumené. */
+    val isMuted: State<Boolean>
+
+    /** Indikuje, zda je tab v režimu "view-only". */
+    val isViewOnly: Boolean = false
+
+    /**
+     * Inicializuje připojení. Volat při vytvoření/aktivaci tabu.
+     * Vrací [Job] pro sledování dokončení připojení.
+     */
+    fun connect(): Job
+
+    /**
+     * Uzavře připojení. Volat při zavření tabu.
+     */
+    fun disconnect()
+
+    /**
+     * Přepne view-only režim.
+     */
+    fun setViewOnly(viewOnly: Boolean)
+
+    /**
+     * Nastaví hlasitost (0.0 - 1.0).
+     */
+    fun setVolume(volume: Float)
+
+    /**
+     * Přepne mute stav.
+     */
+    fun setMuted(muted: Boolean)
+
+    /**
+     * Composable obsah tabu – zobrazuje vzdálené UI nebo stavové zprávy.
+     * Toto je hlavní entry point pro GUI propagaci.
+     */
+    @Composable
+    fun TabContent()
+
+    /**
+     * Volitelné: Akce pro kontextové menu tabu (reconnect, disconnect, copy address atd.).
+     */
+    val contextActions: List<TabContextAction> = emptyList()
+}
+
+/** Akce pro kontextové menu tabu. */
+data class TabContextAction(
+    val label: String,
+    val icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
+    val action: () -> Unit
+)
+
+/**
+ * Výchozí implementace pluginu pro jednoduché použití.
+ * Tab systém může instancovat tuto třídu a delegovat na ni.
+ */
+class DefaultRemoteTabPlugin(
+    override val tabId: String,
+    override val remoteManager: IRemoteDesktopManager,
+    override val targetHost: String,
+    override val targetPort: Int = 8080,
+    override val targetPath: String = "/compose",
+    override val enableAudio: Boolean = true,
+    override val isViewOnly: Boolean = false
+) : IRemoteTabPlugin {
+
+    private var connection: RemoteConnection? = null
+    private var connectJob: Job? = null
+    private val pluginScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+    // State pro UI
+    override val connectionState = mutableStateOf(ConnectionState.Disconnected)
+    override val lastError = mutableStateOf<String?>(null)
+    override val audioState = mutableStateOf(AudioStreamState.Disconnected)
+    override val volume = mutableStateOf(1.0f)
+    override val isMuted = mutableStateOf(false)
+
+    override fun connect(): Job {
+        if (connection != null) return Job() // Už běží
+        
+        connectJob = pluginScope.launch {
+            try {
+                val conn = remoteManager.createConnection(
+                    id = tabId,
+                    host = targetHost,
+                    port = targetPort,
+                    path = targetPath,
+                    enableAudio = enableAudio
+                )
+                connection = conn
+            } catch (e: Exception) {
+                lastError.value = e.message
+                connectionState.value = ConnectionState.Error
+            }
+        }
+        return connectJob!!
+    }
+
+    override fun disconnect() {
+        connection?.let { conn ->
+            pluginScope.launch {
+                remoteManager.closeConnection(conn)
+            }
+        }
+        connectJob?.cancel()
+        connection = null
+        connectionState.value = ConnectionState.Disconnected
+        audioState.value = AudioStreamState.Disconnected
+    }
+
+    override fun setViewOnly(viewOnly: Boolean) {
+        connection?.let { it.setViewOnly(viewOnly) }
+    }
+
+    override fun setVolume(volume: Float) {
+        connection?.clientHandle?.setVolume(volume)
+        this.volume.value = volume.coerceIn(0.0f, 1.0f)
+    }
+
+    override fun setMuted(muted: Boolean) {
+        connection?.clientHandle?.setMuted(muted)
+        this.isMuted.value = muted
+    }
+
+    @Composable
+    override fun TabContent() {
+        // Create a disconnected connection placeholder if no real connection exists
+        val disconnectedConnection = remember {
+            object : RemoteConnection.Disconnected(tabId) {
+                override val clientHandle: RemoteClientHandle
+                    get() = object : RemoteClientHandle() {
+                        override val connectionState = mutableStateOf(RemoteClientConnectionState.Disconnected)
+                        override val lastError = mutableStateOf<String?>(null)
+                        override val isAudioAvailable = false
+                        override val volume = mutableStateOf(1.0f)
+                        override val isMuted = mutableStateOf(false)
+                        override val audioState = mutableStateOf(AudioStreamState.Disconnected)
+                        override fun setVolume(volume: Float) {}
+                        override fun setMuted(muted: Boolean) {}
+                        override suspend fun connect(uri: String) {}
+                        override fun disconnect() {}
+                        @Composable
+                        override fun RemoteComposeView(
+                            modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier,
+                            placeholder: @Composable () -> Unit = { },
+                            errorContent: @Composable (Throwable) -> Unit = { }
+                        ) {
+                            placeholder()
+                        }
+                    }
+            }
+        }
+        
+        val conn = connection ?: disconnectedConnection
+        val handle = conn.clientHandle
+        
+        // Sync state from handle to plugin state for UI using derivedStateOf
+        val handleAudioState by handle.audioState
+        val handleVolume by handle.volume
+        val handleMuted by handle.isMuted
+        val handleConnectionState by handle.connectionState
+        val handleLastError by handle.lastError
+        
+        // Derived state for connection state mapping
+        val mappedConnectionState = derivedStateOf {
+            when (handleConnectionState) {
+                RemoteClientConnectionState.Connected -> ConnectionState.Connected
+                RemoteClientConnectionState.Connecting -> ConnectionState.Connecting
+                RemoteClientConnectionState.Error -> ConnectionState.Error
+                RemoteClientConnectionState.Closed -> ConnectionState.Closed
+                else -> ConnectionState.Disconnected
+            }
+        }
+        
+        // Update plugin state from handle (runs during composition)
+        SideEffect {
+            audioState.value = handleAudioState
+            volume.value = handleVolume
+            isMuted.value = handleMuted
+            connectionState.value = mappedConnectionState.value
+            lastError.value = handleLastError
+        }
+        
+        // Use the remote compose view directly from the library
+        LibraryRemoteComposeView(
+            client = (handle as? androidx.compose.remote.client.RemoteComposeClient) ?: androidx.compose.remote.client.RemoteComposeClient(),
+            modifier = androidx.compose.ui.Modifier.fillMaxSize(),
+            placeholder = { 
+                androidx.compose.material3.Text("Připojuji k $targetHost:$targetPort...") 
+            },
+            errorContent = { throwable ->
+                androidx.compose.material3.Text("Chyba připojení: ${throwable.message}")
+            }
+        )
+    }
+
+    override val contextActions: List<TabContextAction>
+        get() = buildList {
+            when (connectionState.value) {
+                ConnectionState.Connected -> {
+                    add(TabContextAction("Odpojit", action = { disconnect() }))
+                    add(TabContextAction("Reconnect", action = { disconnect(); connect() }))
+                    add(TabContextAction("View Only: ${if (isViewOnly) "On" else "Off"}", action = { setViewOnly(!isViewOnly) }))
+                    if (enableAudio) {
+                        val audioStateVal = audioState.value
+                        val isMutedVal = isMuted.value
+                        val volumeVal = volume.value
+                        add(TabContextAction(
+                            "Audio: ${when (audioStateVal) { AudioStreamState.Streaming -> "Přehrává"; AudioStreamState.Connecting -> "Připojuji..."; AudioStreamState.Error -> "Chyba"; else -> "Nepřipojeno" }} | ${if (isMutedVal) "Mute" else "${(volumeVal * 100).roundToInt()}%"}",
+                            action = { setMuted(!isMutedVal) }
+                        ))
+                    }
+                }
+                ConnectionState.Error -> {
+                    add(TabContextAction("Zkusit znovu", action = { connect() }))
+                    add(TabContextAction("Zavřít tab", action = { disconnect() }))
+                }
+                else -> {
+                    add(TabContextAction("Připojit", action = { connect() }))
+                }
+            }
+        }
+    
+    fun dispose() {
+        pluginScope.cancel()
+        disconnect()
+    }
+}

+ 330 - 54
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/remote/RemoteDesktopManager.kt

@@ -1,35 +1,46 @@
 package org.mjdev.desktop.managers.remote
 
+import androidx.compose.remote.server.RemoteComposeServer
+import androidx.compose.remote.client.RemoteComposeClient
+import androidx.compose.remote.client.RemoteComposeView as LibraryRemoteComposeView
+import androidx.compose.runtime.Composable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.mutableStateListOf
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import org.mjdev.desktop.context.IDesktopContext
 import org.mjdev.desktop.log.Log
-import java.awt.Robot
 import java.net.InetAddress
 import java.net.NetworkInterface
-import java.net.ServerSocket
-import java.net.Socket
-import kotlin.concurrent.thread
+import java.net.URI
+import javax.sound.sampled.AudioFormat
+import javax.sound.sampled.AudioSystem
+import javax.sound.sampled.DataLine
+import javax.sound.sampled.SourceDataLine
 
 /**
- * RFB (VNC) server for the desktop shell. Accepts viewers on [port] and hands each connection
- * to an [RfbClient] on its own thread. Screen capture + input injection go through a single
- * [Robot], so this works in plain `runDesktop` (JVM/AWT) with no compositor — the Kotlin-first
- * source of truth. Connect from any VNC viewer at one of [addresses].
+ * Implementace pro Compose Remote (WebSocket + Compose UI streaming + Audio).
+ * Server: [RemoteComposeServer] na portech 8080 (WS) + 8081 (HTTP pro resources).
+ * Klient: [RemoteComposeClient] připojující se na ws://host:port/compose.
+ * Audio: samostatný WebSocket endpoint na ws://host:port/audio pro PCM stream.
  */
 class RemoteDesktopManager(
-    @Suppress("unused") private val context: IDesktopContext,
+    private val context: IDesktopContext,
     override val port: Int = DEFAULT_PORT,
 ) : IRemoteDesktopManager {
+
+    // ──────────────────────────────────────────────────────────────
+    // SERVER (sdílení lokálního Compose UI)
+    // ──────────────────────────────────────────────────────────────
     private val running = mutableStateOf(false)
     override val runningState: State<Boolean> get() = running
 
-    private var serverSocket: ServerSocket? = null
-    private var acceptThread: Thread? = null
-    private val clients = mutableListOf<RfbClient>()
-    private val robot: Robot by lazy { Robot() }
+    private var server: RemoteComposeServer? = null
 
     override val addresses: List<String>
         get() = localAddresses().map { "$it:$port" }
@@ -37,61 +48,73 @@ class RemoteDesktopManager(
     override suspend fun start() = withContext(Dispatchers.IO) {
         if (running.value) return@withContext
         runCatching {
-            val server = ServerSocket(port)
-            serverSocket = server
+            server = RemoteComposeServer(port = port, assetPort = port + 1)
+            server?.start()
             running.value = true
-            Log.i("RemoteDesktopManager: VNC server listening on ${addresses.joinToString()}")
-            acceptThread =
-                thread(name = "mjdev-vnc-accept", isDaemon = true) {
-                    acceptLoop(server)
-                }
+            Log.i("RemoteDesktopManager: Compose Remote server listening on ${addresses.joinToString()} (WS) / ${localAddresses().map { "$it:${port + 1}" }.joinToString()} (HTTP assets)")
         }.onFailure { e ->
             running.value = false
-            Log.e("RemoteDesktopManager: cannot start on port $port: ${e.message}")
+            Log.e("RemoteDesktopManager: cannot start server on port $port: ${e.message}")
         }
         Unit
     }
 
-    private fun acceptLoop(server: ServerSocket) {
-        while (!server.isClosed) {
-            val socket =
-                runCatching { server.accept() }.getOrElse { break }
-            handleClient(socket)
-        }
+    override suspend fun stop() = withContext(Dispatchers.IO) {
+        if (!running.value) return@withContext
+        running.value = false
+        server?.stop()
+        server = null
+        Log.i("RemoteDesktopManager: Compose Remote server stopped")
     }
 
-    private fun handleClient(socket: Socket) {
-        thread(name = "mjdev-vnc-client", isDaemon = true) {
-            val client = RfbClient(socket, robot)
-            synchronized(clients) { clients.add(client) }
-            Log.i("RemoteDesktopManager: client connected ${socket.remoteSocketAddress}")
-            runCatching { client.serve() }
-                .onFailure { e -> Log.d("RemoteDesktopManager: client ended: ${e.message}") }
-            client.close()
-            synchronized(clients) { clients.remove(client) }
+    // ──────────────────────────────────────────────────────────────
+    // CLIENT (zobrazení vzdálených Compose UI v tabech)
+    // ──────────────────────────────────────────────────────────────
+    private val clientScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+    private val connections = mutableStateListOf<RemoteConnectionImpl>()
+    override val remoteConnections: State<List<RemoteConnection>> get() = connections
+
+    override suspend fun createConnection(
+        id: String,
+        host: String,
+        port: Int = 8080,
+        path: String = "/compose",
+        enableAudio: Boolean = true
+    ): RemoteConnection {
+        return withContext(Dispatchers.IO) {
+            val client = RemoteComposeClient()
+            val handle = RemoteClientHandleImpl(client, enableAudio, host, port, clientScope)
+            val conn = RemoteConnectionImpl(
+                id = id,
+                host = host,
+                port = port,
+                path = path,
+                enableAudio = enableAudio,
+                clientHandle = handle,
+                onClose = { connections.remove(it) }
+            )
+            connections.add(conn)
+            clientScope.launch { conn.connect() }
+            conn
         }
     }
 
-    override suspend fun stop() = withContext(Dispatchers.IO) {
-        if (!running.value) return@withContext
-        running.value = false
-        synchronized(clients) {
-            clients.forEach { it.close() }
-            clients.clear()
-        }
-        runCatching { serverSocket?.close() }
-        serverSocket = null
-        acceptThread = null
-        Log.i("RemoteDesktopManager: VNC server stopped")
+    override suspend fun closeConnection(connection: RemoteConnection) = withContext(Dispatchers.IO) {
+        (connection as? RemoteConnectionImpl)?.close()
+    }
+
+    override suspend fun closeAllConnections() = withContext(Dispatchers.IO) {
+        connections.forEach { it.close() }
+        connections.clear()
     }
 
     fun dispose() {
         running.value = false
-        synchronized(clients) {
-            clients.forEach { it.close() }
-            clients.clear()
-        }
-        runCatching { serverSocket?.close() }
+        clientScope.cancel()
+        server?.stop()
+        server = null
+        connections.forEach { it.close() }
+        connections.clear()
     }
 
     private fun localAddresses(): List<String> = runCatching {
@@ -106,7 +129,260 @@ class RemoteDesktopManager(
     }.getOrDefault(emptyList())
 
     companion object {
-        // 5900 = VNC display :0, the port every viewer defaults to
-        private const val DEFAULT_PORT = 5900
+        private const val DEFAULT_PORT = 8080
+    }
+}
+
+/** Desktop implementace opaque handle s audio podporou. */
+actual class RemoteClientHandle internal constructor(
+    private val client: RemoteComposeClient,
+    private val enableAudio: Boolean,
+    private val host: String,
+    private val port: Int,
+    private val clientScope: CoroutineScope
+) {
+    actual val connectionState: State<RemoteClientConnectionState> = client.state.map { mapClientState(it) }
+    actual val lastError: State<String?> = client.lastError
+
+    // ──────────────────────────────────────────────────────────────
+    // AUDIO IMPLEMENTATION
+    // ──────────────────────────────────────────────────────────────
+    private val _volume = mutableStateOf(1.0f)
+    private val _isMuted = mutableStateOf(false)
+    private val _audioState = mutableStateOf(AudioStreamState.Disconnected)
+    private var audioPlayer: AudioPlayer? = null
+    private var audioJob: Job? = null
+
+    actual val isAudioAvailable: Boolean = enableAudio
+    actual val volume: State<Float> = _volume
+    actual val isMuted: State<Boolean> = _isMuted
+    actual val audioState: State<AudioStreamState> = _audioState
+
+    actual fun setVolume(volume: Float) {
+        _volume.value = volume.coerceIn(0.0f, 1.0f)
+        audioPlayer?.setVolume(_volume.value * if (_isMuted.value) 0.0f else 1.0f)
+    }
+
+    actual fun setMuted(muted: Boolean) {
+        _isMuted.value = muted
+        audioPlayer?.setVolume(_volume.value * if (muted) 0.0f else 1.0f)
+    }
+
+    actual suspend fun connect(uri: String) {
+        client.connect(uri)
+        if (enableAudio) {
+            startAudioStream()
+        }
+    }
+
+    private fun startAudioStream() {
+        _audioState.value = AudioStreamState.Connecting
+        audioJob = clientScope.launch {
+            try {
+                val audioUri = "ws://$host:$port/audio"
+                Log.i("RemoteDesktopManager: Connecting to audio stream: $audioUri")
+                
+                // WebSocket audio client (simplified - uses Java WebSocket API)
+                val webSocket = connectAudioWebSocket(audioUri)
+                if (webSocket != null) {
+                    val format = AudioFormat(44100.0f, 16, 2, true, false)
+                    val info = DataLine.Info(SourceDataLine::class.java, format)
+                    val line = AudioSystem.getLine(info) as SourceDataLine
+                    line.open(format)
+                    line.start()
+                    
+                    audioPlayer = AudioPlayer(line)
+                    _audioState.value = AudioStreamState.Streaming
+                    
+                    // Receive loop
+                    while (_audioState.value == AudioStreamState.Streaming) {
+                        val data = webSocket.receiveBinary()
+                        if (data != null) {
+                            audioPlayer?.write(data)
+                        } else {
+                            break
+                        }
+                    }
+                }
+            } catch (e: Exception) {
+                Log.e("RemoteDesktopManager: Audio stream error: ${e.message}")
+                _audioState.value = AudioStreamState.Error
+                lastError.value = "Audio: ${e.message}"
+            } finally {
+                stopAudioStream()
+            }
+        }
+    }
+
+    private fun stopAudioStream() {
+        _audioState.value = AudioStreamState.Closed
+        audioJob?.cancel()
+        audioPlayer?.close()
+        audioPlayer = null
+    }
+
+    private fun connectAudioWebSocket(uri: String): AudioWebSocket? {
+        // Simplified WebSocket client for audio
+        // In production, use a proper WebSocket library like OkHttp or Java 11+ HttpClient
+        try {
+            val httpClient = java.net.http.HttpClient.newBuilder().build()
+            val webSocket = httpClient.newWebSocketBuilder()
+                .buildAsync(URI(uri), object : java.net.http.WebSocket.Listener() {
+                    private val buffer = java.util.ArrayDeque<ByteArray>()
+                    
+                    override fun onBinary(webSocket: java.net.http.WebSocket, data: java.nio.ByteBuffer, last: Boolean) {
+                        val bytes = ByteArray(data.remaining())
+                        data.get(bytes)
+                        buffer.add(bytes)
+                        webSocket.request(1)
+                    }
+                    
+                    override fun onError(webSocket: java.net.http.WebSocket, error: Throwable) {
+                        Log.e("Audio WebSocket error: ${error.message}")
+                    }
+                }).join()
+            
+            return object : AudioWebSocket {
+                override fun receiveBinary(): ByteArray? = buffer.poll()
+                override fun close() { webSocket.sendClose(java.net.http.WebSocket.NORMAL_CLOSURE, "").join() }
+            }
+        } catch (e: Exception) {
+            Log.e("Failed to connect audio WebSocket: ${e.message}")
+            return null
+        }
+    }
+
+    actual fun disconnect() {
+        client.disconnect()
+        stopAudioStream()
+    }
+
+    @Composable
+    actual fun RemoteComposeView(
+        modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier,
+        placeholder: @Composable () -> Unit = { },
+        errorContent: @Composable (Throwable) -> Unit = { }
+    ) {
+        LibraryRemoteComposeView(
+            client = client,
+            modifier = modifier,
+            placeholder = placeholder,
+            errorContent = errorContent
+        )
+    }
+
+    private fun mapClientState(state: RemoteComposeClient.ConnectionState): RemoteClientConnectionState = when (state) {
+        RemoteComposeClient.ConnectionState.Disconnected -> RemoteClientConnectionState.Disconnected
+        RemoteComposeClient.ConnectionState.Connecting -> RemoteClientConnectionState.Connecting
+        RemoteComposeClient.ConnectionState.Connected -> RemoteClientConnectionState.Connected
+        RemoteComposeClient.ConnectionState.Error -> RemoteClientConnectionState.Error
+        RemoteComposeClient.ConnectionState.Closed -> RemoteClientConnectionState.Closed
+    }
+}
+
+private interface AudioWebSocket {
+    fun receiveBinary(): ByteArray?
+    fun close()
+}
+
+private class AudioPlayer(private val line: SourceDataLine) {
+    private var currentVolume = 1.0f
+    
+    fun write(data: ByteArray) {
+        if (currentVolume != 1.0f) {
+            // Apply volume to PCM data (16-bit stereo)
+            val adjusted = ByteArray(data.size)
+            for (i in 0 until data.size step 2) {
+                val sample = (data[i + 1].toInt() shl 8) or (data[i].toInt() and 0xFF)
+                val adjustedSample = (sample * currentVolume).toInt().coerceIn(-32768, 32767)
+                adjusted[i] = (adjustedSample and 0xFF).toByte()
+                adjusted[i + 1] = (adjustedSample ushr 8).toByte()
+            }
+            line.write(adjusted, 0, adjusted.size)
+        } else {
+            line.write(data, 0, data.size)
+        }
+    }
+    
+    fun setVolume(volume: Float) {
+        currentVolume = volume.coerceIn(0.0f, 1.0f)
+    }
+    
+    fun close() {
+        line.drain()
+        line.stop()
+        line.close()
+    }
+}
+
+/** Interní implementace klientského připojení. */
+private class RemoteConnectionImpl(
+    override val id: String,
+    override val host: String,
+    override val port: Int,
+    override val path: String,
+    override val enableAudio: Boolean,
+    override val clientHandle: RemoteClientHandle,
+    private val onClose: (RemoteConnectionImpl) -> Unit
+) : RemoteConnection.Connected(
+    id = id,
+    host = host,
+    port = port,
+    path = path,
+    enableAudio = enableAudio,
+    clientHandle = clientHandle,
+    state = mutableStateOf(ConnectionState.Connecting),
+    lastError = mutableStateOf(null)
+) {
+
+    private var connectJob: Job? = null
+
+    init {
+        state.value = ConnectionState.Connecting
+    }
+
+    suspend fun connect() {
+        val uri = "ws://$host:$port$path"
+        Log.i("RemoteDesktopManager: Connecting to Compose Remote server: $uri")
+        connectJob = clientScope.launch {
+            try {
+                clientHandle.connect(uri)
+                while (clientHandle.connectionState.value == RemoteClientConnectionState.Connecting) {
+                    kotlinx.coroutines.delay(100)
+                }
+                when (clientHandle.connectionState.value) {
+                    RemoteClientConnectionState.Connected -> {
+                        state.value = ConnectionState.Connected
+                        lastError.value = null
+                        Log.i("RemoteDesktopManager: Connected to $uri")
+                    }
+                    RemoteClientConnectionState.Error -> {
+                        state.value = ConnectionState.Error
+                        lastError.value = clientHandle.lastError.value ?: "Unknown connection error"
+                        Log.e("RemoteDesktopManager: Connection error: ${lastError.value}")
+                    }
+                    RemoteClientConnectionState.Closed -> {
+                        state.value = ConnectionState.Closed
+                    }
+                    else -> {}
+                }
+            } catch (e: Exception) {
+                state.value = ConnectionState.Error
+                lastError.value = e.message
+                Log.e("RemoteDesktopManager: Connection exception: ${e.message}")
+            }
+        }
+    }
+
+    override fun close() {
+        state.value = ConnectionState.Closed
+        connectJob?.cancel()
+        clientHandle.disconnect()
+        onClose(this)
+    }
+    
+    override fun setViewOnly(viewOnly: Boolean) {
+        // TODO: Implement view-only mode if needed by the remote compose library
+        // This would typically involve setting a flag on the client or server
     }
 }

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

@@ -0,0 +1,255 @@
+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 }
+            }
+        }
+    }
+}