diff --git a/android/app/src/main/java/com/ts3client/android/ConnectionService.kt b/android/app/src/main/java/com/ts3client/android/ConnectionService.kt index 2746857..8442aef 100644 --- a/android/app/src/main/java/com/ts3client/android/ConnectionService.kt +++ b/android/app/src/main/java/com/ts3client/android/ConnectionService.kt @@ -8,6 +8,8 @@ import android.app.PendingIntent import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo +import android.graphics.Color +import android.graphics.drawable.Icon import android.net.wifi.WifiManager import android.os.PowerManager import androidx.lifecycle.LifecycleService @@ -17,12 +19,17 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import com.ts3client.config.Settings import com.ts3client.net.ClientStatus +import com.ts3client.session.SelfState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch +import kotlin.math.roundToInt /** * Keeps the process in the foreground while connected, so the connection and its audio @@ -36,6 +43,7 @@ class ConnectionService : LifecycleService() { private lateinit var wakeLock: PowerManager.WakeLock private lateinit var overlay: PttOverlay private var foregroundTypes = 0 + private var shown: ServerController? = null override fun onCreate() { super.onCreate() @@ -53,19 +61,30 @@ class ConnectionService : LifecycleService() { show(sessions.current.value?.state?.value ?: SessionState(), capturing = false) lifecycleScope.launch { - val currentState = sessions.current.flatMapLatest { it?.state ?: flowOf(null) } // The server count shows in the notification too. - combine(currentState, sessions.micOwner, sessions.servers) { state, owner, _ -> state to (owner != null) } - .collectLatest { (state, capturing) -> - if (state != null) { - show(state, capturing) - } else { + combine(shownServer(), sessions.micOwner, sessions.servers) { shown, owner, _ -> shown to (owner != null) } + .conflate() + .collect { (shown, capturing) -> + if (shown != null) { + this@ConnectionService.shown = shown.first + show(shown.second, capturing) + // Talking flips the icon often, and Android drops updates that come too fast. + delay(MIN_UPDATE_MS) + } + } + } + lifecycleScope.launch { + sessions.current.flatMapLatest { it?.state ?: flowOf(null) }.collectLatest { state -> + if (state == null) { // A reconnect leaves the server briefly; only stop once it stays gone. delay(STOP_DELAY_MS) stopSelf() } } } + lifecycleScope.launch { + sessions.icons.active.drop(1).collect { shown?.let { show(it.state.value, sessions.micOwner.value != null) } } + } overlay = PttOverlay(this, sessions.icons, sessions::setPushToTalk) lifecycleScope.launch { @@ -89,9 +108,25 @@ class ConnectionService : LifecycleService() { } } + /** + * The server the notification speaks for, as the desktop tray icon does: the one + * holding the microphone, or the visible one when nobody is capturing. + */ + private fun shownServer(): Flow?> = + combine(sessions.current, sessions.micOwner) { current, owner -> current to owner } + .flatMapLatest { (current, owner) -> + combine(current?.state ?: flowOf(null), owner?.state ?: flowOf(null)) { currentState, ownerState -> + when { + owner != null && ownerState?.connected == true -> owner to ownerState + current != null && currentState != null -> current to currentState + else -> null + } + } + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) - val server = sessions.current.value ?: return START_NOT_STICKY + val server = shown ?: sessions.current.value ?: return START_NOT_STICKY val state = server.state.value when (intent?.action) { ACTION_TOGGLE_MUTE -> server.setMicMuted(!state.micMuted) @@ -142,7 +177,8 @@ class ConnectionService : LifecycleService() { ) val others = sessions.servers.value.size - 1 return Notification.Builder(this, CHANNEL) - .setSmallIcon(android.R.drawable.ic_btn_speak_now) + .setSmallIcon(statusIcon(state.self)) + .setLargeIcon(largeIcon(state.self)) .setContentTitle(if (others > 0) "${state.title} +$others" else state.title) .setContentText(state.status) .setContentIntent(open) @@ -153,6 +189,26 @@ class ConnectionService : LifecycleService() { .build() } + /** + * The status bar shows only the shape of an icon, which pack art loses, so it is + * drawn from the Material pack whatever pack is chosen. + */ + private fun statusIcon(self: SelfState): Icon { + val key = self.iconKey() ?: return Icon.createWithResource(this, android.R.drawable.ic_btn_speak_now) + val size = (STATUS_ICON_DP * resources.displayMetrics.density).roundToInt() + val bitmap = sessions.icons.renderMaterial(key, size, Color.WHITE) + ?: return Icon.createWithResource(this, android.R.drawable.ic_btn_speak_now) + return Icon.createWithBitmap(bitmap) + } + + /** The expanded notification has room for the chosen pack's own art, as the tree draws it. */ + private fun largeIcon(self: SelfState): Icon? { + val key = self.iconKey() ?: return null + val size = (LARGE_ICON_DP * resources.displayMetrics.density).roundToInt() + return sessions.icons.render(listOf(key), size, getColor(android.R.color.system_neutral1_500)) + ?.let(Icon::createWithBitmap) + } + private fun action(action: String, label: String): Notification.Action { val intent = PendingIntent.getService( this, action.hashCode(), Intent(this, ConnectionService::class.java).setAction(action), @@ -165,6 +221,9 @@ class ConnectionService : LifecycleService() { const val CHANNEL = "connection" const val NOTIFICATION_ID = 1 const val STOP_DELAY_MS = 3_000L + const val MIN_UPDATE_MS = 250L + const val STATUS_ICON_DP = 24 + const val LARGE_ICON_DP = 48 const val ACTION_TOGGLE_MUTE = "com.ts3client.android.TOGGLE_MUTE" const val ACTION_TOGGLE_DEAFEN = "com.ts3client.android.TOGGLE_DEAFEN" const val ACTION_DISCONNECT = "com.ts3client.android.DISCONNECT" diff --git a/android/app/src/main/java/com/ts3client/android/ServerController.kt b/android/app/src/main/java/com/ts3client/android/ServerController.kt index 7cf7989..edbc870 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerController.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerController.kt @@ -355,6 +355,7 @@ class ServerController internal constructor( channelId = if (session.isConnected) self?.channelId ?: 0 else 0, inputMode = app.settings.inputMode, hostButton = if (session.isConnected) conn.model.hostButton() else null, + self = session.selfState(), ) } diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index 8617a76..6730dd7 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -20,6 +20,7 @@ import com.ts3client.contacts.ContactStore import com.ts3client.myts.MyTeamSpeak import com.ts3client.net.HostButton import com.ts3client.myts.MyTeamSpeakLogin +import com.ts3client.session.SelfState import com.ts3client.session.Sessions import com.ts3client.sound.SoundNotifier import com.ts3client.sound.SoundPack @@ -53,6 +54,8 @@ data class SessionState( val channelId: Int = 0, val inputMode: Settings.InputMode = Settings.InputMode.VOICE_ACTIVATION, val hostButton: HostButton? = null, + /** What the tree shows for the local client, and the connection notification too. */ + val self: SelfState = SelfState.DISCONNECTED, ) /** Something that happened once, for the UI to tell the user about. */ diff --git a/ts3-client/core/src/main/java/com/ts3client/session/SelfState.java b/ts3-client/core/src/main/java/com/ts3client/session/SelfState.java index 20c11c5..0b85907 100644 --- a/ts3-client/core/src/main/java/com/ts3client/session/SelfState.java +++ b/ts3-client/core/src/main/java/com/ts3client/session/SelfState.java @@ -7,23 +7,30 @@ package com.ts3client.session; */ public enum SelfState { - DISCONNECTED("Not connected"), - DEAFENED("Speakers muted"), - MIC_MUTED("Microphone muted"), - MIC_LOCAL_MUTED("Microphone locally muted"), - AWAY("Away"), - COMMANDER_TALKING("Talking (channel commander)"), - COMMANDER("Channel commander"), - TALKING("Talking"), - IDLE("Connected"); + DISCONNECTED("Not connected", null), + DEAFENED("Speakers muted", "OUTPUT_MUTED"), + MIC_MUTED("Microphone muted", "INPUT_MUTED"), + MIC_LOCAL_MUTED("Microphone locally muted", "INPUT_MUTED_LOCAL"), + AWAY("Away", "AWAY"), + COMMANDER_TALKING("Talking (channel commander)", "PLAYER_COMMANDER_ON"), + COMMANDER("Channel commander", "PLAYER_COMMANDER_OFF"), + TALKING("Talking", "PLAYER_ON"), + IDLE("Connected", "PLAYER_OFF"); private final String label; + private final String iconKey; - SelfState(String label) { + SelfState(String label, String iconKey) { this.label = label; + this.iconKey = iconKey; } public String label() { return label; } + + /** The icon pack key the state is drawn with, or {@code null} for the app's own icon. */ + public String iconKey() { + return iconKey; + } }