Show the local client's state in the Android connection notification, like the tray icon
The notification follows the server holding the microphone, as the desktop tray does, and draws its state: the Material glyph in the status bar, the chosen pack's art as the large icon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Pair<ServerController, SessionState>?> =
|
||||
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"
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user