fix(desktop): volume curve, windows update loop, and taskbar thumbnail controls

This commit is contained in:
Murilo Scolari 2026-10-24 01:28:22 -03:00
parent 76a95bceb7
commit d5c5c88eef
10 changed files with 386 additions and 14 deletions

View file

@ -202,6 +202,9 @@ kotlin {
implementation(libs.zxing.core)
// DBus for Linux desktop window occlusion management
implementation("com.github.hypfvieh:dbus-java-core:5.0.0")
// Windows native integration (ITaskbarList3 thumbnail controls)
implementation(libs.jna)
implementation(libs.jna.platform)
}
}
}

View file

@ -416,3 +416,7 @@
# JNA references the signature-polymorphic java.lang.invoke.MethodHandle.invoke(...) overloads, which
# ProGuard can't resolve as concrete methods. JNA itself is kept above; suppress these warnings.
-dontwarn com.sun.jna.**
# Preserve BuildKonfig and VersionManager for runtime version/hash checks
-keep class com.maxrave.simpmusic.BuildKonfig { *; }
-keep class com.maxrave.simpmusic.utils.VersionManager { *; }

View file

@ -925,7 +925,7 @@ fun App(
onDismissRequest = {
if (updateState !is UpdateState.Downloading) {
shouldShowUpdateDialog = false
viewModel.showedUpdateDialog = false
viewModel.dismissUpdateDialog(response.tagName)
PlatformAppUpdater.resetState()
}
},
@ -1000,7 +1000,7 @@ fun App(
TextButton(
onClick = {
shouldShowUpdateDialog = false
viewModel.showedUpdateDialog = false
viewModel.dismissUpdateDialog(response.tagName)
PlatformAppUpdater.resetState()
},
) {
@ -1014,7 +1014,7 @@ fun App(
TextButton(
onClick = {
shouldShowUpdateDialog = false
viewModel.showedUpdateDialog = false
viewModel.dismissUpdateDialog(response.tagName)
PlatformAppUpdater.resetState()
},
) {
@ -1028,7 +1028,7 @@ fun App(
TextButton(
onClick = {
shouldShowUpdateDialog = false
viewModel.showedUpdateDialog = false
viewModel.dismissUpdateDialog(response.tagName)
},
) {
Text(

View file

@ -2863,7 +2863,7 @@ fun SettingScreen(
title = stringResource(Res.string.check_for_update),
subtitle = checkForUpdateSubtitle,
onClick = {
sharedViewModel.checkForUpdate()
sharedViewModel.checkForUpdate(isManual = true)
},
)
SettingItem(

View file

@ -57,11 +57,17 @@ internal fun isNewerRelease(
val versionComparison = compareVersions(cleanCurrent, cleanTag)
if (versionComparison < 0) return true
if (versionComparison == 0) {
if (tag.startsWith("custom-v") && commitHash.isNotEmpty() && !tag.endsWith(commitHash)) {
return true
}
}
if (versionComparison > 0) return false
// When base numeric versions match (e.g. 1.0.1 vs 1.0.1-hf, or 0.1.5-beta vs 0.1.5):
val tagIsHotfix = cleanTag.contains("-hf") || cleanTag.contains("-hotfix")
val currentIsHotfix = cleanCurrent.contains("-hf") || cleanCurrent.contains("-hotfix")
if (tagIsHotfix && !currentIsHotfix) return true
val currentIsPrerelease = cleanCurrent.contains("-beta") || cleanCurrent.contains("-alpha") || cleanCurrent.contains("-dev")
val tagIsPrerelease = cleanTag.contains("-beta") || cleanTag.contains("-alpha") || cleanTag.contains("-dev")
if (currentIsPrerelease && !tagIsPrerelease && !tagIsHotfix) return true
return false
}

View file

@ -1510,7 +1510,12 @@ class SharedViewModel(
private var _updateResponse = MutableStateFlow<UpdateData?>(null)
val updateResponse: StateFlow<UpdateData?> = _updateResponse
fun checkForUpdate() {
fun dismissUpdateDialog(tagName: String) {
showedUpdateDialog = false
putString("dismissed_update_tag", tagName)
}
fun checkForUpdate(isManual: Boolean = false) {
// Raised before the coroutine starts, so Home's launch banner, which reads it right after
// startup, can never miss a check that is already under way but not yet marked.
_isCheckingUpdate.value = true
@ -1520,13 +1525,16 @@ class SharedViewModel(
"CheckForUpdateAt",
System.currentTimeMillis().toString(),
)
val dismissedTag = if (!isManual) dataStoreManager.getString("dismissed_update_tag").first() else ""
if (updateChannel == DataStoreManager.GITHUB) {
updateRepository.checkForGithubReleaseUpdate().collectLatest { response ->
val data = response.data
when (response) {
is Resource.Success if (data != null) -> {
_updateResponse.value = data
showedUpdateDialog = true
if (isManual || data.tagName != dismissedTag) {
showedUpdateDialog = true
}
}
else -> {
@ -1541,7 +1549,9 @@ class SharedViewModel(
when (response) {
is Resource.Success if (data != null) -> {
_updateResponse.value = data
showedUpdateDialog = true
if (isManual || data.tagName != dismissedTag) {
showedUpdateDialog = true
}
}
else -> {

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -420,6 +421,7 @@ fun runDesktopApp(args: Array<String> = emptyArray()) {
}
},
) {
val coroutineScope = rememberCoroutineScope()
var isAwtIconified by remember { mutableStateOf(false) }
var isOccluded by remember { mutableStateOf(false) }
val isWindowMinimized = windowState.isMinimized || !isVisible || isAwtIconified || isOccluded
@ -438,6 +440,11 @@ fun runDesktopApp(args: Array<String> = emptyArray()) {
DisposableEffect(window) {
DesktopGpuManager.attach(window)
val cleanupTaskbarThumbnail = WindowsTaskbarThumbnailController.attach(
window = window,
mediaPlayerHandler = mediaPlayerHandler,
coroutineScope = coroutineScope,
)
fun triggerWakeup() {
if (isOccluded) isOccluded = false
@ -558,6 +565,7 @@ fun runDesktopApp(args: Array<String> = emptyArray()) {
window.removeWindowListener(windowListener)
window.removeWindowFocusListener(windowListener)
window.removeComponentListener(componentListener)
cleanupTaskbarThumbnail()
DesktopGpuManager.detach()
}
}

View file

@ -0,0 +1,339 @@
package com.maxrave.simpmusic
import com.maxrave.domain.mediaservice.handler.MediaPlayerHandler
import com.maxrave.domain.mediaservice.handler.PlayerEvent
import com.maxrave.logger.Logger
import com.sun.jna.Callback
import com.sun.jna.Function
import com.sun.jna.Library
import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.Structure
import com.sun.jna.WString
import com.sun.jna.platform.win32.Guid
import com.sun.jna.platform.win32.Ole32
import com.sun.jna.platform.win32.User32
import com.sun.jna.platform.win32.WinDef
import com.sun.jna.platform.win32.WinGDI
import com.sun.jna.ptr.PointerByReference
import com.sun.jna.win32.W32APIOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.awt.Color
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.Window
import java.awt.image.BufferedImage
/**
* Native Windows Taskbar Thumbnail Toolbar Controller (ITaskbarList3).
*
* Provides Previous, Play/Pause, and Next media buttons in the taskbar thumbnail preview
* when hovering over the app window on Windows 7, 8, 10, and 11.
*/
object WindowsTaskbarThumbnailController {
private const val TAG = "WindowsTaskbarThumbnail"
private const val THB_BITMAP = 0x00000001
private const val THB_ICON = 0x00000002
private const val THB_TOOLTIP = 0x00000004
private const val THB_FLAGS = 0x00000008
private const val THBF_ENABLED = 0x00000000
private const val THBF_DISABLED = 0x00000001
private const val THBF_DISMISSONCLICK = 0x00000002
private const val THBF_NOBACKGROUND = 0x00000004
private const val THBF_HIDDEN = 0x00000008
private const val THBF_NONINTERACTIVE = 0x00000010
private const val THBN_CLICKED = 0x1800
private const val WM_COMMAND = 0x0111
private const val GWLP_WNDPROC = -4
private const val ID_PREV = 101
private const val ID_PLAY_PAUSE = 102
private const val ID_NEXT = 103
@Structure.FieldOrder("dwMask", "iId", "iBitmap", "hIcon", "szTip", "dwFlags")
open class THUMBBUTTON : Structure {
@JvmField var dwMask: Int = 0
@JvmField var iId: Int = 0
@JvmField var iBitmap: Int = 0
@JvmField var hIcon: WinDef.HICON? = null
@JvmField var szTip: CharArray = CharArray(260)
@JvmField var dwFlags: Int = 0
constructor() : super()
constructor(p: Pointer) : super(p) { read() }
fun setTooltip(tip: String) {
val chars = tip.toCharArray()
val len = minOf(chars.size, 259)
System.arraycopy(chars, 0, szTip, 0, len)
szTip[len] = '\u0000'
}
class ByReference : THUMBBUTTON(), Structure.ByReference
}
private interface CustomUser32 : Library {
fun SetWindowLongPtr(hWnd: WinDef.HWND, nIndex: Int, wndProc: Callback): Pointer?
fun SetWindowLongPtr(hWnd: WinDef.HWND, nIndex: Int, wndProc: Pointer): Pointer?
fun CallWindowProc(
lpPrevWndFunc: Pointer,
hWnd: WinDef.HWND,
uMsg: Int,
wParam: WinDef.WPARAM,
lParam: WinDef.LPARAM,
): WinDef.LRESULT
fun RegisterWindowMessageW(lpString: WString): Int
fun CreateIconIndirect(piconinfo: WinGDI.ICONINFO): WinDef.HICON?
fun DestroyIcon(hIcon: WinDef.HICON): Boolean
companion object {
val INSTANCE: CustomUser32 by lazy {
Native.load("user32", CustomUser32::class.java, W32APIOptions.UNICODE_OPTIONS)
}
}
}
private interface CustomGdi32 : Library {
fun CreateBitmap(nWidth: Int, nHeight: Int, nPlanes: Int, nBitCount: Int, lpBits: IntArray): WinDef.HBITMAP?
fun CreateBitmap(nWidth: Int, nHeight: Int, nPlanes: Int, nBitCount: Int, lpBits: ByteArray): WinDef.HBITMAP?
fun DeleteObject(hObject: WinDef.HBITMAP): Boolean
companion object {
val INSTANCE: CustomGdi32 by lazy {
Native.load("gdi32", CustomGdi32::class.java, W32APIOptions.DEFAULT_OPTIONS)
}
}
}
private interface WindowProcCallback : Callback {
fun callback(
hwnd: WinDef.HWND,
uMsg: Int,
wParam: WinDef.WPARAM,
lParam: WinDef.LPARAM,
): WinDef.LRESULT
}
private class TaskbarList3(val pointer: Pointer) {
private val vtable: Pointer = pointer.getPointer(0)
private fun getFn(idx: Int): Function {
return Function.getFunction(vtable.getPointer(idx.toLong() * Native.POINTER_SIZE))
}
fun hrInit(): Int = getFn(3).invokeInt(arrayOf(pointer))
fun thumbBarAddButtons(hwnd: WinDef.HWND, count: Int, pButtons: Pointer): Int =
getFn(15).invokeInt(arrayOf(pointer, hwnd.pointer, count, pButtons))
fun thumbBarUpdateButtons(hwnd: WinDef.HWND, count: Int, pButtons: Pointer): Int =
getFn(16).invokeInt(arrayOf(pointer, hwnd.pointer, count, pButtons))
fun release(): Int = getFn(2).invokeInt(arrayOf(pointer))
}
private val CLSID_TaskbarList = Guid.CLSID(Guid.GUID("{56FDF344-FD6D-11d0-958A-006097C9A090}"))
private val IID_ITaskbarList3 = Guid.IID(Guid.GUID("{ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf}"))
fun attach(
window: Window,
mediaPlayerHandler: MediaPlayerHandler,
coroutineScope: CoroutineScope,
): () -> Unit {
if (!System.getProperty("os.name", "").contains("Windows", ignoreCase = true)) {
return {}
}
return try {
val hwndPointer = Native.getWindowPointer(window) ?: return {}
val hwnd = WinDef.HWND(hwndPointer)
Ole32.INSTANCE.CoInitializeEx(null, Ole32.COINIT_APARTMENTTHREADED)
val pTaskbar = PointerByReference()
val hr = Ole32.INSTANCE.CoCreateInstance(
CLSID_TaskbarList,
null,
1, // CLSCTX_INPROC_SERVER
IID_ITaskbarList3,
pTaskbar,
)
if (hr.toInt() != 0 || pTaskbar.value == null) {
Logger.w(TAG, "CoCreateInstance(ITaskbarList3) failed: hr=$hr")
return {}
}
val taskbar = TaskbarList3(pTaskbar.value)
taskbar.hrInit()
val prevIcon = createGlyphIcon { g ->
g.fillRect(3, 4, 3, 12)
g.fillPolygon(intArrayOf(16, 7, 16), intArrayOf(4, 10, 16), 3)
}
val playIcon = createGlyphIcon { g ->
g.fillPolygon(intArrayOf(5, 16, 5), intArrayOf(4, 10, 16), 3)
}
val pauseIcon = createGlyphIcon { g ->
g.fillRect(5, 4, 3, 12)
g.fillRect(12, 4, 3, 12)
}
val nextIcon = createGlyphIcon { g ->
g.fillPolygon(intArrayOf(4, 13, 4), intArrayOf(4, 10, 16), 3)
g.fillRect(14, 4, 3, 12)
}
val template = THUMBBUTTON()
@Suppress("UNCHECKED_CAST")
val buttons = template.toArray(3) as Array<THUMBBUTTON>
// Button 0: Prev
buttons[0].dwMask = THB_ICON or THB_TOOLTIP or THB_FLAGS
buttons[0].iId = ID_PREV
buttons[0].hIcon = prevIcon
buttons[0].setTooltip("Previous")
buttons[0].dwFlags = THBF_ENABLED
// Button 1: Play/Pause
buttons[1].dwMask = THB_ICON or THB_TOOLTIP or THB_FLAGS
buttons[1].iId = ID_PLAY_PAUSE
buttons[1].hIcon = playIcon
buttons[1].setTooltip("Play")
buttons[1].dwFlags = THBF_ENABLED
// Button 2: Next
buttons[2].dwMask = THB_ICON or THB_TOOLTIP or THB_FLAGS
buttons[2].iId = ID_NEXT
buttons[2].hIcon = nextIcon
buttons[2].setTooltip("Next")
buttons[2].dwFlags = THBF_ENABLED
fun addButtons() {
buttons[0].write()
buttons[1].write()
buttons[2].write()
val res = taskbar.thumbBarAddButtons(hwnd, 3, buttons[0].pointer)
Logger.d(TAG, "thumbBarAddButtons result: $res")
}
fun updateButtons(isPlaying: Boolean, hasPrev: Boolean, hasNext: Boolean) {
buttons[0].dwFlags = if (hasPrev) THBF_ENABLED else THBF_DISABLED
buttons[1].hIcon = if (isPlaying) pauseIcon else playIcon
buttons[1].setTooltip(if (isPlaying) "Pause" else "Play")
buttons[1].dwFlags = THBF_ENABLED
buttons[2].dwFlags = if (hasNext) THBF_ENABLED else THBF_DISABLED
buttons[0].write()
buttons[1].write()
buttons[2].write()
taskbar.thumbBarUpdateButtons(hwnd, 3, buttons[0].pointer)
}
addButtons()
val taskbarCreatedMsg = CustomUser32.INSTANCE.RegisterWindowMessageW(WString("TaskbarButtonCreated"))
var oldWndProc: Pointer? = null
val wndProc = object : WindowProcCallback {
override fun callback(
hwnd: WinDef.HWND,
uMsg: Int,
wParam: WinDef.WPARAM,
lParam: WinDef.LPARAM,
): WinDef.LRESULT {
if (uMsg == taskbarCreatedMsg) {
addButtons()
} else if (uMsg == WM_COMMAND) {
val hiWord = (wParam.toLong() shr 16) and 0xFFFF
if (hiWord == THBN_CLICKED.toLong()) {
val id = (wParam.toLong() and 0xFFFF).toInt()
when (id) {
ID_PREV -> coroutineScope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.Previous) }
ID_PLAY_PAUSE -> coroutineScope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.PlayPause) }
ID_NEXT -> coroutineScope.launch { mediaPlayerHandler.onPlayerEvent(PlayerEvent.Next) }
}
return WinDef.LRESULT(0)
}
}
val prev = oldWndProc
return if (prev != null) {
CustomUser32.INSTANCE.CallWindowProc(prev, hwnd, uMsg, wParam, lParam)
} else {
User32.INSTANCE.DefWindowProc(hwnd, uMsg, wParam, lParam)
}
}
}
oldWndProc = CustomUser32.INSTANCE.SetWindowLongPtr(hwnd, GWLP_WNDPROC, wndProc)
val stateJob = coroutineScope.launch {
mediaPlayerHandler.controlState.collectLatest { state ->
updateButtons(
isPlaying = state.isPlaying,
hasPrev = state.isPreviousAvailable,
hasNext = state.isNextAvailable,
)
}
}
val cleanup: () -> Unit = {
stateJob.cancel()
val prev = oldWndProc
if (prev != null) {
CustomUser32.INSTANCE.SetWindowLongPtr(hwnd, GWLP_WNDPROC, prev)
}
prevIcon?.let { CustomUser32.INSTANCE.DestroyIcon(it) }
playIcon?.let { CustomUser32.INSTANCE.DestroyIcon(it) }
pauseIcon?.let { CustomUser32.INSTANCE.DestroyIcon(it) }
nextIcon?.let { CustomUser32.INSTANCE.DestroyIcon(it) }
taskbar.release()
}
cleanup
} catch (e: Throwable) {
Logger.e(TAG, "Failed to initialize WindowsTaskbarThumbnailController: ${e.message}")
val noop: () -> Unit = {}
noop
}
}
private fun createGlyphIcon(draw: (Graphics2D) -> Unit): WinDef.HICON? {
val size = 20
val img = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB)
val g2 = img.createGraphics()
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = Color.WHITE
draw(g2)
} finally {
g2.dispose()
}
val pixels = IntArray(size * size)
img.getRGB(0, 0, size, size, pixels, 0, size)
val colorBmp = CustomGdi32.INSTANCE.CreateBitmap(size, size, 1, 32, pixels) ?: return null
val maskBytes = ByteArray(((size + 15) / 16) * 2 * size) // empty monochrome mask
val maskBmp = CustomGdi32.INSTANCE.CreateBitmap(size, size, 1, 1, maskBytes)
if (maskBmp == null) {
CustomGdi32.INSTANCE.DeleteObject(colorBmp)
return null
}
val iconInfo = WinGDI.ICONINFO().apply {
fIcon = true
xHotspot = 0
yHotspot = 0
hbmMask = maskBmp
hbmColor = colorBmp
}
val hIcon = CustomUser32.INSTANCE.CreateIconIndirect(iconInfo)
CustomGdi32.INSTANCE.DeleteObject(colorBmp)
CustomGdi32.INSTANCE.DeleteObject(maskBmp)
return hIcon
}
}

2
core

@ -1 +1 @@
Subproject commit 1e7b925469d5190dea9d67d0f073deb1a1ff10fc
Subproject commit 561bd98bad94943cfc7a2d51571e74806e169fcd

View file

@ -63,6 +63,7 @@ include(
":listenTogether",
":loginSync",
":simpConnect",
":sync",
)
// core modules
@ -81,6 +82,7 @@ project(":kizzy").projectDir = File(serviceDir, "kizzy")
project(":listenTogether").projectDir = File(serviceDir, "listenTogether")
project(":loginSync").projectDir = File(serviceDir, "loginSync")
project(":simpConnect").projectDir = File(serviceDir, "simpConnect")
project(":sync").projectDir = File(serviceDir, "sync")
// media modules
project(":media-jvm").projectDir = File(mediaDir, "media-jvm")