Skip to content

Commit b5ec4cd

Browse files
committed
Scroll the debug screen with the brightness wheel
The debug screen is the one place in LightPods longer than the panel, and it is the screen you read while your other hand is holding the earbuds — swiping it is awkward exactly when you need it. The wheel is right under the thumb already. Only the turns are taken. The click and the camera button belong to LightControl, which owns them across the phone and passes bare turns down to com.gios.* so an app can scroll a notch at a time instead of being driven by a synthetic finger. The home screen is deliberately one screenful, so nothing there is wired.
1 parent 7dbc4ac commit b5ec4cd

5 files changed

Lines changed: 421 additions & 102 deletions

File tree

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,24 @@ AAP, so they are not there. The debug screen has a one-tap AAP probe that tries
8484
L2CAP 0x1001 and reports exactly how it is refused — worth running once on your own
8585
handset rather than taking the paragraph above on trust.
8686

87+
## The wheel
88+
89+
The brightness wheel scrolls the debug screen. Nothing else in the app is longer than the
90+
panel — a battery figure you have to scroll to is a battery figure you may as well not have
91+
— so that is the only place a notch does anything.
92+
93+
It reaches the app at all because Light patched `/system/usr/keylayout/Generic.kl`, so each
94+
notch of the `Pixart pat9126ja` sensor arrives as an ordinary key event and nothing above the
95+
app intercepts it. `hw/LightKeys.kt` resolves `WHEEL_CCW` and `WHEEL_CW` by label at runtime
96+
and falls back to the raw scancode gated on the device name, so a paired keyboard's `r`
97+
doesn't scroll anything. Turns only: the click and the camera button belong to
98+
[LightControl](https://github.com/gi-os/LightControl), which owns them phone-wide and passes
99+
bare turns through so that apps can scroll per notch instead of being handed a synthetic
100+
finger. Notches are frame-timed into a glide rather than applied on arrival, and the first
101+
notch after a pause waits for a second to confirm it, because the wheel sits under a thumb.
102+
Both are explained at length in
103+
[LightNews](https://github.com/gi-os/LightNews#the-wheel-and-the-camera-button).
104+
87105
## Install
88106

89107
```sh
@@ -136,6 +154,7 @@ bt/AirPodsScanner.kt controller-side scan filter, two duty cycles
136154
bt/PodsConnector.kt the three-step connect chain
137155
data/PodsRepository.kt process-wide StateFlow shared by service and UI
138156
service/PodsService.kt foreground service, ongoing status notification
157+
hw/ the brightness wheel, turns only
139158
ui/ Compose, greyscale, Akkurat pulled from LightOS
140159
```
141160

‎app/src/main/kotlin/com/gios/lightpods/MainActivity.kt‎

Lines changed: 135 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import android.content.pm.PackageManager
88
import android.os.Build
99
import android.os.Bundle
1010
import android.provider.Settings
11+
import android.view.KeyEvent
1112
import androidx.activity.ComponentActivity
1213
import androidx.activity.compose.setContent
1314
import androidx.activity.enableEdgeToEdge
1415
import androidx.activity.result.contract.ActivityResultContracts
1516
import androidx.compose.foundation.layout.safeDrawingPadding
17+
import androidx.compose.runtime.CompositionLocalProvider
1618
import androidx.compose.runtime.LaunchedEffect
1719
import androidx.compose.runtime.collectAsState
1820
import androidx.compose.runtime.getValue
@@ -29,6 +31,10 @@ import com.gios.lightpods.bt.MediaControls
2931
import com.gios.lightpods.bt.PodsConnector
3032
import com.gios.lightpods.data.PodsRepository
3133
import com.gios.lightpods.data.isStale
34+
import com.gios.lightpods.hw.LightKey
35+
import com.gios.lightpods.hw.LightKeys
36+
import com.gios.lightpods.hw.LocalWheelBus
37+
import com.gios.lightpods.hw.WheelBus
3238
import com.gios.lightpods.service.PodsService
3339
import com.gios.lightpods.ui.DebugScreen
3440
import com.gios.lightpods.ui.HomeScreen
@@ -40,6 +46,9 @@ class MainActivity : ComponentActivity() {
4046

4147
private var permissionsGranted by mutableStateOf(false)
4248

49+
/** Wheel notches on their way to whichever screen is up. */
50+
private val wheel = WheelBus()
51+
4352
private val requestPermissions =
4453
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
4554
permissionsGranted = hasPermissions()
@@ -49,6 +58,29 @@ class MainActivity : ComponentActivity() {
4958
}
5059
}
5160

61+
/**
62+
* Every hardware key arrives here first — `DecorView` hands the event to the window
63+
* callback before it walks the view hierarchy — so a notch reaches the screen whatever
64+
* happens to hold focus.
65+
*
66+
* Only the turns. The wheel click and the camera button belong to LightControl, which
67+
* owns them phone-wide and passes bare turns through on purpose.
68+
*/
69+
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
70+
when (LightKeys.of(event)) {
71+
LightKey.WheelUp -> {
72+
if (event.action == KeyEvent.ACTION_DOWN) wheel.send(1)
73+
return true
74+
}
75+
LightKey.WheelDown -> {
76+
if (event.action == KeyEvent.ACTION_DOWN) wheel.send(-1)
77+
return true
78+
}
79+
else -> Unit
80+
}
81+
return super.dispatchKeyEvent(event)
82+
}
83+
5284
override fun onCreate(savedInstanceState: Bundle?) {
5385
super.onCreate(savedInstanceState)
5486
// Targeting SDK 35 forces edge to edge, so the theme's bar colours stop
@@ -60,117 +92,119 @@ class MainActivity : ComponentActivity() {
6092
val connector = PodsConnector(this)
6193

6294
setContent {
63-
LightPodsTheme {
64-
val scope = rememberCoroutineScope()
65-
val view by PodsRepository.view.collectAsState()
66-
val candidates by PodsRepository.candidates.collectAsState()
67-
val connected by PodsRepository.connected.collectAsState()
68-
val connectMessage by PodsRepository.connectResult.collectAsState()
69-
70-
var connecting by remember { mutableStateOf(false) }
71-
var showDebug by remember { mutableStateOf(false) }
72-
var probing by remember { mutableStateOf(false) }
73-
var probeResult by remember { mutableStateOf<String?>(null) }
74-
var tick by remember { mutableLongStateOf(System.currentTimeMillis()) }
75-
var playing by remember { mutableStateOf(false) }
76-
var volume by remember { mutableFloatStateOf(0f) }
77-
78-
// Going out of range, switching Bluetooth off, and pausing playback in
79-
// another app all produce silence rather than an event, so the screen
80-
// needs its own heartbeat to notice them.
81-
LaunchedEffect(Unit) {
82-
while (true) {
83-
tick = System.currentTimeMillis()
84-
playing = media.isPlaying
85-
volume = media.volume()
86-
delay(2_000)
95+
CompositionLocalProvider(LocalWheelBus provides wheel) {
96+
LightPodsTheme {
97+
val scope = rememberCoroutineScope()
98+
val view by PodsRepository.view.collectAsState()
99+
val candidates by PodsRepository.candidates.collectAsState()
100+
val connected by PodsRepository.connected.collectAsState()
101+
val connectMessage by PodsRepository.connectResult.collectAsState()
102+
103+
var connecting by remember { mutableStateOf(false) }
104+
var showDebug by remember { mutableStateOf(false) }
105+
var probing by remember { mutableStateOf(false) }
106+
var probeResult by remember { mutableStateOf<String?>(null) }
107+
var tick by remember { mutableLongStateOf(System.currentTimeMillis()) }
108+
var playing by remember { mutableStateOf(false) }
109+
var volume by remember { mutableFloatStateOf(0f) }
110+
111+
// Going out of range, switching Bluetooth off, and pausing playback in
112+
// another app all produce silence rather than an event, so the screen
113+
// needs its own heartbeat to notice them.
114+
LaunchedEffect(Unit) {
115+
while (true) {
116+
tick = System.currentTimeMillis()
117+
playing = media.isPlaying
118+
volume = media.volume()
119+
delay(2_000)
120+
}
87121
}
88-
}
89122

90-
val stale = remember(view, tick) { view?.isStale(tick) == true }
91-
val bluetoothOn = remember(tick) { isBluetoothOn() }
123+
val stale = remember(view, tick) { view?.isStale(tick) == true }
124+
val bluetoothOn = remember(tick) { isBluetoothOn() }
125+
126+
if (showDebug) {
127+
DebugScreen(
128+
view = view,
129+
candidates = candidates,
130+
probeResult = probeResult,
131+
probing = probing,
132+
onProbe = {
133+
probing = true
134+
probeResult = null
135+
scope.launch {
136+
val adapter = connector.adapter
137+
val device = connector.targetDevice()
138+
probeResult = when {
139+
adapter == null -> "no Bluetooth adapter"
140+
device == null -> "no bonded audio device to probe"
141+
else -> AapProbe.run(adapter, device).detail
142+
}
143+
probing = false
144+
}
145+
},
146+
onBack = { showDebug = false },
147+
)
148+
return@LightPodsTheme
149+
}
92150

93-
if (showDebug) {
94-
DebugScreen(
151+
HomeScreen(
152+
modifier = Modifier.safeDrawingPadding(),
95153
view = view,
96-
candidates = candidates,
97-
probeResult = probeResult,
98-
probing = probing,
99-
onProbe = {
100-
probing = true
101-
probeResult = null
154+
stale = stale,
155+
bluetoothOn = bluetoothOn,
156+
permissionsGranted = permissionsGranted,
157+
connected = connected,
158+
playing = playing,
159+
volume = volume,
160+
connecting = connecting,
161+
connectMessage = connectMessage,
162+
onConnect = {
163+
connecting = true
164+
PodsRepository.setConnectResult(null)
102165
scope.launch {
103-
val adapter = connector.adapter
104-
val device = connector.targetDevice()
105-
probeResult = when {
106-
adapter == null -> "no Bluetooth adapter"
107-
device == null -> "no bonded audio device to probe"
108-
else -> AapProbe.run(adapter, device).detail
166+
val result = connector.connect()
167+
connecting = false
168+
when (result) {
169+
is PodsConnector.Result.Connected -> {
170+
PodsRepository.setConnected(true)
171+
PodsRepository.setConnectResult(
172+
"${result.device} — ${result.via}",
173+
)
174+
}
175+
176+
PodsConnector.Result.NoDevice ->
177+
PodsRepository.setConnectResult("Pair your earbuds first")
178+
179+
PodsConnector.Result.BluetoothOff ->
180+
PodsRepository.setConnectResult("Bluetooth is off")
181+
182+
is PodsConnector.Result.Failed -> {
183+
PodsRepository.setConnectResult("Could not connect")
184+
openBluetoothSettings()
185+
}
109186
}
110-
probing = false
111187
}
112188
},
113-
onBack = { showDebug = false },
189+
onPlayPause = {
190+
media.playPause()
191+
playing = !playing
192+
},
193+
onPrevious = media::previous,
194+
onNext = media::next,
195+
onVolumeUp = {
196+
media.volumeUp()
197+
volume = media.volume()
198+
},
199+
onVolumeDown = {
200+
media.volumeDown()
201+
volume = media.volume()
202+
},
203+
onGrantPermissions = { requestPermissions.launch(requiredPermissions()) },
204+
onOpenSettings = ::openBluetoothSettings,
205+
onDebug = { showDebug = true },
114206
)
115-
return@LightPodsTheme
116207
}
117-
118-
HomeScreen(
119-
modifier = Modifier.safeDrawingPadding(),
120-
view = view,
121-
stale = stale,
122-
bluetoothOn = bluetoothOn,
123-
permissionsGranted = permissionsGranted,
124-
connected = connected,
125-
playing = playing,
126-
volume = volume,
127-
connecting = connecting,
128-
connectMessage = connectMessage,
129-
onConnect = {
130-
connecting = true
131-
PodsRepository.setConnectResult(null)
132-
scope.launch {
133-
val result = connector.connect()
134-
connecting = false
135-
when (result) {
136-
is PodsConnector.Result.Connected -> {
137-
PodsRepository.setConnected(true)
138-
PodsRepository.setConnectResult(
139-
"${result.device} — ${result.via}",
140-
)
141-
}
142-
143-
PodsConnector.Result.NoDevice ->
144-
PodsRepository.setConnectResult("Pair your earbuds first")
145-
146-
PodsConnector.Result.BluetoothOff ->
147-
PodsRepository.setConnectResult("Bluetooth is off")
148-
149-
is PodsConnector.Result.Failed -> {
150-
PodsRepository.setConnectResult("Could not connect")
151-
openBluetoothSettings()
152-
}
153-
}
154-
}
155-
},
156-
onPlayPause = {
157-
media.playPause()
158-
playing = !playing
159-
},
160-
onPrevious = media::previous,
161-
onNext = media::next,
162-
onVolumeUp = {
163-
media.volumeUp()
164-
volume = media.volume()
165-
},
166-
onVolumeDown = {
167-
media.volumeDown()
168-
volume = media.volume()
169-
},
170-
onGrantPermissions = { requestPermissions.launch(requiredPermissions()) },
171-
onOpenSettings = ::openBluetoothSettings,
172-
onDebug = { showDebug = true },
173-
)
174208
}
175209
}
176210
}

0 commit comments

Comments
 (0)