From aee8a717f7fcd843433202653820a9121b41a42a Mon Sep 17 00:00:00 2001 From: laolu Date: Thu, 23 Jul 2026 11:34:22 -0400 Subject: [PATCH 01/47] Add Queue Button To Mini Player Added the queue button that features on the NowPlayingScreen to the persistent miniplayer at the bottom of the screen. --- .../maxrave/simpmusic/ui/screen/MiniPlayer.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt index d09f96111..d76703ce3 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt @@ -42,6 +42,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.VolumeOff import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.filled.VolumeOff import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.outlined.OpenInNew @@ -111,6 +112,7 @@ import com.maxrave.simpmusic.ui.component.ExplicitBadge import com.maxrave.simpmusic.ui.component.HeartCheckBox import com.maxrave.simpmusic.ui.component.PlayPauseButton import com.maxrave.simpmusic.ui.component.PlayerControlLayout +import com.maxrave.simpmusic.ui.component.QueueBottomSheet import com.maxrave.simpmusic.ui.component.liquidGlass import com.maxrave.simpmusic.ui.theme.LocalIsDarkTheme import com.maxrave.simpmusic.ui.theme.typo @@ -217,6 +219,7 @@ fun MiniPlayer( mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() val animatedProgress by animateFloatAsState( @@ -566,6 +569,9 @@ fun MiniPlayer( var sliderValue by rememberSaveable { mutableFloatStateOf(0f) } + var showQueueBottomSheet by rememberSaveable { + mutableStateOf(false) + } LaunchedEffect(key1 = timelineState, key2 = isSliding) { if (!isSliding) { sliderValue = @@ -576,6 +582,15 @@ fun MiniPlayer( } } } + + if (showQueueBottomSheet) { + QueueBottomSheet( + onDismiss = { + showQueueBottomSheet = false + }, + ) + } + Box( modifier.then( Modifier.clickable { @@ -832,6 +847,24 @@ fun MiniPlayer( sharedViewModel.onUIEvent(UIEvent.ToggleLike) } Spacer(Modifier.width(4.dp)) + // Queue Button + IconButton( + modifier = + Modifier + .width(32.dp) + .aspectRatio(1f) + .clip(CircleShape), + onClick = { + showQueueBottomSheet = true + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.QueueMusic, + tint = Color.White, + contentDescription = "", + ) + } + Spacer(Modifier.width(2.dp)) // Desktop mini player button (JVM only) if (getPlatform() == Platform.Desktop) { IconButton(onClick = { toggleMiniPlayer() }) { From 86e39c5717522a92654044963f3607d1cc208577 Mon Sep 17 00:00:00 2001 From: laolu Date: Thu, 23 Jul 2026 11:34:22 -0400 Subject: [PATCH 02/47] Add Queue Button To Mini Player Added the queue button that features on the NowPlayingScreen to the persistent miniplayer at the bottom of the screen. --- .../maxrave/simpmusic/ui/screen/MiniPlayer.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt index d09f96111..de3af26b4 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt @@ -42,6 +42,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.VolumeOff import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.filled.VolumeOff import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.outlined.OpenInNew @@ -111,6 +112,7 @@ import com.maxrave.simpmusic.ui.component.ExplicitBadge import com.maxrave.simpmusic.ui.component.HeartCheckBox import com.maxrave.simpmusic.ui.component.PlayPauseButton import com.maxrave.simpmusic.ui.component.PlayerControlLayout +import com.maxrave.simpmusic.ui.component.QueueBottomSheet import com.maxrave.simpmusic.ui.component.liquidGlass import com.maxrave.simpmusic.ui.theme.LocalIsDarkTheme import com.maxrave.simpmusic.ui.theme.typo @@ -217,6 +219,7 @@ fun MiniPlayer( mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() val animatedProgress by animateFloatAsState( @@ -566,6 +569,9 @@ fun MiniPlayer( var sliderValue by rememberSaveable { mutableFloatStateOf(0f) } + var showQueueBottomSheet by rememberSaveable { + mutableStateOf(false) + } LaunchedEffect(key1 = timelineState, key2 = isSliding) { if (!isSliding) { sliderValue = @@ -576,6 +582,13 @@ fun MiniPlayer( } } } + if (showQueueBottomSheet) { + QueueBottomSheet( + onDismiss = { + showQueueBottomSheet = false + }, + ) + } Box( modifier.then( Modifier.clickable { @@ -832,6 +845,24 @@ fun MiniPlayer( sharedViewModel.onUIEvent(UIEvent.ToggleLike) } Spacer(Modifier.width(4.dp)) + // Queue Button + IconButton( + modifier = + Modifier + .width(32.dp) + .aspectRatio(1f) + .clip(CircleShape), + onClick = { + showQueueBottomSheet = true + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.QueueMusic, + tint = Color.White, + contentDescription = "", + ) + } + Spacer(Modifier.width(2.dp)) // Desktop mini player button (JVM only) if (getPlatform() == Platform.Desktop) { IconButton(onClick = { toggleMiniPlayer() }) { From e979337d755d584f9472fe773879e25bfa057c6b Mon Sep 17 00:00:00 2001 From: laolu Date: Thu, 23 Jul 2026 13:43:11 -0400 Subject: [PATCH 03/47] Fix queue button sizing Remove modifier so it follows pattern of other icons --- .../kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt index 4dda60a5d..8cfd59298 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt @@ -846,14 +846,9 @@ fun MiniPlayer( Spacer(Modifier.width(4.dp)) // Queue Button IconButton( - modifier = - Modifier - .width(32.dp) - .aspectRatio(1f) - .clip(CircleShape), onClick = { showQueueBottomSheet = true - }, + } ) { Icon( imageVector = Icons.AutoMirrored.Rounded.QueueMusic, From bb55681587547d81546218814b105e5c6f4cd6bc Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 25 Jul 2026 00:20:11 +0700 Subject: [PATCH 04/47] feat(player): add inline lyric line and remove blur backgrounds --- .../composeResources/values-az/strings.xml | 4 - .../composeResources/values-bg/strings.xml | 4 - .../composeResources/values-ca/strings.xml | 4 - .../composeResources/values-de/strings.xml | 4 - .../composeResources/values-es/strings.xml | 4 - .../composeResources/values-fa/strings.xml | 4 - .../composeResources/values-fr/strings.xml | 4 - .../composeResources/values-hi/strings.xml | 4 - .../composeResources/values-id/strings.xml | 4 - .../composeResources/values-it/strings.xml | 4 - .../composeResources/values-ja/strings.xml | 4 - .../composeResources/values-ko/strings.xml | 4 - .../composeResources/values-pl/strings.xml | 4 - .../composeResources/values-pt/strings.xml | 4 - .../composeResources/values-ru/strings.xml | 4 - .../composeResources/values-th/strings.xml | 4 - .../composeResources/values-tr/strings.xml | 4 - .../composeResources/values-uk/strings.xml | 4 - .../composeResources/values-vi/strings.xml | 4 - .../values-zh-rCN/strings.xml | 4 - .../values-zh-rTW/strings.xml | 4 - .../composeResources/values/strings.xml | 4 - .../simpmusic/ui/component/LyricsView.kt | 240 +++++++---------- .../simpmusic/ui/screen/home/SettingScreen.kt | 20 +- .../ui/screen/player/NowPlayingScreen.kt | 247 ++++++++---------- .../simpmusic/viewModel/SettingsViewModel.kt | 36 --- .../simpmusic/viewModel/SharedViewModel.kt | 11 - core | 2 +- 28 files changed, 201 insertions(+), 443 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-az/strings.xml b/composeApp/src/commonMain/composeResources/values-az/strings.xml index a3fe1205c..f4e1d6b58 100644 --- a/composeApp/src/commonMain/composeResources/values-az/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-az/strings.xml @@ -366,13 +366,9 @@ Təkrar göstərmə Yeniləmə üçün avtomatik yoxla Tətbiqi açdığınızda yeniləmə yoxlanılır - Tam ekran lirika effektini tutqunlaşdır - Tam ekran lirika ekran effekti arxa planı, tutqunlaşır Səslənmə sürəti Ucalıq - Səsləndirici arxa planın tutqunlaşdır - İndi səslənən ekran effekti fonunu tutqunlaşdır Səs və videonun birləşdirilməsi Xəta baş verdi Səs endirilir: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-bg/strings.xml b/composeApp/src/commonMain/composeResources/values-bg/strings.xml index 0559b6592..7f11b1462 100644 --- a/composeApp/src/commonMain/composeResources/values-bg/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bg/strings.xml @@ -385,13 +385,9 @@ Не показвай отново Автоматична проверка за актуализации Проверка за актуализации когато отворите приложението - Ефект на замъгляване на текстовете в цял екран - Замъгляване на фона на текстовете в цял екран Скорост на възпроизвеждане Тон - Замъгли на фона на плейъра - Замъгляване на фона на плейъра Смесване на звук и видео Възникна грешка Сваляне на аудио: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-ca/strings.xml b/composeApp/src/commonMain/composeResources/values-ca/strings.xml index bf644da30..999e10f6f 100644 --- a/composeApp/src/commonMain/composeResources/values-ca/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ca/strings.xml @@ -395,14 +395,10 @@ No ho tornis a mostrar Busca actualitzacions automàticament Busca actualitzacions cada cop que l'aplicació s'obre - Efecte de desenfocament amb la lletra a pantalla completa - Effecte de desenfoc del fons de la pantalla de lletra de la cançó a pantalla completa Velocitat de reproducció To - Desenfoca el fons del reproductor - Efecte de desenfocament del fons de la pantalla \"Reproduint\" Fusionant àudio i vídeo S'ha produït un error Descarregant àudio: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 132360720..c12fda416 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -386,14 +386,10 @@ Nicht erneut anzeigen Automatisches Überprüfen auf Updates Überprüfung auf Updates beim App Start - Verwischen Sie den Texteffekt im Vollbildmodus - Den Hintergrund des Vollbild-Textbildschirmeffekts unscharf machen Wiedergabegeschwindigkeit Tonhöhe - Player Hintergrund verwischen - Verwischt den Hintergrund des ''spielt gerade'' Bildschirmeffekts Audio und Video zusammenfügen Es ist ein Fehler aufgetreten Audio wird heruntergeladen: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index a313165f7..168d09e39 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -395,14 +395,10 @@ No mostrar de nuevo Comprobar actualizaciones automáticamente Comprobando actualizaciones al abrir la aplicación - Efecto de desenfoque de letra a pantalla completa - Efecto de desenfoque del fondo de la pantalla de letras en pantalla completa Velocidad de reproducción Tono - Desenfocar el fondo del reproductor - Efecto de desenfoque del fondo de la pantalla de reproducción actual Combinando audio y vídeo Ocurrió un error Descargando audio: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml index 1ff2dcc7c..6b6f2ddca 100644 --- a/composeApp/src/commonMain/composeResources/values-fa/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml @@ -392,13 +392,9 @@ دوباره نشان نده بررسی خودکار بروزرسانی بررسی به روز رسانی هنگام باز کردن برنامه - افکت محو کردن متن آهنگ در حالت تمام صفحه - محو کردن پس‌زمینه‌ی صفحه‌ی کامل متن آهنگ سرعت پخش گام صدا - تار کردن پس‌زمینه‌ پخش کننده - تار کردن پس‌زمینه‌ی صفحه‌ی در حال پخش ادغام صدا و ویدیو خطایی رخ داد در حال دانلود صدا: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index b1d74a43e..7cf7ba492 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -395,14 +395,10 @@ Ne plus afficher Vérification automatique des mises à jour Vérification des mises à jour lorsque vous ouvrez l'application - Effet de flou sur les paroles en plein écran - Flouter l'arrière-plan de l'effet d'écran des paroles en plein écran Vitesse de lecture Hauteur - Flouter l'arrière-plan du lecteur - Effet de floutage de l'arrière-plan de l'écran en cours de lecture Fusionner l'audio et la vidéo Une erreur s'est produite Téléchargement audio : %1$s diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml index 3d2cf4663..5b502127a 100644 --- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -382,13 +382,9 @@ दोबारा न दिखाएं अपडेट की स्वतः जाँच ऐप खोलते समय अपडेट की जाँच करना - पूर्ण स्क्रीन गीत प्रभाव को धुंधला करें - पूर्ण स्क्रीन गीत प्रभाव को धुंधला किया जा रहा है प्लेबैक गति पिच - प्लेयर बैकग्राउंड धुंधला करें - नाओ प्लेयिंग स्क्रीन प्रभाव की पृष्ठभूमि को धुंधला किया गया ऑडियो और वीडियो का संयोजन त्रुटि हुई ऑडियो डाउनलोड हो रहा है: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index 1dbf0e69a..31f787936 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -394,14 +394,10 @@ Jangan tampilkan lagi Pemeriksaan pembaruan otomatis Memeriksa pembaruan saat Anda membuka aplikasi - Buramkan efek lirik layar penuh - Efek memburamkan latar belakang layar lirik Layar Penuh Kecepatan pemutaran Nada - Buramkan latar belakang pemutar - Efek memburamkan latar belakang layar yang sedang diputar Menggabungkan audio dan video Terjadi kesalahan Mengunduh audio: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index c69ae0256..7c5e1e4a3 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -395,14 +395,10 @@ Non mostrare più Controllo automatico degli aggiornamenti Controllo degli aggiornamenti all'apertura dell'app - Sfoca effetto testo a schermo intero - Sfoca lo sfondo dell'effetto schermo intero del testo Velocità di riproduzione Tono - Sfoca lo sfondo del lettore - Sfoca lo sfondo dell'effetto della schermata di riproduzione Unione di audio e video C'è stato un problema Download dell'audio in corso: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index 713e9e4b1..0e5784bfa 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -379,13 +379,9 @@ 再び表示しない 最新版を自動で確認 アプリを開いた時に更新を確認します - 全画面の歌詞でぼかす効果 - 全画面の歌詞画面の背景をぼかす 再生速度 ピッチ - プレーヤーの背景をぼかす - 再生中の画面の背景をぼかす 音声と動画を統合中 エラーが発生しました 音声をダウンロード中: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index a6c9afd05..3293e2200 100644 --- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -394,14 +394,10 @@ 다시 보지 않기 업데이트 자동 확인 앱을 열 때 업데이트 유무 확인 - 전체화면 가사 배경 흐리게 - 전체화면 가사의 배경을 흐리게 처리 재생 속도 음정 - 플레이어 배경 흐리게 - 재생 화면의 배경을 흐리게 처리 오디오와 동영상 병합 중 오류 발생함 오디오 다운로드 중: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 7e652afb4..873fcb913 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -386,13 +386,9 @@ Nie pokazuj ponownie Automatyczne sprawdzanie aktualizacji Sprawdzanie aktualizacji po otwarciu aplikacji - Efekt rozmycia tekstu piosenki na pełnym ekranie - Rozmycie tła efektu pełnoekranowego ekranu tekstu Szybkość odtwarzania Tonacja - Rozmyj tło odtwarzacza - Rozmycie tła efektu pełnoekranowego ekranu obecnie odtwarzanego utworu Scalanie audio i wideo Wystąpił błąd Pobieranie audio: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 834ff1dfa..b682d8e49 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -395,14 +395,10 @@ Não mostrar novamente Verificar atualizações automaticamente Procurar atualizações quando abre o aplicativo - Desfocar o efeito letras em tela cheia - Desfocando o fundo do efeito de tela em letras em tela cheia Velocidade de reprodução Tonalidade - Desfocar fundo do ‘player’ - Desfocando o fundo do efeito de tela do tocando agora Mesclando áudio e vídeo Ocorreu um erro Baixando áudio: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index df6222675..a17540421 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -397,14 +397,10 @@ Больше не показывать Автоматически проверять обновления Проверьте наличие обновлений при открытии приложения - Эффект размытия экрана текста песен - Размытие фона в полноэкранном просмотре текстов песен Скорость воспроизведения Тон - Размыть фон плеера - Эффект размытия фона текущего экрана воспроизведения Объединение аудио и видео Произошла ошибка Загрузка аудио: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml index 2e922de31..21ebfd781 100644 --- a/composeApp/src/commonMain/composeResources/values-th/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -396,14 +396,10 @@ อย่าแสดงอีก ตรวจสอบการอัปเดตอัตโนมัติ ตรวจสอบการอัปเดตทุกครั้งที่คุณเปิดแอป - เอฟเฟกต์เบลอในหน้าจอเนื้อเพลงเต็มจอ - เอฟเฟกต์เบลอพื้นหลังหน้าจอเมื่อเปิดเนื้อเพลงแบบเต็มจอ ความเร็วในการเล่น ความสูง-ต่ำของเสียง - เบลอพื้นหลังของตัวเล่น - การเบลอพื้นหลังของเอฟเฟกต์หน้าจอขณะเล่น รวมเสียงและวิดีโอ เกิดข้อผิดพลาด กำลังดาวน์โหลดเสียง:%1$s diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 7f6841257..9cb522ede 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -388,14 +388,10 @@ Tekrar gösterme Güncellemeleri otomatik denetle Uygulamayı açtığında güncellemeleri denetle - Tam ekran bulanık şarkı sözü efekti - Tam Ekran şarkı sözleri ekran efektinin arka planını bulanıklaştır Oynatma hızı Pitch - Oynatıcı arka planını bulanıklaştır - Şu anda oynatılan ekran efektinin arka planını bulanıklaştır Ses ve videoyu birleştir Hata oluştu Ses indiriliyor: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml index ad8f85430..5cc1463ed 100644 --- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -397,14 +397,10 @@ Більше не показувати Автоматично перевіряти оновлення Перевірка наявності оновлень, коли ви відкриєте застосунок - Ефект розмиття екрана тексту пісень - Розмиття фону в повноекранному перегляді текстів пісень Швидкість відтворення Тон - Розмити фон плеєра - Розмиття фону екрана, що зараз відтворюється Об'єднання аудіо та відео Сталася помилка Завантаження аудіо: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index d24b97d4f..acde4042b 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -394,14 +394,10 @@ Không hiển thị lại Kiểm tra cập nhật tự động Kiểm tra cập nhật mỗi khi bạn mở ứng dụng - Làm mờ màn hình lời bài hát toàn màn hình - Hiệu ứng làm mờ nền màn hình lời bài hát toàn màn hình Tốc độ phát lại Cao độ - Làm mờ nền trình phát - Hiệu ứng làm mờ nền màn hình đang phát Đang ghép tệp âm thanh vào video Đã xảy ra lỗi Đang tải xuống âm thanh: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-zh-rCN/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rCN/strings.xml index 48ba7e5e6..9376f1ffd 100644 --- a/composeApp/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -394,14 +394,10 @@ 不再显示 自动检查更新 当您打开应用程序时检查更新 - 模糊全屏歌词特效 - 模糊全屏歌词界面的背景效果 播放速度 音调 - 播放界面背景模糊 - “正在播放”界面的背景模糊效果 正在合并音频和视频 出错了 正在下载音频: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml index ad0f2fbc3..cc94d43f5 100644 --- a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -394,14 +394,10 @@ 以後不再提示 自動檢查更新 啓動時檢查更新 - 模糊全螢幕歌詞 - 模糊全螢幕歌詞畫面的背景 回放速度 音調 - 模糊播放器背景 - 模糊正在播放畫面的背景 音訊與視訊合併 發生錯誤 下載音頻中...%1$s diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 402562921..e0ef7b20d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -410,14 +410,10 @@ Do not show again Automatic check for update Checking for update when you open app - Blur fullscreen lyrics effect - Blurring the background of the Fullscreen lyrics screen effect Playback speed Pitch - Blur player background - Blurring the background of the now playing screen effect Merging audio and video Error occurred Downloading audio: %1$s diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt index 5966ff252..49c260931 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt @@ -103,12 +103,6 @@ import com.maxrave.simpmusic.ui.theme.typo import com.maxrave.simpmusic.viewModel.NowPlayingScreenData import com.maxrave.simpmusic.viewModel.SharedViewModel import com.maxrave.simpmusic.viewModel.UIEvent -import dev.chrisbanes.haze.HazeTint -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.CupertinoMaterials -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -248,7 +242,6 @@ fun LyricsView( modifier: Modifier = Modifier, showScrollShadows: Boolean = false, backgroundColor: Color = Color(0xFF242424), - hasBlurBackground: Boolean = false, ) { val listState = rememberLazyListState() val current by timeLine.collectAsStateWithLifecycle() @@ -568,7 +561,6 @@ private fun AnimatedWord( } } -@OptIn(ExperimentalHazeMaterialsApi::class) @ExperimentalMaterial3Api @ExperimentalFoundationApi @Composable @@ -576,7 +568,6 @@ fun FullscreenLyricsSheet( sharedViewModel: SharedViewModel, navController: NavController, color: Color = Color(0xFF242424), - shouldHaze: Boolean, onDismiss: () -> Unit, ) { val screenDataState by sharedViewModel.nowPlayingScreenData.collectAsStateWithLifecycle() @@ -612,79 +603,67 @@ fun FullscreenLyricsSheet( // Dynamic gradient animation - MULTIPLE DIRECTIONS // Replaces the previous `while(true) { delay(16) }` loop with a Compose - // infinite transition. When haze is ON the gradient is hidden, so we keep - // values at 0f and skip the transition entirely. - val gradientAngle: Float - val gradientOffsetX: Float - val gradientOffsetY: Float - if (!shouldHaze) { - val gradientTransition = rememberInfiniteTransition(label = "lyricsGradient") - val animatedAngle by gradientTransition.animateFloat( - initialValue = -45f, - targetValue = 45f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 6000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "lyricsGradientAngle", - ) - val animatedOffsetX by gradientTransition.animateFloat( - initialValue = -1500f, - targetValue = 1500f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 8000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "lyricsGradientOffsetX", - ) - val animatedOffsetY by gradientTransition.animateFloat( - initialValue = -1000f, - targetValue = 1000f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 8000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "lyricsGradientOffsetY", - ) - gradientAngle = animatedAngle - gradientOffsetX = animatedOffsetX - gradientOffsetY = animatedOffsetY - } else { - gradientAngle = 0f - gradientOffsetX = 0f - gradientOffsetY = 0f - } + // infinite transition. + val gradientTransition = rememberInfiniteTransition(label = "lyricsGradient") + val animatedAngle by gradientTransition.animateFloat( + initialValue = -45f, + targetValue = 45f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 6000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "lyricsGradientAngle", + ) + val animatedOffsetX by gradientTransition.animateFloat( + initialValue = -1500f, + targetValue = 1500f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "lyricsGradientOffsetX", + ) + val animatedOffsetY by gradientTransition.animateFloat( + initialValue = -1000f, + targetValue = 1000f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "lyricsGradientOffsetY", + ) + val gradientAngle = animatedAngle + val gradientOffsetX = animatedOffsetX + val gradientOffsetY = animatedOffsetY - // Smooth color animation based on lyrics color — only when haze is OFF - LaunchedEffect(color, shouldHaze) { - if (!shouldHaze) { - launch { - startColor.animateTo( - targetValue = color, - animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), - ) - } - launch { - midColor1.animateTo( - targetValue = color.copy(alpha = 0.95f), - animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), - ) - } - launch { - midColor2.animateTo( - targetValue = color.copy(alpha = 0.85f), - animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), - ) - } - launch { - endColor.animateTo( - targetValue = Color.Black, - animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), - ) - } + // Smooth color animation based on lyrics color + LaunchedEffect(color) { + launch { + startColor.animateTo( + targetValue = color, + animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), + ) + } + launch { + midColor1.animateTo( + targetValue = color.copy(alpha = 0.95f), + animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), + ) + } + launch { + midColor2.animateTo( + targetValue = color.copy(alpha = 0.85f), + animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), + ) + } + launch { + endColor.animateTo( + targetValue = Color.Black, + animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), + ) } } @@ -758,83 +737,41 @@ fun FullscreenLyricsSheet( label = "sliderCrossfadeColor", ) Box(modifier = Modifier.fillMaxSize()) { - // ── Haze state (used only when shouldHaze = true) ───────────────── - val hazeState = rememberHazeState(blurEnabled = true) - - if (shouldHaze) { - // Full-screen album art as haze SOURCE — blurred poster background - Box( - modifier = - Modifier - .fillMaxSize() - .hazeSource(hazeState), - ) { - AsyncImage( - model = - ImageRequest - .Builder(LocalPlatformContext.current) - .data(screenDataState.thumbnailURL) - .crossfade(300) - .diskCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(screenDataState.thumbnailURL) - .build(), - contentDescription = null, - contentScale = ContentScale.FillHeight, - modifier = Modifier.fillMaxSize(), - ) - } - } else { - // Animated gradient background — only shown when haze is OFF - Box( - modifier = - Modifier - .fillMaxSize() - .background( - Brush.linearGradient( - colors = - listOf( - startColor.value, - midColor1.value, - midColor2.value, - endColor.value.copy(alpha = 0.9f), - endColor.value, - ), - start = - Offset( - x = gradientOffsetX + (cos(gradientAngle * PI.toFloat() / 180f) * 800f), - y = gradientOffsetY + (sin(gradientAngle * PI.toFloat() / 180f) * 800f), - ), - end = - Offset( - x = gradientOffsetX + 2500f + (cos((gradientAngle + 180f) * PI.toFloat() / 180f) * 800f), - y = gradientOffsetY + 2500f + (sin((gradientAngle + 180f) * PI.toFloat() / 180f) * 800f), - ), - ), + // Animated gradient background + Box( + modifier = + Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = + listOf( + startColor.value, + midColor1.value, + midColor2.value, + endColor.value.copy(alpha = 0.9f), + endColor.value, + ), + start = + Offset( + x = gradientOffsetX + (cos(gradientAngle * PI.toFloat() / 180f) * 800f), + y = gradientOffsetY + (sin(gradientAngle * PI.toFloat() / 180f) * 800f), + ), + end = + Offset( + x = gradientOffsetX + 2500f + (cos((gradientAngle + 180f) * PI.toFloat() / 180f) * 800f), + y = gradientOffsetY + 2500f + (sin((gradientAngle + 180f) * PI.toFloat() / 180f) * 800f), + ), ), - ) - } + ), + ) // ── Foreground content column ───────────────────────────────────── Column( modifier = Modifier .fillMaxSize() - // Apply frosted glass haze effect over the poster when enabled - .then( - if (shouldHaze) { - Modifier.hazeEffect( - hazeState, - style = CupertinoMaterials.regular(), - ) { - blurEnabled = true - // Force-dark: the poster artwork can be bright, so pin a dark tint over - // the blur so the white lyrics stay readable in every app theme. - tints = listOf(HazeTint(Color.Black.copy(alpha = 0.5f))) - } - } else { - Modifier - }, - ).padding( + .padding( bottom = with(localDensity) { windowInsets.getBottom(localDensity).toDp() @@ -985,7 +922,6 @@ fun FullscreenLyricsSheet( modifier = Modifier.fillMaxSize(), showScrollShadows = true, backgroundColor = startColor.value, - hasBlurBackground = shouldHaze, ) } } else { diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt index c6e67ca29..18100c242 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt @@ -184,10 +184,6 @@ import simpmusic.composeapp.generated.resources.baseline_playlist_add_24 import simpmusic.composeapp.generated.resources.better_lyrics import simpmusic.composeapp.generated.resources.blog_notification_description import simpmusic.composeapp.generated.resources.blog_notification_title -import simpmusic.composeapp.generated.resources.blur_fullscreen_lyrics -import simpmusic.composeapp.generated.resources.blur_fullscreen_lyrics_description -import simpmusic.composeapp.generated.resources.blur_player_background -import simpmusic.composeapp.generated.resources.blur_player_background_description import simpmusic.composeapp.generated.resources.buy_me_a_coffee import simpmusic.composeapp.generated.resources.cancel import simpmusic.composeapp.generated.resources.canvas_info @@ -451,8 +447,6 @@ fun SettingScreen( val proxyUsername by viewModel.proxyUsername.collectAsStateWithLifecycle() val proxyPassword by viewModel.proxyPassword.collectAsStateWithLifecycle() val autoCheckUpdate by viewModel.autoCheckUpdate.collectAsStateWithLifecycle() - val blurFullscreenLyrics by viewModel.blurFullscreenLyrics.collectAsStateWithLifecycle() - val blurPlayerBackground by viewModel.blurPlayerBackground.collectAsStateWithLifecycle() val aiProvider by viewModel.aiProvider.collectAsStateWithLifecycle() val isHasApiKey by viewModel.isHasApiKey.collectAsStateWithLifecycle() val useAITranslation by viewModel.useAITranslation.collectAsStateWithLifecycle() @@ -614,18 +608,6 @@ fun SettingScreen( smallSubtitle = true, switch = (enableTranslucentNavBar to { viewModel.setTranslucentBottomBar(it) }), ) - SettingItem( - title = stringResource(Res.string.blur_fullscreen_lyrics), - subtitle = stringResource(Res.string.blur_fullscreen_lyrics_description), - smallSubtitle = true, - switch = (blurFullscreenLyrics to { viewModel.setBlurFullscreenLyrics(it) }), - ) - SettingItem( - title = stringResource(Res.string.blur_player_background), - subtitle = stringResource(Res.string.blur_player_background_description), - smallSubtitle = true, - switch = (blurPlayerBackground to { viewModel.setBlurPlayerBackground(it) }), - ) if (getPlatform() == Platform.Android) { SettingItem( title = stringResource(Res.string.enable_liquid_glass_effect), @@ -2723,7 +2705,7 @@ fun SettingScreen( }, containerColor = MaterialTheme.colorScheme.surface, dragHandle = {}, - scrimColor = Color.Black, + scrimColor = Color.Black.copy(alpha = .5f), sheetState = sheetState, contentWindowInsets = { WindowInsets(0, 0, 0, 0) }, shape = RectangleShape, diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt index 8910205ce..e761fc44f 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt @@ -35,7 +35,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -136,7 +135,6 @@ import com.maxrave.common.Config.MAIN_PLAYER import com.maxrave.domain.mediaservice.handler.MediaPlayerHandler import com.maxrave.domain.mediaservice.handler.RepeatState import com.maxrave.logger.Logger -import com.maxrave.simpmusic.ui.component.rememberHolderPainter import com.maxrave.simpmusic.Platform import com.maxrave.simpmusic.expect.toggleMiniPlayer import com.maxrave.simpmusic.expect.ui.MediaPlayerView @@ -167,6 +165,7 @@ import com.maxrave.simpmusic.ui.component.PlayPauseButton import com.maxrave.simpmusic.ui.component.PlayerControlLayout import com.maxrave.simpmusic.ui.component.QueueBottomSheet import com.maxrave.simpmusic.ui.component.VoteLyricsDialog +import com.maxrave.simpmusic.ui.component.rememberHolderPainter import com.maxrave.simpmusic.ui.navigation.destination.list.ArtistDestination import com.maxrave.simpmusic.ui.navigation.destination.player.FullscreenDestination import com.maxrave.simpmusic.ui.theme.blackMoreOverlay @@ -177,12 +176,6 @@ import com.maxrave.simpmusic.viewModel.NowPlayingBottomSheetUIEvent import com.maxrave.simpmusic.viewModel.NowPlayingBottomSheetViewModel import com.maxrave.simpmusic.viewModel.SharedViewModel import com.maxrave.simpmusic.viewModel.UIEvent -import dev.chrisbanes.haze.HazeTint -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.CupertinoMaterials -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged @@ -220,8 +213,16 @@ import kotlin.math.roundToLong private const val TAG = "NowPlayingScreen" private val RICH_SYNC_TIMESTAMP_REGEX = Regex("""<\d{2}:\d{2}\.\d{2,3}>\s*""") +private val WHITESPACE_REGEX = Regex("""\s+""") -@OptIn(ExperimentalFoundationApi::class, ExperimentalHazeMaterialsApi::class) +// Word-by-word lyrics carry a timestamp per word; replace each with a space +// (not ""), then collapse — otherwise the words run together. +private fun String.stripRichSyncTimestamps(): String = + replace(RICH_SYNC_TIMESTAMP_REGEX, " ") + .replace(WHITESPACE_REGEX, " ") + .trim() + +@OptIn(ExperimentalFoundationApi::class) @ExperimentalMaterial3Api @Composable fun NowPlayingScreen( @@ -251,7 +252,7 @@ fun NowPlayingScreen( }, containerColor = Color.Black, dragHandle = {}, - scrimColor = Color.Black, + scrimColor = Color.Black.copy(alpha = .5f), sheetState = sheetState, contentWindowInsets = { WindowInsets(0, 0, 0, 0) }, shape = RectangleShape, @@ -268,7 +269,7 @@ fun NowPlayingScreen( } } -@OptIn(ExperimentalFoundationApi::class, ExperimentalHazeMaterialsApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun NowPlayingScreenContent( sharedViewModel: SharedViewModel = koinInject(), @@ -449,8 +450,6 @@ fun NowPlayingScreenContent( mutableStateOf(Color.White) } - val blurBg by sharedViewModel.blurBg.collectAsStateWithLifecycle() - LaunchedEffect(screenDataState) { Logger.d(TAG, "ScreenDataState: $screenDataState") showHideMiddleLayout = screenDataState.canvasData == null @@ -610,7 +609,7 @@ fun NowPlayingScreenContent( mutableStateOf(false) } - var canvasSubtitleLineIndex by rememberSaveable { + var currentLyricLineIndex by rememberSaveable { mutableIntStateOf(-1) } @@ -625,7 +624,7 @@ fun NowPlayingScreenContent( LaunchedEffect(timelineState, screenDataState.lyricsData?.lyrics) { val lyrics = screenDataState.lyricsData?.lyrics if (lyrics == null || lyrics.syncType == "UNSYNCED" || lyrics.syncType == null) { - canvasSubtitleLineIndex = -1 + currentLyricLineIndex = -1 return@LaunchedEffect } val lines = lyrics.lines ?: return@LaunchedEffect @@ -644,16 +643,16 @@ fun NowPlayingScreenContent( startTimeMs + 60000 } if (timelineState.current in startTimeMs..endTimeMs) { - canvasSubtitleLineIndex = i + currentLyricLineIndex = i } } if (lines.isNotEmpty() && timelineState.current in 0..(lines.getOrNull(0)?.startTimeMs?.toLongOrNull() ?: 0L) ) { - canvasSubtitleLineIndex = -1 + currentLyricLineIndex = -1 } } else { - canvasSubtitleLineIndex = -1 + currentLyricLineIndex = -1 } } @@ -677,7 +676,6 @@ fun NowPlayingScreenContent( sharedViewModel = sharedViewModel, navController = navController, // <-- ADD THIS LINE color = startColor.value, - shouldHaze = sharedViewModel.blurFullscreenLyrics(), ) { showFullscreenLyrics = false } @@ -757,36 +755,10 @@ fun NowPlayingScreenContent( ) } - val hazeState = - rememberHazeState( - blurEnabled = true, - ) - if (screenDataState.lyricsData != null && controllerState.isPlaying) { KeepScreenOn() } Box { - if (blurBg && screenDataState.canvasData == null) { - AsyncImage( - model = - ImageRequest - .Builder(LocalPlatformContext.current) - .data(screenDataState.thumbnailURL) - .diskCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(screenDataState.thumbnailURL + "BIGGER") - .crossfade(550) - .build(), - contentDescription = "", - contentScale = ContentScale.FillHeight, - placeholder = rememberHolderPainter(), - error = rememberHolderPainter(), - modifier = - Modifier - .align(Alignment.Center) - .fillMaxSize() - .hazeSource(hazeState), - ) - } Column( Modifier .verticalScroll( @@ -798,31 +770,18 @@ fun NowPlayingScreenContent( // drags fall through to the Pager. .then( if (showHideMiddleLayout) { - if (blurBg && screenDataState.canvasData == null) { - Modifier - .background(Color.Transparent) - .hazeEffect(hazeState, style = CupertinoMaterials.thin()) { - blurEnabled = true - // The player must stay dark in every app theme. Haze samples the app - // content behind the sheet (white in light theme), so pin a black - // backdrop + tint — otherwise the blurred background washes out to white. - backgroundColor = Color.Black - tints = listOf(HazeTint(Color.Black.copy(alpha = 0.7f))) - } - } else { - Modifier - .background( - Brush.linearGradient( - colors = - listOf( - startColor.value, - endColor.value, - ), - start = gradientOffset.start, - end = gradientOffset.end, - ), - ) - } + Modifier + .background( + Brush.linearGradient( + colors = + listOf( + startColor.value, + endColor.value, + ), + start = gradientOffset.start, + end = gradientOffset.end, + ), + ) } else { Modifier.background(Color.Black) }, @@ -851,7 +810,7 @@ fun NowPlayingScreenContent( val isCurrentArtworkPage = page == currentOrderIndex val pageHasCanvas = isCurrentArtworkPage && screenDataState.canvasData != null - // Per-page palette state for the !blurBg gradient backdrop. + // Per-page palette state for the gradient backdrop. // The bitmap is fed in by Layer 2's adjacent-thumbnail AsyncImage // (onSuccess), so we use the SAME bitmap that's painted on screen — // matches the outer Column's palette extraction characteristics. @@ -897,60 +856,30 @@ fun NowPlayingScreenContent( ), ) { // ── Layer 0: per-page backdrop (adjacent pages only) ── - // Mirrors the outer Column's bg branching so the backdrop logic stays - // consistent during a swipe: - // - blurBg=true → dim track thumbnail (matches the haze look) - // - blurBg=false → palette gradient (startColor → endColor) so the - // adjacent page never falls back to a flat dark void - // when the user disables blur. + // Palette gradient (startColor → endColor) so the adjacent page never + // falls back to a flat dark void during a swipe. // The CURRENT page deliberately skips this layer so the existing - // hazeEffect blur / gradient / canvas on the Column stays visible. + // gradient / canvas on the Column stays visible. if (!isCurrentArtworkPage && pageTrack != null) { - if (blurBg) { - val backdropUrl = - pageTrack.thumbnails - ?.maxByOrNull { it.width * it.height } - ?.url - AsyncImage( - model = - ImageRequest - .Builder(LocalPlatformContext.current) - .data(backdropUrl) - .diskCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(backdropUrl) - .crossfade(300) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - placeholder = rememberHolderPainter(), - error = rememberHolderPainter(), - modifier = - Modifier - .fillMaxSize() - // Dim slightly so the centered thumbnail above stands out. - .alpha(0.35f), - ) - } else { - // Palette is fed by Layer 2's adjacent-thumbnail AsyncImage - // (see below) so the gradient color stays consistent with - // the bitmap actually painted for that page. - Box( - modifier = - Modifier - .fillMaxSize() - .background( - Brush.linearGradient( - colors = - listOf( - pageStartColor.value, - Color.Black, - ), - start = gradientOffset.start, - end = gradientOffset.end, - ), + // Palette is fed by Layer 2's adjacent-thumbnail AsyncImage + // (see below) so the gradient color stays consistent with + // the bitmap actually painted for that page. + Box( + modifier = + Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = + listOf( + pageStartColor.value, + Color.Black, + ), + start = gradientOffset.start, + end = gradientOffset.end, ), - ) - } + ), + ) } // ── Layer 1: fullscreen canvas backdrop (current track + canvas data) ── @@ -1347,15 +1276,16 @@ fun NowPlayingScreenContent( .value .toInt() } - }, + }.padding( + top = with(localDensity) { WindowInsets.statusBars.getTop(localDensity).toDp() }, + ), colors = TopAppBarDefaults.topAppBarColors().copy( containerColor = Color.Transparent, ), - windowInsets = - TopAppBarDefaults.windowInsets.only( - WindowInsetsSides.Top, - ), + // Position-aware insets shrink per frame while the sheet is dragged (pinned + // bar + layout jitter) — status-bar space is static padding on the modifier. + windowInsets = WindowInsets(0, 0, 0, 0), title = { Column( modifier = Modifier.fillMaxWidth(), @@ -1458,14 +1388,61 @@ fun NowPlayingScreenContent( }.aspectRatio(1f), ) - Spacer( + // Spotify-style current lyric line — vertically centered in the gap + // between the artwork and the info layout below. This Box replaces the + // plain gap Spacer at the same middleLayoutPaddingDp height, so the + // info layout position never moves; the line just lives inside the gap. + Box( + contentAlignment = Alignment.Center, modifier = Modifier .animateContentSize() .height( middleLayoutPaddingDp.dp, ).fillMaxWidth(), - ) + ) { + val inlineLyrics = screenDataState.lyricsData?.lyrics + val hasSyncedLyrics = + inlineLyrics != null && + inlineLyrics.syncType != null && + inlineLyrics.syncType != "UNSYNCED" && + inlineLyrics.lines != null + // Canvas mode has its own subtitle overlay — never show both. + val currentLyricLineText = + if (!hasSyncedLyrics || + screenDataState.canvasData != null || + currentLyricLineIndex < 0 + ) { + "" + } else { + inlineLyrics + ?.lines + ?.getOrNull(currentLyricLineIndex) + ?.words + ?.stripRichSyncTimestamps() + .orEmpty() + } + Crossfade( + targetState = currentLyricLineText, + animationSpec = tween(durationMillis = 300), + label = "inlineLyricLine", + ) { lineText -> + Text( + text = lineText, + style = typo().labelSmall, + color = Color.White, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + ) + } + } // Info Layout Box { @@ -1941,7 +1918,7 @@ fun NowPlayingScreenContent( .animateContentSize(), ) { this@Column.AnimatedVisibility( - visible = canvasSubtitleLineIndex > -1, + visible = currentLyricLineIndex > -1, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { @@ -1950,10 +1927,9 @@ fun NowPlayingScreenContent( screenDataState.lyricsData ?.lyrics ?.lines - ?.getOrNull(canvasSubtitleLineIndex) + ?.getOrNull(currentLyricLineIndex) ?.words - ?.replace(RICH_SYNC_TIMESTAMP_REGEX, "") - ?.trim() + ?.stripRichSyncTimestamps() if (!lineText.isNullOrBlank()) { Column( modifier = Modifier.fillMaxWidth(), @@ -1978,10 +1954,9 @@ fun NowPlayingScreenContent( ?.translatedLyrics ?.first ?.lines - ?.getOrNull(canvasSubtitleLineIndex) + ?.getOrNull(currentLyricLineIndex) ?.words - ?.replace(RICH_SYNC_TIMESTAMP_REGEX, "") - ?.trim() + ?.stripRichSyncTimestamps() if (!translatedLineText.isNullOrBlank()) { Text( modifier = diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt index cd7064c26..b382a58aa 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt @@ -121,10 +121,6 @@ class SettingsViewModel( val autoCheckUpdate: StateFlow = _autoCheckUpdate private var _updateChannel: MutableStateFlow = MutableStateFlow(DataStoreManager.GITHUB) val updateChannel: StateFlow = _updateChannel - private var _blurFullscreenLyrics = MutableStateFlow(false) - val blurFullscreenLyrics: StateFlow = _blurFullscreenLyrics - private var _blurPlayerBackground = MutableStateFlow(false) - val blurPlayerBackground: StateFlow = _blurPlayerBackground private val _aiProvider = MutableStateFlow(DataStoreManager.AI_PROVIDER_OPENAI) val aiProvider: StateFlow = _aiProvider private val _isHasApiKey = MutableStateFlow(false) @@ -260,8 +256,6 @@ class SettingsViewModel( getCanvasCache() getTranslucentBottomBar() getAutoCheckUpdate() - getBlurFullscreenLyrics() - getBlurPlayerBackground() getAIProvider() getAIApiKey() getAITranslation() @@ -728,36 +722,6 @@ class SettingsViewModel( } } - private fun getBlurFullscreenLyrics() { - viewModelScope.launch { - dataStoreManager.blurFullscreenLyrics.collect { blurFullscreenLyrics -> - _blurFullscreenLyrics.value = blurFullscreenLyrics == DataStoreManager.TRUE - } - } - } - - fun setBlurFullscreenLyrics(blurFullscreenLyrics: Boolean) { - viewModelScope.launch { - dataStoreManager.setBlurFullscreenLyrics(blurFullscreenLyrics) - getBlurFullscreenLyrics() - } - } - - private fun getBlurPlayerBackground() { - viewModelScope.launch { - dataStoreManager.blurPlayerBackground.collect { blurPlayerBackground -> - _blurPlayerBackground.value = blurPlayerBackground == DataStoreManager.TRUE - } - } - } - - fun setBlurPlayerBackground(blurPlayerBackground: Boolean) { - viewModelScope.launch { - dataStoreManager.setBlurPlayerBackground(blurPlayerBackground) - getBlurPlayerBackground() - } - } - private fun getAutoCheckUpdate() { viewModelScope.launch { dataStoreManager.autoCheckForUpdates.collect { autoCheckUpdate -> diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt index 45ffb5c9a..bdd6f8c23 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -163,15 +163,6 @@ class SharedViewModel( val castState: StateFlow get() = mediaPlayerHandler.castState - val blurBg: StateFlow = - dataStoreManager.blurPlayerBackground - .map { it == TRUE } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(500L), - initialValue = false, - ) - private var _controllerState = MutableStateFlow( ControlState( @@ -479,8 +470,6 @@ class SharedViewModel( } } - fun blurFullscreenLyrics(): Boolean = runBlocking { dataStoreManager.blurFullscreenLyrics.first() == TRUE } - private fun getLikeStatus(videoId: String?) { viewModelScope.launch { if (videoId != null) { diff --git a/core b/core index 63c9b8bde..7086d6a1c 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 63c9b8bdefe2b97758c5f62b253a49875613dae0 +Subproject commit 7086d6a1cef77431ec9d60a3cff168d366465b1a From 54fce50debfea7d585926a903c4d02ede1f9457d Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 25 Jul 2026 16:42:05 +0700 Subject: [PATCH 05/47] fix(spotify): match login status URL with query string --- .../maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt index d0dd892a8..3e83dcc13 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt @@ -172,7 +172,11 @@ fun SpotifyLoginScreen( } viewModel.setFullSpotifyCookies(cookies) } - val statusUrl = Regex("^https://accounts\\.spotify\\.com/(?:[^/]+/)?status$") + // Some login flows land on the status page with a query string appended + // (e.g. /en/status?flow_ctx=...). Anchoring right after `status` made those + // URLs fail the match silently, so sp_dc was never saved and auto-login + // appeared to do nothing. + val statusUrl = Regex("^https://accounts\\.spotify\\.com/(?:[^/]+/)?status(?:\\?.*)?$") if (statusUrl.matches(url)) { cookie .takeIf { From 8d1a23dbfbbf6d073185fce3cb378160f5e5f142 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 25 Jul 2026 16:42:05 +0700 Subject: [PATCH 06/47] feat(player): rework now playing backdrop and artist card --- .../ui/screen/player/NowPlayingScreen.kt | 177 +++++++++++------- 1 file changed, 114 insertions(+), 63 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt index e761fc44f..bba458250 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt @@ -106,7 +106,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape @@ -222,6 +224,12 @@ private fun String.stripRichSyncTimestamps(): String = .replace(WHITESPACE_REGEX, " ") .trim() +// Backdrop behind the player. A dark surface rather than pure black: #000000 reads as a hole +// next to the artwork-tinted gradient and cards, which is why Spotify sits its player on a +// near-black surface instead. Used for the gradient's end colour, the fade-to target and the +// area below the gradient so all three match exactly and leave no seam. +private val PlayerBackdropColor = Color(0xFF121212) + @OptIn(ExperimentalFoundationApi::class) @ExperimentalMaterial3Api @Composable @@ -466,7 +474,9 @@ fun NowPlayingScreenContent( .collectLatest { spotShadowColor = it.getColorFromPalette() startColor.animateTo(it.getColorFromPalette()) - endColor.animateTo(Color.Black) + // Lands on the same backdrop colour the fade and the area below the gradient + // use, so the palette ramp resolves into the surface instead of a black patch. + endColor.animateTo(PlayerBackdropColor) } } @@ -771,17 +781,44 @@ fun NowPlayingScreenContent( .then( if (showHideMiddleLayout) { Modifier - .background( - Brush.linearGradient( - colors = - listOf( - startColor.value, - endColor.value, + // The backdrop fills the whole scrollable content, then the gradient + // is drawn over just the first screen height. Using background() for + // the gradient instead would stretch it across the entire content, + // which is what made it run on forever while scrolling. + .background(PlayerBackdropColor) + .drawBehind { + val gradientHeight = screenInfo.hPX.toFloat() + val area = Size(size.width, gradientHeight) + // Palette gradient, keeping its diagonal angle. + drawRect( + brush = + Brush.linearGradient( + colors = + listOf( + startColor.value, + endColor.value, + ), + start = gradientOffset.start, + end = gradientOffset.end, ), - start = gradientOffset.start, - end = gradientOffset.end, - ), - ) + size = area, + ) + // Vertical fade to the backdrop colour, fully opaque from 90% + // down, so the bottom edge meets the area underneath seamlessly + // across the whole width. Adding the same colour as a stop to + // the diagonal gradient above could not do that — it would only + // arrive in one corner and leave a visible diagonal seam. + drawRect( + brush = + Brush.verticalGradient( + 0f to Color.Transparent, + 0.95f to PlayerBackdropColor, + startY = 0f, + endY = gradientHeight, + ), + size = area, + ) + } } else { Modifier.background(Color.Black) }, @@ -2298,65 +2335,79 @@ fun NowPlayingScreenContent( shape = RoundedCornerShape(8.dp), colors = CardDefaults.elevatedCardColors().copy( - containerColor = startColor.value, + containerColor = Color(0xFF212121), ), ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(250.dp), - ) { - val thumb = screenDataState.songInfoData?.authorThumbnail - AsyncImage( - model = - ImageRequest - .Builder(LocalPlatformContext.current) - .data(thumb) - .diskCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(thumb) - .crossfade(550) - .build(), - placeholder = rememberHolderPainter(isVideo = true), - error = rememberHolderPainter(isVideo = true), - contentDescription = null, - contentScale = ContentScale.Crop, + Column(modifier = Modifier.fillMaxWidth()) { + // Artwork occupies the top of the card; only the section + // label sits on top of it. Name and subscriber count moved + // below onto the solid card surface so they stay readable + // regardless of how bright the artist photo is. + Box( modifier = Modifier - .fillMaxSize() - .alpha(0.8f) - .clip( - RoundedCornerShape(8.dp), - ), - ) - Box( + .fillMaxWidth() + .height(250.dp), + ) { + val thumb = screenDataState.songInfoData?.authorThumbnail + AsyncImage( + model = + ImageRequest + .Builder(LocalPlatformContext.current) + .data(thumb) + .diskCachePolicy(CachePolicy.ENABLED) + .diskCacheKey(thumb) + .crossfade(550) + .build(), + placeholder = rememberHolderPainter(isVideo = true), + error = rememberHolderPainter(isVideo = true), + contentDescription = null, + contentScale = ContentScale.Crop, + // No explicit clip: the ElevatedCard already clips to + // its 8.dp shape, so only the card's top corners round + // and the image meets the panel below flush. + modifier = Modifier.fillMaxSize(), + ) + // Scrim behind the label: artist photos are often bright + // at the top, which swallowed the white text. + Box( + modifier = + Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.6f), + 0.4f to Color.Transparent, + ), + ), + ) + Text( + text = stringResource(Res.string.artists), + style = typo().labelMedium, + color = Color.White, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(15.dp), + ) + } + Column( modifier = Modifier - .padding(15.dp) - .fillMaxSize(), + .fillMaxWidth() + .padding(horizontal = 15.dp, vertical = 12.dp), ) { - Column(Modifier.align(Alignment.TopStart)) { - Spacer(modifier = Modifier.height(5.dp)) - Text( - text = stringResource(Res.string.artists), - style = typo().labelMedium, - color = Color.White, - ) - } - Column(Modifier.align(Alignment.BottomStart)) { - Text( - text = screenDataState.songInfoData?.author ?: "", - style = typo().labelMedium, - color = Color.White, - ) - Spacer(modifier = Modifier.height(5.dp)) - Text( - text = screenDataState.songInfoData?.subscribers ?: "", - style = typo().bodySmall, - textAlign = TextAlign.End, - ) - Spacer(modifier = Modifier.height(5.dp)) - } + Text( + text = screenDataState.songInfoData?.author ?: "", + style = typo().titleMedium, + color = Color.White, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = screenDataState.songInfoData?.subscribers ?: "", + style = typo().bodySmall, + color = Color.White.copy(alpha = 0.7f), + ) } } } From b5e88772841efad4354e1ef2da4ac2c17e8874e0 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 25 Jul 2026 16:42:06 +0700 Subject: [PATCH 07/47] chore(core): bump submodule --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 7086d6a1c..f9f1fb5e8 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 7086d6a1cef77431ec9d60a3cff168d366465b1a +Subproject commit f9f1fb5e8682220954707adc4e2b7ad77a394919 From a57a3c1bba3f6178244e6307eacdaddd0dfbb1d2 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Mon, 27 Jul 2026 17:05:38 +0700 Subject: [PATCH 08/47] chore(core): bump submodule for mpv desktop backend --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index f9f1fb5e8..9a4ff00a3 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit f9f1fb5e8682220954707adc4e2b7ad77a394919 +Subproject commit 9a4ff00a3280abf9d106f7f643071ca852a4600f From 160f9ff62b6a3a7e1ae1ce9bfcde45202fb5261d Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Mon, 27 Jul 2026 17:41:25 +0700 Subject: [PATCH 09/47] chore(core): bump submodule for mpv DJ crossfade --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 9a4ff00a3..b1afe0940 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 9a4ff00a3280abf9d106f7f643071ca852a4600f +Subproject commit b1afe09409d36fb5c2afa785db97997c5c9c86ef From 88be088bc290b16418830460ef24a02ff9bfc609 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Mon, 27 Jul 2026 21:19:07 +0700 Subject: [PATCH 10/47] chore(desktop): replace VLC natives with prebuilt libmpv slices --- .github/workflows/android-release.yml | 9 - .github/workflows/desktop-package.yml | 24 +- .gitignore | 4 +- CLAUDE.md | 27 +- composeApp/build.gradle.kts | 865 ++++++++++++------ .../simpmusic/ui/screen/home/SettingScreen.kt | 51 +- conveyor.conf | 42 +- core | 2 +- desktopApp/build.gradle.kts | 66 +- .../kotlin/com/maxrave/simpmusic/Main.kt | 2 +- gradle/libs.versions.toml | 4 - 11 files changed, 696 insertions(+), 400 deletions(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index dd31e3ca4..5a108c42e 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -157,15 +157,6 @@ jobs: - name: Generate aboutLibraries.json run: ./gradlew exportLibraryDefinitions - - name: Cache vlc-natives - uses: actions/cache@v4 - with: - path: vlc-natives - key: vlc-natives-${{ hashFiles('gradle/libs.versions.toml') }} - - - name: Populate vlc-natives for all OSes - run: ./gradlew :composeApp:vlcSetupAll --no-configuration-cache - - name: Generate Conveyor config # Run writeConveyorConfig BEFORE Conveyor so the parser only reads # the static generated.conveyor.conf and never tastes Gradle stdout diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index b8335856b..283edabb8 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -52,19 +52,13 @@ jobs: distribution: "microsoft" cache: 'gradle' - - name: Install 7-Zip 24.x + libfuse2 - # Ubuntu's p7zip-full (16.02) can't extract HFS+ DMG content fully — - # outer kolyDMG opens but the inner HFS+ partition stays opaque, so - # VLC.app never materializes. Use the official Igor Pavlov build, - # which has full HFS+ support like the modern Linux/macOS package. - # libfuse2 is needed by appimagetool when wrapping the Linux app. + - name: Install libfuse2 + # Needed by appimagetool when wrapping the Linux app. Nothing else is + # required here: mpvSetupAll only downloads and untars the prebuilt + # native slices, so no 7-Zip, patchelf or dwarfs toolchain on the runner. run: | sudo apt-get update sudo apt-get install -y libfuse2 - curl -fsSL https://github.com/ip7z/7zip/releases/download/26.01/7z2601-linux-x64.tar.xz -o /tmp/7zz.tar.xz - sudo tar -xf /tmp/7zz.tar.xz -C /usr/local/bin/ 7zz - sudo ln -sf /usr/local/bin/7zz /usr/local/bin/7z - 7z i | head -3 - name: Update build product flavor run: | @@ -82,14 +76,14 @@ jobs: - name: Generate aboutLibraries.json run: ./gradlew exportLibraryDefinitions - - name: Cache vlc-natives + - name: Cache mpv-natives uses: actions/cache@v4 with: - path: vlc-natives - key: vlc-natives-${{ hashFiles('gradle/libs.versions.toml') }} + path: mpv-natives + key: mpv-natives-${{ hashFiles('composeApp/build.gradle.kts') }} - - name: Populate vlc-natives for all OSes - run: ./gradlew :composeApp:vlcSetupAll --no-configuration-cache + - name: Populate mpv-natives for all OSes + run: ./gradlew :composeApp:mpvSetupAll --no-configuration-cache - name: Generate Conveyor config # Run writeConveyorConfig BEFORE Conveyor so the parser only reads diff --git a/.gitignore b/.gitignore index fb8e7edc5..c6077e489 100644 --- a/.gitignore +++ b/.gitignore @@ -33,8 +33,8 @@ sentry.properties /.claude/settings.local.json /androidApp/build/ /androidApp/cache/ -# VLC native libraries (downloaded by vlc-setup plugin) -/vlc-natives/ +# libmpv native libraries (downloaded by `./gradlew :composeApp:mpvSetupAll`) +/mpv-natives/ /.omc/ /desktopApp/build/ # Conveyor auto-generated config (run `./gradlew :desktopApp:writeConveyorConfig`) diff --git a/CLAUDE.md b/CLAUDE.md index 665297767..928a41800 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Contains core modules organized by functionality: ##### **core/media/** - **media3/**: Media3 ExoPlayer integration (includes `CrossfadeExoPlayerAdapter` for DJ-style crossfade on Android) - **media3-ui/**: Media3 UI components -- **media-jvm/**: JVM media playback (VLCJ - replaced GStreamer post-1.0.4) +- **media-jvm/**: JVM media playback (libmpv via JNA — replaced VLCJ, which replaced GStreamer post-1.0.4) - **media-jvm-ui/**: JVM media UI components ##### **core/service/** @@ -135,8 +135,8 @@ Service modules: ### Desktop - **Compose for Desktop**: UI -- **VLCJ**: Audio playback (replaced GStreamer since post-1.0.4) -- VLC native libraries are bundled per platform via `vlc-setup` Gradle plugin +- **libmpv** (mpv's C client API, bound with JNA): audio + video playback. Replaced VLCJ, which had replaced GStreamer post-1.0.4 +- libmpv natives are bundled per platform via `./gradlew :composeApp:mpvSetupAll` into `mpv-natives/-/` ### Networking & APIs - **Ktor Client**: HTTP client @@ -261,7 +261,7 @@ Before implementing code, researching code, or answering technical questions, th ### 5. Work with Media Playback **Location**: `core/media/media3/` (Android) or `core/media/media-jvm/` (Desktop) - Media3/ExoPlayer + CrossfadeExoPlayerAdapter for Android -- VLCJ (VlcPlayerAdapter) for Desktop +- libmpv (MpvPlayerAdapter / MpvPlayer / MpvLibrary) for Desktop - Queue management in `core/data/src/.../mediaservice/` - Playback controls @@ -332,7 +332,7 @@ Before implementing code, researching code, or answering technical questions, th #### Desktop - **Required Dependencies**: - - VLCJ: Audio playback (bundled via vlc-setup plugin) + - libmpv: audio + video playback (bundled via `mpvSetupAll`; falls back to a system-wide libmpv when `mpv-natives/` has not been staged) - **Features**: - Deep link support (`simpmusic://` and `simpmusic.org`) - Mini Player window (always-on-top, resizable, draggable) @@ -351,13 +351,15 @@ Before implementing code, researching code, or answering technical questions, th ## 🎵 Media Playback Architecture -### Desktop Player (VLCJ - replaced GStreamer post-1.0.4) +### Desktop Player (libmpv — replaced VLCJ 2026-07-27) -**Location**: `core/media/media-jvm/src/main/java/com/simpmusic/media_jvm/VlcPlayerAdapter.kt` +**Location**: `core/media/media-jvm/src/main/java/com/simpmusic/media_jvm/mpv/` -- Uses **VLCJ** library for audio playback (GStreamer was removed) -- VLC native libraries bundled per platform via `vlc-setup` Gradle plugin in `composeApp/build.gradle.kts` -- Bundled natives stored in `vlc-natives/{linux,macos,windows}/` +- `MpvLibrary.kt` — JNA binding for libmpv's C client API, hand-mapped against client API 2.x. Struct layouts are read by raw offset, so a MAJOR client-API bump needs them re-verified +- `MpvPlayer.kt` — one handle per media item; `vo=libmpv` + software render context +- `MpvVideoSurfacePanel.kt` — mpv SW render API → `BufferedImage` → Swing, embedded in Compose via `SwingPanel` +- `MpvPlayerAdapter.kt` — the `MediaPlayerInterface` implementation; separate YouTube audio/video URLs are merged into ONE source with an `edl://...;!new_stream;...` URL (mpv's equivalent of Android's `MergingMediaSource`) +- Natives bundled per platform in `mpv-natives/-/`, staged by `mpvSetupAll` - Supports crossfade transition with dual-player approach #### Crossfade Transition (Desktop) @@ -408,7 +410,8 @@ See `CODE_OF_CONDUCT.md` - [Media3 (ExoPlayer)](https://developer.android.com/guide/topics/media/media3) - [Room Database](https://developer.android.com/training/data-storage/room) - [Ktor Client](https://ktor.io/docs/client.html) -- [VLCJ](https://github.com/caprica/vlcj) +- [libmpv client API](https://github.com/mpv-player/mpv/blob/master/include/mpv/client.h) +- [mpv EDL format](https://github.com/mpv-player/mpv/blob/master/DOCS/edl-mpv.rst) ### Community - Website: https://simpmusic.org @@ -480,6 +483,8 @@ if (getPlatform() == Platform.Android) { - **VM environment detection**: Disable transparency and custom titlebar in VMs - **Google Cast (2026-07, Full build only)**: `cast`/`cast-empty` module pair gated by `isFullBuild`; unified Media3 `CastPlayer` wraps the session `ForwardingPlayer`; `CastHandoffManager` pushes resolved-URL queue windows to the receiver with 403/expiry retry; Cast button in Now Playing top bar, "Playing on " pill, crossfade/DJ/EQ settings gray out while casting; FOSS build stays GMS-free - **Windows SMTC (2026-07)**: System Media Transport Controls on Windows via `jmtc`/`nowplayingcenter` 0.0.3 (forked JMTC). The native `SMTCAdapter.dll` was hardened against the 1.0.x crash (Sentry SIMPMUSIC-DESKTOP-7, ~95k events): COM apartment tolerates `RPC_E_CHANGED_MODE`, `MediaPlayer` kept alive process-wide, and every exported call is exception-guarded so nothing crosses the JNA boundary as "Invalid memory access". JMTC is confined to a dedicated thread (off the AWT EDT), and `MediaType.Music` is set before display properties so title/artist render (not just the app name). Enabled in `JvmMediaPlayerHandlerImpl` for `Platform.Windows` (Linux MPRIS unchanged; macOS uses NowPlayingCenter). DLL built by GitHub Actions (`windows-latest`) in the NowPlayingCenter repo. +- **VLC removed entirely (2026-07-27)**: `VlcPlayerAdapter`, `DefaultVlcDiscoverer`, `MacOsVlcDiscoverer` and `VlcModule` are deleted; `VlcModule.kt` became `DesktopPlayerModule.kt` (`loadVlcModule()` → `loadDesktopPlayerModule()`). The `vlcj` dependency, the `vlc-setup` Gradle plugin, every `vlcSetup*` task, the `vlc-natives/` tree and the VLC Conveyor inputs are all gone. `appResourcesRootDir` now points at `mpv-natives/`. libmpv is the only desktop backend. +- **Bundled libmpv (2026-07-27)**: `./gradlew :composeApp:mpvSetupAll` stages libmpv + its dependency closure into `mpv-natives/{linux-x64,macos-arm64,macos-x64,windows-x64,windows-arm64}/`, wired into installers via `mpv-natives` inputs in `conveyor.conf` (`to = "mpv"`). Sources: shinchiro `mpv-dev-*.7z` (Windows, self-contained), pkgforge-dev mpv-AppImage (Linux, sharun `$ORIGIN`), Homebrew + `dylibbundler` (macOS). **The macOS slice needs a macOS runner** — one per architecture — because the closure is gathered from a Homebrew install; `mpvSetupMacCi` skips itself on other hosts. Do NOT try to lift `IINA.app/Contents/Frameworks` instead: IINA 1.4.4 ships a version-skewed pair (libmpv needs `_pl_log_create_349`, bundled `libplacebo.338.dylib` exports `_pl_log_create_338`) and that libmpv fails `dlopen` under both RTLD_NOW and RTLD_LAZY. `MpvLibrary.bundledLibraryDirs()` resolves the staged folder (`mpv.bundled.path` → `compose.application.resources.dir` → `mpv/` found by walking up from the JAR → `mpv-natives/-`), mirroring `DefaultVlcDiscoverer`. ## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 270c8c11f..b66b5fdd8 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -25,7 +25,6 @@ plugins { alias(libs.plugins.build.config) alias(libs.plugins.osdetector) alias(libs.plugins.packagedeps) - alias(libs.plugins.vlc.setup) } // composeApp uses the `android.kotlin.multiplatform.library` plugin, so with the @@ -175,7 +174,7 @@ kotlin { implementation(libs.kotlin.test) } jvmMain.dependencies { - // Desktop app entry (main.kt), VLC setup, jpackage/Conveyor + // Desktop app entry (main.kt), jpackage/Conveyor // packaging, and tray icon live in :desktopApp per the // JetBrains 2026 KMP default structure. This module keeps the // shared JVM UI + expect/actuals and their direct dependencies. @@ -192,94 +191,22 @@ kotlin { // linuxDebConfig{}, the custom AppImage tooling, and Conveyor packaging // live in :desktopApp per the JetBrains 2026 KMP default structure. // -// vlcSetup{} stays in :composeApp — moving it to :desktopApp fails -// because vlc.setup eagerly iterates tasks at apply time, which force- -// realizes Conveyor's lazily-registered writeConveyorConfig task before -// kotlin.multiplatform has created jvmJar → "Task with name 'jar' not -// found" (reproduced 2026-05-21 with vlc.setup placed last in the -// plugins block — plugin order does not help). composeApp has no -// Conveyor so the conflict cannot occur here. -// -// Run `./gradlew :composeApp:vlcSetup --no-configuration-cache` to -// populate vlc-natives/{linux-x64,macos-,windows-x64}/. Layout -// is per-arch so Conveyor can bundle the right native slice into each -// per-machine installer (universal Mac dylibs almost doubled artifact -// size pre-split — see commit message for context). -val hostMacArchDir = if (System.getProperty("os.arch").lowercase().contains("aarch64")) { - "macos-arm64" -} else { - "macos-x64" -} -val hostWinArchDir = if (System.getProperty("os.arch").lowercase().contains("aarch64")) { - "windows-arm64" -} else { - "windows-x64" -} -vlcSetup { - vlcVersion = libs.versions.vlc.get() - shouldCompressVlcFiles = false - shouldIncludeAllVlcFiles = true - pathToCopyVlcLinuxFilesTo = rootDir.resolve("vlc-natives/linux-x64/") - pathToCopyVlcMacosFilesTo = rootDir.resolve("vlc-natives/$hostMacArchDir/") - pathToCopyVlcWindowsFilesTo = rootDir.resolve("vlc-natives/$hostWinArchDir/") -} - -// Flatten vlc-natives//vlc/plugins → vlc-natives//plugins after -// vlc-setup copies files. The plugin ships a nested vlc/ subdir for VLC's -// own path resolution, but Conveyor then copies both the nested tree AND -// extracts the .so files flat at the parent → 2× duplication (~348 MB -// extra) in the packaged AppImage. Flat layout keeps Conveyor lean while -// VLCJ still resolves libs via DefaultVlcDiscoverer. -tasks.named("vlcSetup").configure { - doLast { - listOf("linux-x64", hostMacArchDir, hostWinArchDir).forEach { archDir -> - val root = rootDir.resolve("vlc-natives/$archDir") - val nested = root.resolve("vlc") - if (nested.isDirectory) { - nested.listFiles()?.forEach { child -> - val target = root.resolve(child.name) - if (target.exists()) target.deleteRecursively() - child.renameTo(target) - } - nested.deleteRecursively() - } - } - } -} - -// ============================================================================ -// Cross-OS VLC natives downloader (single-runner CI) -// ============================================================================ -// The `vlc-setup` plugin above only registers `vlcSetup` for the HOST OS -// (LinuxTasksConfigure / MacTasksConfigure / WindowsTasksConfigure each -// gate on `getCurrentOs() == OS.X`). To package multi-OS artifacts from a -// single CI runner (Linux), we replicate the plugin's download + filter + -// copy logic for the OTHER two OSes here. -// -// Resulting layout matches the upstream plugin so VLCJ's -// DefaultVlcDiscoverer keeps working unchanged. -// -// Local dev: keep using `./gradlew :composeApp:vlcSetup` (host-OS only). -// Cross-OS CI: use `./gradlew :composeApp:vlcSetupAll`. -// -// Mac DMG extraction needs the 7z tool: -// Ubuntu CI: sudo apt-get install -y p7zip-full -// macOS local: brew install p7zip -val vlcCacheDir = layout.buildDirectory.dir("vlc-cache") +// Native-library staging (libmpv) lives here in :composeApp — see the +// mpv-natives block below. The layout is per-arch so Conveyor bundles only +// the slice each per-machine installer actually needs. -fun downloadIfMissing(url: String, target: java.io.File) { +fun downloadIfMissing(url: String, target: java.io.File, logPrefix: String = "mpv-multi") { if (target.exists() && target.length() > 0) { - logger.lifecycle("[vlc-multi] Cached: ${target.name}") + logger.lifecycle("[$logPrefix] Cached: ${target.name}") return } - logger.lifecycle("[vlc-multi] Downloading $url") + logger.lifecycle("[$logPrefix] Downloading $url") target.parentFile.mkdirs() - // Use curl instead of Java's URL.openStream() because get.videolan.org - // returns a 302 redirect to a random mirror per request, and Java's - // default HttpURLConnection redirect handling is fragile — if the - // mirror lands on a cross-protocol redirect or returns an HTML error - // page, openStream() silently saves the HTML response as the target - // file, producing a "Cannot expand ZIP" downstream. curl's `-L` + // Use curl instead of Java's URL.openStream(): release downloads answer + // with a redirect to a CDN, and Java's default HttpURLConnection redirect + // handling is fragile — on a cross-protocol redirect or an HTML error + // page, openStream() silently saves the error body as the target file, + // which only surfaces as a corrupt-archive failure later. curl's `-L` // follows redirects robustly across protocols + mirrors, `--fail` // exits non-zero on HTTP errors instead of saving error bodies, and // `-o` writes atomically via tmp file. The downloaded artifact is @@ -304,237 +231,611 @@ fun downloadIfMissing(url: String, target: java.io.File) { } } -val vlcSetupLinuxCi by tasks.registering { - group = "vlc-multi" - description = "Cross-OS: populate vlc-natives/linux-x64/ with .so files." - val outputDir = rootDir.resolve("vlc-natives/linux-x64/") - outputs.dir(outputDir) - doLast { - // Pinned upstream — Linux artifact is a custom Maven package whose - // version is independent of the desktop VLC release. - val linuxVersion = "3.0.20-2" - val cache = vlcCacheDir.get().asFile - val jar = cache.resolve("vlc-plugins-linux-$linuxVersion.jar") - downloadIfMissing( - "https://repo1.maven.org/maven2/ir/mahozad/vlc-plugins-linux/$linuxVersion/vlc-plugins-linux-$linuxVersion.jar", - jar, - ) - outputDir.walk().filter { it.extension == "so" }.forEach { it.delete() } - project.copy { - from(zipTree(jar)) - into(outputDir) - // Ship the full VLC plugin set (matches the v1.2.1 release). - // A curated subset based on upstream vlc-setup defaults turned - // out to be insufficient for SimpMusic — YT Music streaming - // depends on HTTP/HTTPS access + MP4/WebM demuxers that the - // upstream music-app preset doesn't cover. `**/` is needed - // because include() evaluates against the original jar paths - // (which include the `vlc-plugins-linux-/` top-level dir) - // before the eachFile drop(1) transformation kicks in. - include("**/*.so", "**/*.so.*") - // Strip the top-level dir inside the jar (matches upstream plugin). - eachFile { - if (relativePath.segments.size > 1) { - relativePath = RelativePath(true, *relativePath.segments.drop(1).toTypedArray()) - } - } - includeEmptyDirs = false - } - // Flatten vlc-natives/linux-x64/vlc/* → vlc-natives/linux-x64/* (same - // as the host-OS flatten task above) so Conveyor doesn't duplicate - // plugins. - val nested = outputDir.resolve("vlc") - if (nested.isDirectory) { - nested.listFiles()?.forEach { child -> - val target = outputDir.resolve(child.name) - if (target.exists()) target.deleteRecursively() - child.renameTo(target) +// =========================================================================== +// mpv natives (libmpv) — staged into mpv-natives/-/ for packaging. +// +// mpv publishes no portable libmpv of its own and there is no Gradle plugin +// that fetches one, so each OS lifts libmpv out of a prebuilt artifact that +// ALREADY ships a relocatable dependency closure. That is what keeps this +// cheap: gathering ffmpeg / libplacebo / libass / luajit by hand and +// rewriting their install names is precisely the work these upstreams have +// already done and keep doing on every release. +// +// Windows shinchiro/mpv-winbuild-cmake `mpv-dev-.7z` +// → libmpv-2.dll with ffmpeg linked in. Nothing to patch. +// Linux pkgforge-dev/mpv-AppImage +// → libmpv.so.2 + closure under shared/lib with RPATH=$ORIGIN +// (sharun), so nothing to patch there either. +// macOS Homebrew's mpv + dylibbundler, run on a macOS host. +// +// macOS is the odd one out and deliberately so. The obvious shortcut — lifting +// IINA.app/Contents/Frameworks straight out of IINA's .dmg — DOES NOT WORK, +// and fails in a way worth recording so nobody retries it. In IINA 1.4.4 the +// bundled pair is version-skewed: +// +// nm -u libmpv.2.dylib → _pl_log_create_349 (libplacebo API 349) +// nm -gU libplacebo.338.dylib → _pl_log_create_338 +// +// on BOTH slices of the fat binaries, with only one libplacebo in the bundle +// and the reference not weak ("(undefined) external ... (from libplacebo.338)"). +// dlopen() of that libmpv fails outright — verified with RTLD_NOW *and* +// RTLD_LAZY — and libmpv has 136 undefined _pl_* symbols riding on it. Whatever +// makes IINA itself work, that closure is not self-sufficient, and MpvLibrary +// loads with RTLD_NOW by design, so there is no flag to hide behind. +// +// Homebrew resolves mpv and libplacebo as one dependency graph, so its closure +// is self-consistent by construction. dylibbundler then copies that closure and +// rewrites every dependency to @loader_path/... in one pass. The dylibs must be +// re-signed ad-hoc afterwards: mutating a Mach-O invalidates its signature and +// macOS refuses to load an invalidly-signed dylib (hard failure on Apple +// Silicon). Cost of this route: the macOS slice needs a macOS runner, one per +// architecture — it cannot be produced from the Linux runner that builds +// every other slice. +// +// Conveyor stages one slice per machine — see the mpv-natives inputs in +// conveyor.conf. +// =========================================================================== +val mpvCacheDir = layout.buildDirectory.dir("mpv-cache") + +// Pinned upstream artifacts. Bump deliberately: MpvLibrary.kt hand-maps the +// libmpv struct layouts by raw offset, so a client-API MAJOR bump means the +// structs must be re-verified before these pins move. mpv 0.41.x is client +// API 2.5; MpvLibrary.kt was written against 2.2 and only guards the major. +// mpv's own tagged release — the source of the macOS slices. +val mpvVersion = "0.41.0" +val mpvWinBuildTag = "20260610" +val mpvWinBuildSuffix = "20260610-git-304426c" +// Percent-encoded because the release tag embeds an '@'. Kept as a literal rather than +// URLEncoder.encode(): in a build script `java` resolves to the JavaPluginExtension +// accessor, so `java.net.URLEncoder` is unresolvable in expression position. +val mpvAppImageTagEncoded = "v0.41.0%402026-07-01_1782914175" +val mpvAppImageVersion = "v0.41.0" +// Reads the AppImage's DwarFS payload. 0.15.6 handles DwarFS v2.5, which is what this +// AppImage carries; an older dwarfs reports "unsupported major version". +val dwarfsVersion = "0.15.6" + +// Every extractor below finds the directory that actually holds the libmpv +// artifact and copies its whole sibling set, rather than hard-coding upstream +// tree shapes. Those layouts drift between releases; "the folder libmpv lives +// in" does not. +fun findDirContaining(root: java.io.File, namePredicate: (String) -> Boolean): java.io.File? = + root.walkTopDown() + .firstOrNull { it.isFile && namePredicate(it.name) } + ?.parentFile + +/** True when [tool] can be executed at all (i.e. it exists on PATH). */ +fun toolAvailable(tool: String): Boolean = + try { + ProcessBuilder(tool) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + .waitFor() + true + } catch (e: java.io.IOException) { + false + } + +fun runChecked(vararg command: String) { + val exit = ProcessBuilder(*command).inheritIO().start().waitFor() + check(exit == 0) { "Command failed (exit $exit): ${command.joinToString(" ")}" } +} + +// --------------------------------------------------------------------------- +// macOS +// --------------------------------------------------------------------------- + +/** + * Rewrite every `@executable_path/lib/` load-command entry to [prefix]``. + * + * Patches bytes instead of shelling out to `install_name_tool`, which is what keeps the macOS + * slice buildable on ANY host — the whole point, since every other slice comes off the same + * Linux runner. + * + * Safe because the replacement is always SHORTER than what it replaces (`@executable_path/lib/` + * is 21 chars; `@loader_path/lib/` is 17 and `@loader_path/` is 13), so the tail is NUL-padded: + * a Mach-O dylib path is a C string inside a load command of fixed `cmdsize`, and it ends at the + * first NUL. Growing a path would need the command resized, which this deliberately never does. + * + * @return how many entries were rewritten. + */ +fun rewriteExecutablePathRefs(file: java.io.File, prefix: String): Int { + val needle = "@executable_path/lib/".toByteArray(Charsets.US_ASCII) + val prefixBytes = prefix.toByteArray(Charsets.US_ASCII) + check(prefixBytes.size <= needle.size) { + "prefix '$prefix' is longer than '@executable_path/lib/' — paths can only be shortened in place" + } + val data = file.readBytes() + var rewritten = 0 + var i = 0 + outer@ while (i <= data.size - needle.size) { + for (j in needle.indices) { + if (data[i + j] != needle[j]) { + i++ + continue@outer } - nested.deleteRecursively() } + // The entry runs from the match to its terminating NUL. + var end = i + needle.size + while (end < data.size && data[end] != 0.toByte()) end++ + val name = data.copyOfRange(i + needle.size, end) + val replacement = prefixBytes + name + check(replacement.size <= end - i) + replacement.copyInto(data, i) + for (k in i + replacement.size until end) data[k] = 0 + rewritten++ + i = end } + if (rewritten > 0) file.writeBytes(data) + return rewritten } -// Shared Mac DMG download + extraction logic. Each per-arch task calls -// this with its slice's DMG suffix ("arm64" or "intel64") and output -// folder. We pull per-arch DMGs (48-55 MB each) instead of the universal -// DMG (84.9 MB = arm64 + intel64 fat binary), so each per-machine zip -// only ships its own slice — saves ~25-40 MB per user download. -fun extractMacVlcSlice( - archSuffix: String, - outputDir: java.io.File, -) { - val macVersion = libs.versions.vlc.get() - val cache = vlcCacheDir.get().asFile - val dmg = cache.resolve("vlc-$macVersion-$archSuffix.dmg") +/** + * Re-sign every Mach-O under [dir] ad-hoc, recursively. + * + * Rewriting load commands invalidates the signature mpv's CI applied, and macOS refuses to load + * an invalidly-signed Mach-O (a hard failure on Apple Silicon). `codesign` only exists on macOS, + * so on a Linux runner this warns instead: Conveyor signs the macOS bundle it produces, which is + * what makes the staged slice loadable in the shipped app. A locally staged slice built on Linux + * and run directly, without going through Conveyor, would not load. + */ +fun codesignAdhoc(dir: java.io.File) { + val machO = dir.walkTopDown().filter { it.isFile && (it.name.endsWith(".dylib") || it.extension.isEmpty()) }.toList() + check(machO.isNotEmpty()) { "Nothing to sign in ${dir.absolutePath}" } + + // Patching load commands invalidates mpv's own signature and macOS refuses to load an + // invalidly-signed Mach-O, so re-signing is mandatory. `codesign` is macOS-only, which is + // fine: these tasks build the published archives and are run on a Mac, not in CI. + check(toolAvailable("codesign")) { + "codesign is required to re-sign the patched macOS slice — run this task on a Mac. " + + "CI does not: it downloads the prebuilt archives instead." + } + logger.lifecycle("[mpv-multi] Ad-hoc signing ${machO.size} Mach-O files in ${dir.name}") + machO.forEach { runChecked("codesign", "--force", "--sign", "-", it.absolutePath) } +} + +/** + * Stage one macOS slice straight out of mpv's own release build. + * + * mpv's macOS artifacts are app bundles that link libmpv STATICALLY into + * `mpv.app/Contents/MacOS/mpv`, so there is no libmpv.dylib to copy — but that binary is a PIE + * Mach-O exporting the whole client API (54 `_mpv_*` symbols, checked with `nm -gU`), and macOS + * `dlopen()` accepts a PIE executable. Renaming it to `libmpv.dylib` is what lets JNA find it: + * `Native.load("mpv")` maps to exactly that filename on macOS (NativeLibrary.mapSharedLibraryName). + * + * Its dependency closure ships alongside in `Contents/MacOS/lib/` already relocatable via + * `@executable_path/lib/...`; only the anchor has to change, because the loading process here is + * the JVM rather than mpv itself. Two different prefixes are needed: libmpv sits one level above + * `lib/`, while the closure sits inside it. + * + * Do NOT swap this for IINA's .dmg. IINA 1.4.4 ships a libmpv that needs libplacebo API 349 next + * to a libplacebo.338 exporting only 338, on both slices and not weakly referenced, so its libmpv + * fails dlopen under RTLD_NOW *and* RTLD_LAZY. + */ +fun extractMacMpvSlice(assetArch: String, outputDir: java.io.File) { + val cache = mpvCacheDir.get().asFile + val zip = cache.resolve("mpv-$mpvVersion-$assetArch.zip") downloadIfMissing( - "https://get.videolan.org/vlc/$macVersion/macosx/vlc-$macVersion-$archSuffix.dmg", - dmg, + "https://github.com/mpv-player/mpv/releases/download/v$mpvVersion/mpv-v$mpvVersion-$assetArch.zip", + zip, + logPrefix = "mpv-multi", ) - outputDir.walk().filter { it.extension == "dylib" }.forEach { it.delete() } - outputDir.mkdirs() - // Pick the extractor that's native to the host: - // • macOS → hdiutil (built-in, no install required for local dev) - // • Linux/Windows CI → 7z (cross-platform HFS+ support, needs - // p7zip-full / official 7-Zip 23+ installed on the runner) - // Both paths drop a directory containing the VLC.app payload at - // `macOsDir`, ready for the curated copy step below. - val isMacHost = System.getProperty("os.name").lowercase().contains("mac") - val macOsDir: java.io.File - val cleanupMount: (() -> Unit)? - if (isMacHost) { - val mountPoint = cache.resolve("vlc-mount-$macVersion-$archSuffix") - mountPoint.deleteRecursively() - mountPoint.mkdirs() - val attachExit = ProcessBuilder( - "hdiutil", "attach", - "-mountpoint", mountPoint.absolutePath, - "-nobrowse", "-quiet", - dmg.absolutePath, - ).inheritIO().start().waitFor() - check(attachExit == 0) { - "hdiutil attach failed with exit code $attachExit for $dmg" - } - macOsDir = mountPoint.resolve("VLC.app/Contents/MacOS") - cleanupMount = { - ProcessBuilder("hdiutil", "detach", "-quiet", mountPoint.absolutePath) - .inheritIO().start().waitFor() - } - } else { - val extractDir = cache.resolve("vlc-mac-$macVersion-$archSuffix-extract") - extractDir.deleteRecursively() - extractDir.mkdirs() - // 7z returns exit code 2 because the DMG contains a "VLC media - // player/Applications → /Applications" drag-to-install symlink - // that 7z refuses to extract (dangerous absolute link). The - // VLC.app payload extracts fine, so we verify by directory - // presence below rather than trusting the exit code. - val sevenZipExit = ProcessBuilder( - "7z", "x", "-y", "-bso0", "-bsp0", - "-o${extractDir.absolutePath}", - dmg.absolutePath, - ).inheritIO().start().waitFor() - macOsDir = extractDir.walkTopDown() - .firstOrNull { - it.isDirectory && - it.name == "MacOS" && - it.parentFile?.name == "Contents" && - it.parentFile?.parentFile?.name == "VLC.app" - } - ?: error( - "VLC.app/Contents/MacOS/ not found inside extracted DMG at $extractDir " + - "(7z exit code $sevenZipExit)", - ) - cleanupMount = null + + // The published .zip wraps a .tar.gz, so this unpacks twice. Both steps are plain Gradle file + // operations — no 7z, no hdiutil, nothing host-specific. + val stage = cache.resolve("mac-$assetArch-extract") + stage.deleteRecursively() + stage.mkdirs() + project.copy { + from(zipTree(zip)) + into(stage) } - check(macOsDir.isDirectory) { - "VLC.app/Contents/MacOS not found at ${macOsDir.absolutePath}" + val innerTar = stage.walkTopDown().firstOrNull { it.isFile && it.name.endsWith(".tar.gz") } + ?: error("No inner tarball inside ${zip.name}") + project.copy { + from(tarTree(resources.gzip(innerTar))) + into(stage) } - try { - project.copy { - from(macOsDir) - into(outputDir) - // Ship the full VLC plugin set (matches v1.2.1 release). - // Curated music-app preset from upstream vlc-setup didn't - // include HTTP/HTTPS access + MP4/WebM demuxers needed for - // YT Music streaming. - include("lib/libvlc.dylib", "lib/libvlccore.dylib", "plugins/**") - // Flatten lib/ → root (matches upstream Mac VlcSetupTask). - eachFile { - if (relativePath.segments.firstOrNull() == "lib") { - relativePath = RelativePath(true, *relativePath.segments.drop(1).toTypedArray()) - } - } - includeEmptyDirs = false - } - } finally { - cleanupMount?.invoke() + val macOsDir = stage.walkTopDown().firstOrNull { + it.isDirectory && it.name == "MacOS" && it.parentFile?.name == "Contents" + } ?: error("mpv.app/Contents/MacOS not found inside ${zip.name}") + val binary = macOsDir.resolve("mpv") + check(binary.isFile) { "mpv binary missing from ${macOsDir.absolutePath}" } + + outputDir.deleteRecursively() + outputDir.mkdirs() + project.copy { + from(macOsDir.resolve("lib")) + into(outputDir.resolve("lib")) + } + val staged = outputDir.resolve("libmpv.dylib") + binary.copyTo(staged, overwrite = true) + staged.setWritable(true) + + var rewritten = rewriteExecutablePathRefs(staged, "@loader_path/lib/") + outputDir.resolve("lib").listFiles()?.filter { it.isFile && it.name.endsWith(".dylib") }?.forEach { dylib -> + dylib.setWritable(true) + rewritten += rewriteExecutablePathRefs(dylib, "@loader_path/") } + logger.lifecycle("[mpv-multi] $assetArch: rewrote $rewritten install-name entries") + codesignAdhoc(outputDir) } -val vlcSetupMacArmCi by tasks.registering { - group = "vlc-multi" - description = "Cross-OS: populate vlc-natives/macos-arm64/ with Apple Silicon .dylib files." - val outputDir = rootDir.resolve("vlc-natives/macos-arm64/") +val mpvSetupMacArmCi by tasks.registering { + group = "mpv-multi" + description = "Cross-OS: populate mpv-natives/macos-arm64/ with libmpv + its dylib closure." + val outputDir = rootDir.resolve("mpv-natives/macos-arm64/") outputs.dir(outputDir) - doLast { extractMacVlcSlice("arm64", outputDir) } + doLast { extractMacMpvSlice("macos-15-arm", outputDir) } } -val vlcSetupMacX64Ci by tasks.registering { - group = "vlc-multi" - description = "Cross-OS: populate vlc-natives/macos-x64/ with Intel .dylib files." - val outputDir = rootDir.resolve("vlc-natives/macos-x64/") +val mpvSetupMacX64Ci by tasks.registering { + group = "mpv-multi" + description = "Cross-OS: populate mpv-natives/macos-x64/ with Intel libmpv + its dylib closure." + val outputDir = rootDir.resolve("mpv-natives/macos-x64/") outputs.dir(outputDir) - doLast { extractMacVlcSlice("intel64", outputDir) } + doLast { extractMacMpvSlice("macos-15-intel", outputDir) } } -// Shared Windows VLC zip extraction. VideoLAN ships separate per-arch -// zips (win64/ for x64, winarm64/ for ARM64) — we mirror that layout -// in vlc-natives/ so Conveyor bundles the right slice per msix. -fun extractWindowsVlcSlice( - archSuffix: String, - outputDir: java.io.File, -) { - val winVersion = libs.versions.vlc.get() - val cache = vlcCacheDir.get().asFile - // VideoLAN URL layout for Windows: - // x64: /vlc//win64/vlc--win64.zip - // arm: /vlc//winarm64/vlc--winarm64.zip - // Both zips share the same internal tree shape, so once downloaded - // the rest of the pipeline is identical. - val subDir = if (archSuffix == "winarm64") "winarm64" else "win64" - val zip = cache.resolve("vlc-$winVersion-$archSuffix.zip") +// --------------------------------------------------------------------------- +// Windows +// --------------------------------------------------------------------------- + +fun extractWindowsMpvSlice(arch: String, outputDir: java.io.File) { + // `7zz` (the official 7-Zip binary) is preferred over `7z`, which on many machines is p7zip + // — a fork last released in 2017. shinchiro's aarch64 archive uses the ARM64 BCJ filter that + // 7-Zip only gained in 21.07, so p7zip fails it with "Unsupported Method : libmpv-2.dll" + // while extracting the x86_64 one just fine. The CI image installs 7-Zip 26.x for the same + // reason. + val sevenZip = listOf("7zz", "7z").firstOrNull(::toolAvailable) + ?: error( + "7-Zip is required to unpack shinchiro's .7z builds and must be 21.07 or newer. " + + "macOS: `brew install sevenzip` (provides 7zz). Ubuntu: install the official " + + "7-Zip build — distro p7zip is too old for the ARM64 archive.", + ) + val cache = mpvCacheDir.get().asFile + val archive = cache.resolve("mpv-dev-$arch-$mpvWinBuildSuffix.7z") downloadIfMissing( - "https://get.videolan.org/vlc/$winVersion/$subDir/vlc-$winVersion-$archSuffix.zip", - zip, + "https://github.com/shinchiro/mpv-winbuild-cmake/releases/download/" + + "$mpvWinBuildTag/mpv-dev-$arch-$mpvWinBuildSuffix.7z", + archive, + logPrefix = "mpv-multi", ) - outputDir.walk().filter { it.extension == "dll" }.forEach { it.delete() } + val extractDir = cache.resolve("mpv-dev-$arch-extract") + extractDir.deleteRecursively() + extractDir.mkdirs() + // Output is left visible: when the extractor is too old it fails with "Unsupported Method", + // and silencing that turns a one-line diagnosis into a bare non-zero exit code. + runChecked(sevenZip, "x", "-y", "-o${extractDir.absolutePath}", archive.absolutePath) + + // The dev package is headers + import lib + the runtime DLL. Only the DLL + // is shipped; JNA resolves it by name ("libmpv-2" is in MpvLibrary's + // CANDIDATE_NAMES), and ffmpeg is linked into it, so it stands alone. + val dllDir = findDirContaining(extractDir) { it.startsWith("libmpv") && it.endsWith(".dll") } + ?: error("No libmpv*.dll inside ${archive.name}") + outputDir.deleteRecursively() + outputDir.mkdirs() project.copy { - from(zipTree(zip)) + from(dllDir) into(outputDir) - // Ship the full VLC plugin set (matches v1.2.1 release). The - // music-app preset from upstream vlc-setup turned out to be - // missing HTTP/HTTPS access + MP4/WebM demuxers needed for YT - // Music streaming. `**/` is required because include() runs - // against the original `vlc-/...` paths inside the zip - // before the eachFile drop(1) transformation. - include("**/*.dll") - // Strip top-level `vlc-/` prefix dir. - eachFile { - if (relativePath.segments.size > 1) { - relativePath = RelativePath(true, *relativePath.segments.drop(1).toTypedArray()) - } - } + include("*.dll") includeEmptyDirs = false } } -val vlcSetupWindowsX64Ci by tasks.registering { - group = "vlc-multi" - description = "Cross-OS: populate vlc-natives/windows-x64/ with .dll files." - val outputDir = rootDir.resolve("vlc-natives/windows-x64/") +val mpvSetupWindowsX64Ci by tasks.registering { + group = "mpv-multi" + description = "Cross-OS: populate mpv-natives/windows-x64/ with libmpv-2.dll." + val outputDir = rootDir.resolve("mpv-natives/windows-x64/") outputs.dir(outputDir) - doLast { extractWindowsVlcSlice("win64", outputDir) } + doLast { extractWindowsMpvSlice("x86_64", outputDir) } +} + +val mpvSetupWindowsArmCi by tasks.registering { + group = "mpv-multi" + description = "Cross-OS: populate mpv-natives/windows-arm64/ with ARM64 libmpv-2.dll." + val outputDir = rootDir.resolve("mpv-natives/windows-arm64/") + outputs.dir(outputDir) + doLast { extractWindowsMpvSlice("aarch64", outputDir) } +} + +// --------------------------------------------------------------------------- +// Linux +// --------------------------------------------------------------------------- + +/** + * Offset of the payload appended after an ELF file, i.e. the end of the ELF proper. + * + * AppImages are an ELF runtime with a filesystem image concatenated onto it, and the image starts + * exactly where the section-header table ends: `e_shoff + e_shnum * e_shentsize`. + * + * Do NOT try to find the payload by scanning for its magic instead. This runtime embeds the + * strings `DWARFS_BLOCK_SIZE`, `DWARFS_CACHE_SIZE` and friends as environment-variable names, so + * the first `DWARFS` hit lands ~1.1 MB before the real image and every extractor then reports + * "unsupported major version". + */ +fun elfPayloadOffset(file: java.io.File): Long { + val header = ByteArray(64) + file.inputStream().use { check(it.read(header) == 64) { "${file.name} is too small to be an ELF" } } + check(header[0] == 0x7f.toByte() && header[1] == 'E'.code.toByte()) { "${file.name} is not an ELF file" } + // Hand-rolled little-endian reads rather than java.nio.ByteBuffer: inside a build script + // `java` resolves to the JavaPluginExtension accessor, so java.* only works in type position. + fun le(offset: Int, size: Int): Long { + var value = 0L + for (i in size - 1 downTo 0) value = (value shl 8) or (header[offset + i].toLong() and 0xff) + return value + } + val shoff = le(0x28, 8) + val shentsize = le(0x3a, 2) + val shnum = le(0x3c, 2) + return shoff + shentsize * shnum +} + +/** + * `DT_NEEDED` entries of an ELF file, i.e. the shared objects it links against directly. + * + * Enough of a parser to walk a dependency closure: section headers → `.dynamic` → `.dynstr`. + * Returns empty for anything that isn't an ELF with section headers. + */ +fun elfNeeded(file: java.io.File): List { + val d = file.readBytes() + if (d.size < 64 || d[0] != 0x7f.toByte() || d[1] != 'E'.code.toByte()) return emptyList() + fun le(off: Int, size: Int): Long { + var v = 0L + for (i in size - 1 downTo 0) v = (v shl 8) or (d[off + i].toLong() and 0xff) + return v + } + val shoff = le(0x28, 8).toInt() + val shentsize = le(0x3a, 2).toInt() + val shnum = le(0x3c, 2).toInt() + val shstrndx = le(0x3e, 2).toInt() + if (shnum == 0 || shoff == 0) return emptyList() + fun sectionName(i: Int) = le(shoff + i * shentsize, 4).toInt() + fun sectionOff(i: Int) = le(shoff + i * shentsize + 0x18, 8).toInt() + fun sectionSize(i: Int) = le(shoff + i * shentsize + 0x20, 8).toInt() + fun cstr(base: Int, offset: Int): String { + var e = base + offset + while (e < d.size && d[e] != 0.toByte()) e++ + return String(d, base + offset, e - (base + offset), Charsets.US_ASCII) + } + val shstrBase = sectionOff(shstrndx) + var dynamicIdx = -1 + var dynstrIdx = -1 + for (i in 0 until shnum) { + when (cstr(shstrBase, sectionName(i))) { + ".dynamic" -> dynamicIdx = i + ".dynstr" -> dynstrIdx = i + } + } + if (dynamicIdx < 0 || dynstrIdx < 0) return emptyList() + val dynOff = sectionOff(dynamicIdx) + val strBase = sectionOff(dynstrIdx) + val result = mutableListOf() + for (i in 0 until sectionSize(dynamicIdx) / 16) { + val tag = le(dynOff + i * 16, 8) + val value = le(dynOff + i * 16 + 8, 8).toInt() + if (tag == 0L) break + if (tag == 1L) result += cstr(strBase, value) // DT_NEEDED + } + return result } -val vlcSetupWindowsArmCi by tasks.registering { - group = "vlc-multi" - description = "Cross-OS: populate vlc-natives/windows-arm64/ with ARM64 .dll files." - val outputDir = rootDir.resolve("vlc-natives/windows-arm64/") +/** + * Delete everything in `lib/` that [root] does not actually reach. + * + * sharun bundles whatever the mpv *player* needs — X11, wayland, pulse, GTK and more — which is + * 350 shared objects / 373 MB. libmpv's own closure is 91 of them / 54 MB, and the rest would be + * dead weight in every Linux installer. + */ +fun pruneUnreachableSharedObjects(root: java.io.File, libDir: java.io.File) { + val present = libDir.listFiles()?.filter { it.isFile }?.associateBy { it.name }.orEmpty() + val reachable = mutableSetOf() + val queue = ArrayDeque() + queue += root + while (queue.isNotEmpty()) { + elfNeeded(queue.removeFirst()).forEach { name -> + if (reachable.add(name)) present[name]?.let { queue += it } + } + } + var freed = 0L + present.forEach { (name, file) -> + if (name !in reachable) { + freed += file.length() + file.delete() + } + } + logger.lifecycle( + "[mpv-multi] Pruned ${present.size - reachable.size} unreachable shared objects " + + "(${freed / 1048576} MB); kept ${reachable.size}", + ) +} + +val mpvSetupLinuxCi by tasks.registering { + group = "mpv-multi" + description = "Cross-OS: populate mpv-natives/linux-x64/ with libmpv + its .so closure." + val outputDir = rootDir.resolve("mpv-natives/linux-x64/") outputs.dir(outputDir) - doLast { extractWindowsVlcSlice("winarm64", outputDir) } + doLast { + // patchelf is the one genuinely host-specific step. The binary carries NO + // DT_RPATH/DT_RUNPATH at all — inside the AppImage a sharun wrapper sets + // LD_LIBRARY_PATH instead — so an rpath must be added before it can be dlopen()ed + // straight out of the staged folder. + // + // Skip rather than fail when it is missing: `mpvSetupAll` is normally run on a dev's + // own machine to get the app running, and a hard failure there would take the macOS + // and Windows slices down with it for a slice that machine cannot use anyway. CI runs + // on Linux, where patchelf is one apt package away. + if (!toolAvailable("patchelf")) { + logger.warn( + "[mpv-multi] Skipping the Linux slice: patchelf is not on PATH " + + "(`sudo apt-get install -y patchelf`, or `brew install patchelf` locally). " + + "The other slices are unaffected.", + ) + return@doLast + } + val cache = mpvCacheDir.get().asFile + val appImage = cache.resolve("mpv-$mpvAppImageVersion-x86_64.AppImage") + downloadIfMissing( + "https://github.com/pkgforge-dev/mpv-AppImage/releases/download/" + + "$mpvAppImageTagEncoded/mpv-$mpvAppImageVersion-anylinux-x86_64.AppImage", + appImage, + logPrefix = "mpv-multi", + ) + + // The payload is DwarFS, not SquashFS, and this runtime does not implement the classic + // `--appimage-extract` flag (no `--appimage-*` string appears anywhere in the binary), so + // neither unsquashfs nor self-extraction works. dwarfsextract reads it directly, and its + // upstream Linux build is a self-contained tarball — no apt package needed. + // On the Linux runner, fetch upstream's self-contained build so no distro package is + // needed. Anywhere else that binary cannot execute, so fall back to a dwarfsextract + // already on PATH (`brew install dwarfs`) and skip the slice if there is none. + val isLinuxHost = System.getProperty("os.name").lowercase().contains("linux") + val dwarfsExtract: String = + if (isLinuxHost) { + val dwarfsTar = cache.resolve("dwarfs-$dwarfsVersion-Linux-x86_64.tar.xz") + downloadIfMissing( + "https://github.com/mhx/dwarfs/releases/download/v$dwarfsVersion/" + + "dwarfs-$dwarfsVersion-Linux-x86_64.tar.xz", + dwarfsTar, + logPrefix = "mpv-multi", + ) + val toolsDir = cache.resolve("dwarfs-tools") + val binary = + toolsDir.walkTopDown().firstOrNull { it.isFile && it.name == "dwarfsextract" } ?: run { + toolsDir.deleteRecursively() + toolsDir.mkdirs() + runChecked("tar", "-xf", dwarfsTar.absolutePath, "-C", toolsDir.absolutePath) + toolsDir.walkTopDown().firstOrNull { it.isFile && it.name == "dwarfsextract" } + ?: error("dwarfsextract not found inside ${dwarfsTar.name}") + } + binary.setExecutable(true) + binary.absolutePath + } else { + if (!toolAvailable("dwarfsextract")) { + logger.warn( + "[mpv-multi] Skipping the Linux slice: dwarfsextract is not on PATH " + + "(`brew install dwarfs`). The other slices are unaffected.", + ) + return@doLast + } + "dwarfsextract" + } + + val extractDir = cache.resolve("mpv-appimage-extract") + extractDir.deleteRecursively() + extractDir.mkdirs() + val offset = elfPayloadOffset(appImage) + logger.lifecycle("[mpv-multi] DwarFS payload starts at offset $offset") + runChecked( + dwarfsExtract, + "-i", appImage.absolutePath, + "-O", offset.toString(), + "-o", extractDir.absolutePath, + ) + + // sharun keeps the real binary in shared/bin and the closure in shared/lib; shared/bin/mpv + // is the 24 MB PIE that statically links libmpv and exports the full client API (54 + // `mpv_*` dynamic symbols), while bin/mpv is only the ~230 KB sharun launcher. + val sharedDir = extractDir.walkTopDown().firstOrNull { + it.isDirectory && it.name == "shared" && it.resolve("bin/mpv").isFile + } ?: error("shared/bin/mpv not found inside the extracted AppImage") + + outputDir.deleteRecursively() + outputDir.mkdirs() + project.copy { + from(sharedDir.resolve("lib")) + into(outputDir.resolve("lib")) + } + // Named libmpv.so.2 because that is one of MpvLibrary's CANDIDATE_NAMES; JNA passes a + // versioned .so name through unchanged on Linux. + val staged = outputDir.resolve("libmpv.so.2") + sharedDir.resolve("bin/mpv").copyTo(staged, overwrite = true) + staged.setWritable(true) + staged.setExecutable(true) + runChecked("patchelf", "--set-rpath", "\$ORIGIN/lib", staged.absolutePath) + pruneUnreachableSharedObjects(staged, outputDir.resolve("lib")) + logger.lifecycle( + "[mpv-multi] linux-x64: staged libmpv.so.2 + " + + "${outputDir.resolve("lib").listFiles()?.size ?: 0} shared objects", + ) + } } -val vlcSetupAll by tasks.registering { - group = "vlc-multi" - description = "Cross-OS: populate vlc-natives/{linux-x64,macos-arm64,macos-x64,windows-x64,windows-arm64}/ from any host. Use in CI." +// =========================================================================== +// Two entry points, deliberately split. +// +// Everything above turns upstream mpv builds into loadable native slices, and it needs a Mac +// (codesign) plus 7-Zip 21.07+, patchelf and dwarfsextract. Running that in CI would drag all +// of it onto the Ubuntu runner for artifacts that never change between commits. +// +// So it runs ONCE per mpv bump, on a Mac, via `mpvBundleAll` — which also packs the result into +// per-slice tarballs that get attached to a GitHub release. CI then calls `mpvSetupAll`, which +// only downloads and unpacks them: no toolchain, no host requirements, same shape as the old +// vlcSetupAll. +// =========================================================================== +// Kept in a repo of its own rather than SimpMusic's own releases: these archives are ~196 MB per +// mpv bump and would otherwise sit in the release list users browse for the app itself. +val mpvNativesRepo = "maxrave-dev/simpmusic-files" +val mpvNativesTag = "abc" +val mpvSlices = listOf("linux-x64", "macos-arm64", "macos-x64", "windows-x64", "windows-arm64") + +val mpvBundleAll by tasks.registering { + group = "mpv-bundle" + description = "Mac only: build every native slice and pack them into build/mpv-dist/ for a GitHub release." dependsOn( - vlcSetupLinuxCi, - vlcSetupMacArmCi, - vlcSetupMacX64Ci, - vlcSetupWindowsX64Ci, - vlcSetupWindowsArmCi, + mpvSetupLinuxCi, + mpvSetupMacArmCi, + mpvSetupMacX64Ci, + mpvSetupWindowsX64Ci, + mpvSetupWindowsArmCi, ) + val distDir = layout.buildDirectory.dir("mpv-dist") + outputs.dir(distDir) + doLast { + val dist = distDir.get().asFile + dist.deleteRecursively() + dist.mkdirs() + mpvSlices.forEach { slice -> + val sliceDir = rootDir.resolve("mpv-natives/$slice") + check(sliceDir.isDirectory && sliceDir.listFiles()?.isNotEmpty() == true) { + "mpv-natives/$slice is missing or empty — cannot pack an incomplete set" + } + runChecked( + "tar", "-czf", dist.resolve("mpv-natives-$slice.tar.gz").absolutePath, + "-C", rootDir.resolve("mpv-natives").absolutePath, slice, + ) + } + logger.lifecycle("[mpv-bundle] Packed ${mpvSlices.size} slices into ${dist.absolutePath}") + logger.lifecycle("[mpv-bundle] Publish with:") + logger.lifecycle( + " gh release create $mpvNativesTag ${dist.absolutePath}/*.tar.gz " + + "--repo $mpvNativesRepo --title \"Desktop natives (mpv $mpvVersion)\" --notes \"...\"", + ) + } +} + +val mpvSetupAll by tasks.registering { + group = "mpv-multi" + description = "Populate mpv-natives/ from the prebuilt release tarballs. Runs anywhere; this is what CI uses." + val outputRoot = rootDir.resolve("mpv-natives") + outputs.dir(outputRoot) + doLast { + val cache = mpvCacheDir.get().asFile + mpvSlices.forEach { slice -> + val archive = cache.resolve("mpv-natives-$slice-$mpvVersion.tar.gz") + downloadIfMissing( + "https://github.com/$mpvNativesRepo/releases/download/$mpvNativesTag/mpv-natives-$slice.tar.gz", + archive, + logPrefix = "mpv-multi", + ) + val target = outputRoot.resolve(slice) + target.deleteRecursively() + outputRoot.mkdirs() + runChecked("tar", "-xzf", archive.absolutePath, "-C", outputRoot.absolutePath) + check(target.isDirectory) { "$slice missing after unpacking ${archive.name}" } + } + logger.lifecycle("[mpv-multi] Unpacked ${mpvSlices.size} native slices into mpv-natives/") + } } buildkonfig { diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt index 18100c242..97feb54e4 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt @@ -105,6 +105,7 @@ import com.maxrave.domain.utils.LocalResource import com.maxrave.logger.Logger import com.maxrave.simpmusic.Platform import com.maxrave.simpmusic.expect.ui.fileSaverResult +import com.maxrave.simpmusic.expect.ui.isWallpaperDynamicColorSupported import com.maxrave.simpmusic.expect.ui.openEqResult import com.maxrave.simpmusic.extension.bytesToMB import com.maxrave.simpmusic.extension.displayString @@ -121,7 +122,6 @@ import com.maxrave.simpmusic.ui.navigation.destination.login.DiscordLoginDestina import com.maxrave.simpmusic.ui.navigation.destination.login.LoginDestination import com.maxrave.simpmusic.ui.navigation.destination.login.SpotifyLoginDestination import com.maxrave.simpmusic.ui.theme.md_theme_dark_primary -import com.maxrave.simpmusic.expect.ui.isWallpaperDynamicColorSupported import com.maxrave.simpmusic.ui.theme.parseThemeColorHex import com.maxrave.simpmusic.ui.theme.typo import com.maxrave.simpmusic.utils.VersionManager @@ -207,8 +207,8 @@ import simpmusic.composeapp.generated.resources.crossfade_description import simpmusic.composeapp.generated.resources.crossfade_dj_mode import simpmusic.composeapp.generated.resources.crossfade_dj_mode_description import simpmusic.composeapp.generated.resources.crossfade_duration -import simpmusic.composeapp.generated.resources.custom_color import simpmusic.composeapp.generated.resources.custom_ai_model_id +import simpmusic.composeapp.generated.resources.custom_color import simpmusic.composeapp.generated.resources.custom_model_id_messages import simpmusic.composeapp.generated.resources.daily import simpmusic.composeapp.generated.resources.database @@ -1146,20 +1146,20 @@ fun SettingScreen( ) }, ) - if (getPlatform() == Platform.Android) { - SettingItem( - title = stringResource(Res.string.crossfade_dj_mode), - subtitle = - if (castState.isRemote) { - stringResource(Res.string.not_available_while_casting) - } else { - stringResource(Res.string.crossfade_dj_mode_description) - }, - smallSubtitle = true, - switch = ((crossfadeDjMode) to { viewModel.setCrossfadeDjMode(it) }), - isEnable = !castState.isRemote, - ) - } +// if (getPlatform() == Platform.Android) { + SettingItem( + title = stringResource(Res.string.crossfade_dj_mode), + subtitle = + if (castState.isRemote) { + stringResource(Res.string.not_available_while_casting) + } else { + stringResource(Res.string.crossfade_dj_mode_description) + }, + smallSubtitle = true, + switch = ((crossfadeDjMode) to { viewModel.setCrossfadeDjMode(it) }), + isEnable = !castState.isRemote, + ) +// } } } } @@ -1332,7 +1332,12 @@ fun SettingScreen( } item(key = "AI") { Column { - Text(text = stringResource(Res.string.ai), style = typo().labelMedium, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(vertical = 8.dp)) + Text( + text = stringResource(Res.string.ai), + style = typo().labelMedium, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(vertical = 8.dp), + ) SettingItem( title = stringResource(Res.string.ai_provider), subtitle = @@ -2263,8 +2268,16 @@ fun SettingScreen( if (showColorPickerDialog) { val presetColors = listOf( - "FF8ECAE6", "FF4C82EF", "FF9B72CF", "FFEF6C9B", "FFEF5350", - "FFF4A340", "FFFFCA28", "FF66BB6A", "FF26A69A", "FFBDBDBD", + "FF8ECAE6", + "FF4C82EF", + "FF9B72CF", + "FFEF6C9B", + "FFEF5350", + "FFF4A340", + "FFFFCA28", + "FF66BB6A", + "FF26A69A", + "FFBDBDBD", ) var pendingHex by rememberSaveable { mutableStateOf(customThemeColorHex.takeLast(6)) } val parsedColor = parseThemeColorHex(pendingHex) diff --git a/conveyor.conf b/conveyor.conf index d0b26499f..3de99f212 100644 --- a/conveyor.conf +++ b/conveyor.conf @@ -21,9 +21,10 @@ include required("desktopApp/generated.conveyor.conf") // NOTE: deliberately NOT including hydraulic's extract-native-libraries.conf. // That helper extracts every JAR-embedded native into the top-level lib/app -// AND keeps the source tree intact — duplicating our 348 MB VLC plugin set -// (output ballooned from 989 MB to 1.4 GB). VLCJ + JNA load fine because: -// 1. DefaultVlcDiscoverer walks up from the JAR to find vlc/ at runtime +// AND keeps the source tree intact, so every bundled native gets duplicated +// (with the old VLC plugin set that took output from 989 MB to 1.4 GB). +// Nothing needs it: +// 1. MpvLibrary walks up from the JAR to find mpv/ at runtime // 2. JNA's own per-JAR native unpacking still runs without the helper app { @@ -69,10 +70,11 @@ app { // Explicit machines list. Two architectures explicitly dropped because // of missing native dependencies upstream: // - // • linux.aarch64.glibc — VideoLAN does not ship a prebuilt VLC - // binary for Linux ARM ("Linux uses distro package manager"), and - // the mahozad/vlc-plugins-linux Maven artifact is amd64-only. - // Shipping a Linux ARM AppImage would crash on first VLCJ load. + // • linux.aarch64.glibc — dropped when VLC was the backend, because + // VideoLAN ships no prebuilt Linux ARM binary and the + // mahozad/vlc-plugins-linux artifact was amd64-only. That blocker + // died with VLC: mpv-AppImage does publish an aarch64 build, so this + // is worth revisiting once an ARM libmpv slice is staged. // // • windows.aarch64 — androidx.sqlite:sqlite-bundled-jvm 2.6.2 (and // even 2.7.0-alpha05) lacks a windows_arm64 sqliteJni.dll. The @@ -87,16 +89,15 @@ app { "linux.amd64.glibc", ] - // Per-arch VLC native libraries staged by - // `./gradlew :composeApp:vlcSetupAll`. VideoLAN ships separate per-arch - // binaries for Mac (arm64.dmg vs intel64.dmg) and Windows (win64.zip vs - // winarm64.zip), so each per-machine installer only carries the slice - // it actually needs — saves ~25-40 MB per Mac user download vs the - // pre-split universal layout. - windows.amd64.inputs += { from = "vlc-natives/windows-x64", to = "vlc" } - mac.amd64.inputs += { from = "vlc-natives/macos-x64", to = "vlc" } - mac.aarch64.inputs += { from = "vlc-natives/macos-arm64", to = "vlc" } - linux.amd64.inputs += { from = "vlc-natives/linux-x64", to = "vlc" } + // Per-arch libmpv staged by `./gradlew :composeApp:mpvSetupAll`. Each + // per-machine installer carries only the slice it actually needs. + // MpvLibrary resolves this `mpv/` folder by walking up from the JAR + // location — Conveyor does not set compose.application.resources.dir, so + // the packaged app cannot rely on that property. + windows.amd64.inputs += { from = "mpv-natives/windows-x64", to = "mpv" } + mac.amd64.inputs += { from = "mpv-natives/macos-x64", to = "mpv" } + mac.aarch64.inputs += { from = "mpv-natives/macos-arm64", to = "mpv" } + linux.amd64.inputs += { from = "mpv-natives/linux-x64", to = "mpv" } // --- Per-OS overrides ----------------------------------------------------- @@ -143,10 +144,9 @@ app { jvm { modules = "ALL-MODULE-PATH" - // extract-native-libraries = true // disabled: duplicates VLC plugins - // (348 MB) by extracting them flat alongside keeping the original - // tree. VLCJ + JNA load fine without this because DefaultVlcDiscoverer - // walks up from the JAR location to find vlc/ at runtime. + // extract-native-libraries = true // disabled: duplicates the bundled + // natives by extracting them flat while keeping the original tree. + // Not needed — MpvLibrary walks up from the JAR location to find mpv/. options += "--add-opens=java.base/java.nio=ALL-UNNAMED" } diff --git a/core b/core index b1afe0940..359419a41 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit b1afe09409d36fb5c2afa785db97997c5c9c86ef +Subproject commit 359419a41b56dbc2ea5d0abfe5f06f24269c20b5 diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 46992d5bf..36ef0a25b 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -17,7 +17,7 @@ import java.time.format.DateTimeFormatter // * the JVM main() entry // * compose.desktop.application packaging (jpackage path, still used // until Conveyor cutover lands in Task 14) -// * VLC native bundling (vlc-setup) +// * desktop packaging pipelines (Conveyor, AppImage wrapping) // * desktop-only UI (CustomTitleBar, MiniPlayerWindow, CrashDialog, etc.) // // composeApp remains a pure KMP library — its src/jvmMain only carries the @@ -37,15 +37,6 @@ plugins { alias(libs.plugins.conveyor) alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.multiplatform) - // NOTE: `vlc.setup` lives in :composeApp (not here) because its eager - // task iteration at apply time triggers Conveyor's writeConveyorConfig - // creation, which then fails with "Task with name 'jar' not found" — - // jvmJar isn't created until after the script body's `kotlin {}` block - // runs. Plugin order tricks (vlc.setup last, Bifrost ordering) don't - // help because vlc.setup's iteration force-realizes EVERY existing - // task, including the lazily-registered Conveyor ones. Confirmed by - // retry on 2026-05-21: same error reproduced. Run vlcSetup via - // `./gradlew :composeApp:vlcSetup --no-configuration-cache`. } version = libs.versions.version.name.get().removeSuffix("-hf") @@ -101,7 +92,7 @@ kotlin { } // Workaround the Gradle "Cannot mutate configuration after observation" error -// hit when Conveyor 2.0's per-arch deps mix with VLC-setup / compose plugins +// hit when Conveyor 2.0's per-arch deps mix with Conveyor / compose plugins // that resolve runtimeClasspath at configuration time. Creating a sibling // `desktopRuntimeClasspath` configuration shifts Conveyor's resolution off // the primary jvmRuntimeClasspath, breaking the lock chain. @@ -150,18 +141,13 @@ tasks.named("writeConveyorCon } } -// vlcSetup block disabled with the plugin above. VLC natives in -// vlc-natives/{linux,macos,windows}/ are already on disk from prior runs. -// TODO: replace with a simple Gradle download task that doesn't iterate -// tasks at apply time, so Conveyor + vlc-setup can coexist. - compose.desktop { application { mainClass = "com.maxrave.simpmusic.MainKt" jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED" nativeDistributions { - appResourcesRootDir = rootDir.resolve("vlc-natives/") + appResourcesRootDir = rootDir.resolve("mpv-natives/") val listTarget = mutableListOf() if (org.gradle.internal.os.OperatingSystem .current() @@ -252,7 +238,7 @@ afterEvaluate { jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED") jvmArgs("--add-opens", "java.base/java.nio=ALL-UNNAMED") - // Pass bundled VLC natives path to the runtime for `./gradlew desktopApp:run`. + // Pass the bundled natives path to the runtime for `./gradlew desktopApp:run`. val osArch = System.getProperty("os.arch").lowercase() val osSubDir = when { @@ -262,8 +248,24 @@ afterEvaluate { if (osArch.contains("aarch64")) "windows-arm64" else "windows-x64" else -> "linux-x64" } - val vlcNativesPath = rootDir.resolve("vlc-natives/$osSubDir").absolutePath - systemProperty("vlc.bundled.path", vlcNativesPath) + // libmpv is staged by `./gradlew :composeApp:mpvSetupAll`. + // MpvLibrary reads this property and feeds it to jna.library.path. + // + // Without it, JNA cannot find a system libmpv on macOS either: its + // default search list is /usr/lib + /lib, and dyld's leaf-name + // fallback is /usr/local/lib + /usr/lib, so a Homebrew install under + // /opt/homebrew/lib is invisible to both. The bundled path avoids the + // question entirely; `brew install mpv` is only a fallback for a + // checkout that hasn't run mpvSetup yet. + val mpvNativesPath = rootDir.resolve("mpv-natives/$osSubDir") + if (mpvNativesPath.isDirectory) { + systemProperty("mpv.bundled.path", mpvNativesPath.absolutePath) + } else { + logger.info( + "[mpv] ${mpvNativesPath.name} not staged yet — run " + + "`./gradlew :composeApp:mpvSetupAll`. Falling back to a system libmpv.", + ) + } if (System.getProperty("os.name").contains("Mac")) { jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") @@ -292,7 +294,7 @@ afterEvaluate { val conveyorMakeLinuxApp = tasks.register("conveyorMakeLinuxApp") { group = "distribution" description = "Run `conveyor make linux-app` for Linux x86_64 (glibc)." - dependsOn(":composeApp:vlcSetup") + dependsOn(":composeApp:mpvSetupAll") workingDir = rootDir commandLine( "conveyor", @@ -438,11 +440,11 @@ tasks.register("packageConveyorAppImage") { } } -// End-to-end: vlcSetup → conveyor make linux-app → wrap as .AppImage. +// End-to-end: mpvSetupAll → conveyor make linux-app → wrap as .AppImage. // Single command for users: `./gradlew :desktopApp:buildLinuxAppImage --no-configuration-cache` tasks.register("buildLinuxAppImage") { group = "distribution" - description = "Full SimpMusic Desktop Linux AppImage build pipeline (vlcSetup → conveyor → AppImage)." + description = "Full SimpMusic Desktop Linux AppImage build pipeline (mpvSetupAll → conveyor → AppImage)." dependsOn(conveyorMakeLinuxApp) finalizedBy("packageConveyorAppImage") } @@ -455,7 +457,7 @@ tasks.register("buildLinuxAppImage") { val conveyorMakeMacZipAmd64 = tasks.register("conveyorMakeMacZipAmd64") { group = "distribution" description = "Run `conveyor make unnotarized-mac-zip` for macOS Intel." - dependsOn(":composeApp:vlcSetup") + dependsOn(":composeApp:mpvSetupAll") workingDir = rootDir commandLine( "conveyor", @@ -469,7 +471,7 @@ val conveyorMakeMacZipAmd64 = tasks.register("conveyorMakeMacZipAmd64") { val conveyorMakeMacZipAarch64 = tasks.register("conveyorMakeMacZipAarch64") { group = "distribution" description = "Run `conveyor make unnotarized-mac-zip` for macOS Apple Silicon." - dependsOn(":composeApp:vlcSetup") + dependsOn(":composeApp:mpvSetupAll") workingDir = rootDir commandLine( "conveyor", @@ -482,13 +484,13 @@ val conveyorMakeMacZipAarch64 = tasks.register("conveyorMakeMacZipAarch64" tasks.register("buildMacZipAmd64") { group = "distribution" - description = "Full SimpMusic Desktop macOS Intel .zip pipeline (vlcSetup → conveyor)." + description = "Full SimpMusic Desktop macOS Intel .zip pipeline (mpvSetupAll → conveyor)." dependsOn(conveyorMakeMacZipAmd64) } tasks.register("buildMacZipAarch64") { group = "distribution" - description = "Full SimpMusic Desktop macOS Apple Silicon .zip pipeline (vlcSetup → conveyor)." + description = "Full SimpMusic Desktop macOS Apple Silicon .zip pipeline (mpvSetupAll → conveyor)." dependsOn(conveyorMakeMacZipAarch64) } @@ -499,7 +501,7 @@ tasks.register("buildMacZipAarch64") { val conveyorMakeWindowsMsix = tasks.register("conveyorMakeWindowsMsix") { group = "distribution" description = "Run `conveyor make windows-msix` for Windows x86_64." - dependsOn(":composeApp:vlcSetup") + dependsOn(":composeApp:mpvSetupAll") workingDir = rootDir commandLine( "conveyor", @@ -512,7 +514,7 @@ val conveyorMakeWindowsMsix = tasks.register("conveyorMakeWindowsMsix") { tasks.register("buildWindowsMsix") { group = "distribution" - description = "Full SimpMusic Desktop Windows .msix pipeline (vlcSetup → conveyor)." + description = "Full SimpMusic Desktop Windows .msix pipeline (mpvSetupAll → conveyor)." dependsOn(conveyorMakeWindowsMsix) } @@ -520,12 +522,6 @@ tasks.withType().configureEach { notCompatibleWithConfigurationCache("Compose Desktop JPackage tasks are not yet compatible with configuration cache") } -listOf("vlcExtract", "vlcFilterPlugins", "vlcSetup", "clean").forEach { taskName -> - tasks.findByName(taskName)?.let { - it.notCompatibleWithConfigurationCache("vlc-setup plugin tasks are not yet compatible with configuration cache") - } -} - private fun downloadFile( url: String, destFile: java.io.File, diff --git a/desktopApp/src/jvmMain/kotlin/com/maxrave/simpmusic/Main.kt b/desktopApp/src/jvmMain/kotlin/com/maxrave/simpmusic/Main.kt index 33f7df75c..fe9af8d3c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/maxrave/simpmusic/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/maxrave/simpmusic/Main.kt @@ -5,7 +5,7 @@ import java.awt.Toolkit /** * Thin entry point for SimpMusic Desktop. * - * All window setup, VLC bootstrap, Sentry init, Koin loading, deep link + * All window setup, Sentry init, Koin loading, deep link * handling, mini-player wiring, and tray integration live in * `composeApp/src/jvmMain/.../main.kt` as `fun runDesktopApp()`. That keeps * the shared module self-contained (it can still be launched directly diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76400f0fa..ac9eb8d9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,8 +71,6 @@ composeMultiplatform = "1.11.1" datetime = "0.8.0" sqlite = "2.6.2" okio = "3.17.0" -vlcj = "4.12.1" -vlc = "3.0.23" material3-multiplatform = "1.12.0-alpha01" # alpha01 = skiko 0.148.1 (has Matrix33.makeTranslate) to match compottie 1.12 snapshot; alpha02 bumps skiko 0.148.2 which removed it → compottie crash adaptive = "1.2.0" material-icons-multiplatform = "1.7.3" @@ -219,7 +217,6 @@ file-picker = { module = "com.mohamedrejeb.calf:calf-file-picker", version.ref = native-tray = { module = "io.github.kdroidfilter:composenativetray", version.ref = "tray" } #JVM -vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jnaPlatform" } nowplaying = { module = "org.simpmusic:nowplayingcenter", version = "0.0.3" } @@ -271,5 +268,4 @@ packagedeps = { id = "io.github.kdroidfilter.compose.linux.packagedeps", version # JVM osdetector = { id = "com.google.osdetector", version.ref = "osdetector" } -vlc-setup = { id = "ir.mahozad.vlc-setup", version = "0.1.0" } conveyor = { id = "dev.hydraulic.conveyor", version.ref = "conveyor" } From dde01ee4e28bee576b6c1588a4ad861fdb5a42e4 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Mon, 27 Jul 2026 21:53:01 +0700 Subject: [PATCH 11/47] fix(desktop): raise macOS floor to 15.0 for bundled libmpv --- CLAUDE.md | 1 + conveyor.conf | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 928a41800..64e9bd0c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -333,6 +333,7 @@ Before implementing code, researching code, or answering technical questions, th #### Desktop - **Required Dependencies**: - libmpv: audio + video playback (bundled via `mpvSetupAll`; falls back to a system-wide libmpv when `mpv-natives/` has not been staged) +- **Minimum macOS: 15.0** — raised from 11.0 when VLC was replaced by mpv. mpv's macOS release builds target macOS 15 (96/98 arm64 dylibs declare `minos 15.0`; on Intel `libmpv` itself does), and Conveyor rejects a lower `LSMinimumSystemVersion`. No mpv artifact covers both architectures below 15. - **Features**: - Deep link support (`simpmusic://` and `simpmusic.org`) - Mini Player window (always-on-top, resizable, draggable) diff --git a/conveyor.conf b/conveyor.conf index 3de99f212..0f829fe9a 100644 --- a/conveyor.conf +++ b/conveyor.conf @@ -121,6 +121,14 @@ app { info-plist.LSApplicationCategoryType = "public.app-category.music" info-plist.UIBackgroundModes = [ "audio", "fetch", "processing" ] url-schemes = [ "simpmusic" ] + + // Raised from the implicit 11.0 when the desktop backend moved from VLC to mpv. + // mpv's own macOS release builds target macOS 15: 96 of the 98 bundled arm64 dylibs + // declare minos 15.0, and on Intel libmpv itself does — so Conveyor refuses to package + // an app claiming 11.0 ("Required macOS version mismatch"). VideoLAN still built for + // older systems, which is the only reason 11.0 worked before. There is no mpv artifact + // for macOS < 15 covering both architectures, so this is the floor. + info-plist.LSMinimumSystemVersion = 15.0 } windows { From 1712819ecb6eb5888cf82782adc9ff8eacfa78b2 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 00:34:16 +0700 Subject: [PATCH 12/47] fix(desktop): stage natives in release workflow and verify libmpv bundles --- .github/workflows/android-release.yml | 27 ++-- CLAUDE.md | 6 +- composeApp/build.gradle.kts | 190 +++++++++++++++++++++++--- conveyor.conf | 12 ++ core | 2 +- 5 files changed, 206 insertions(+), 31 deletions(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 5a108c42e..b3590e74e 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -127,19 +127,13 @@ jobs: distribution: "microsoft" cache: 'gradle' - - name: Install 7-Zip 24.x + libfuse2 - # Ubuntu's p7zip-full (16.02) can't extract HFS+ DMG content fully — - # outer kolyDMG opens but the inner HFS+ partition stays opaque, so - # VLC.app never materializes. Use the official Igor Pavlov build, - # which has full HFS+ support like the modern Linux/macOS package. - # libfuse2 is needed by appimagetool when wrapping the Linux app. + - name: Install libfuse2 + # Needed by appimagetool when wrapping the Linux app. Nothing else is + # required here: mpvSetupAll only downloads and untars the prebuilt + # native slices, so no 7-Zip, patchelf or dwarfs toolchain on the runner. run: | sudo apt-get update sudo apt-get install -y libfuse2 - curl -fsSL https://github.com/ip7z/7zip/releases/download/26.01/7z2601-linux-x64.tar.xz -o /tmp/7zz.tar.xz - sudo tar -xf /tmp/7zz.tar.xz -C /usr/local/bin/ 7zz - sudo ln -sf /usr/local/bin/7zz /usr/local/bin/7z - 7z i | head -3 - name: Update build product flavor run: | @@ -157,6 +151,19 @@ jobs: - name: Generate aboutLibraries.json run: ./gradlew exportLibraryDefinitions + - name: Cache mpv-natives + uses: actions/cache@v4 + with: + path: mpv-natives + key: mpv-natives-${{ hashFiles('composeApp/build.gradle.kts') }} + + - name: Populate mpv-natives for all OSes + # Conveyor is invoked by its own action further down, NOT through Gradle, + # so the dependsOn(":composeApp:mpvSetupAll") wired into the conveyor* + # tasks never fires on this path — the natives have to be staged here + # explicitly or the installers ship without libmpv. + run: ./gradlew :composeApp:mpvSetupAll --no-configuration-cache + - name: Generate Conveyor config # Run writeConveyorConfig BEFORE Conveyor so the parser only reads # the static generated.conveyor.conf and never tastes Gradle stdout diff --git a/CLAUDE.md b/CLAUDE.md index 64e9bd0c7..b9ed0d577 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -485,7 +485,11 @@ if (getPlatform() == Platform.Android) { - **Google Cast (2026-07, Full build only)**: `cast`/`cast-empty` module pair gated by `isFullBuild`; unified Media3 `CastPlayer` wraps the session `ForwardingPlayer`; `CastHandoffManager` pushes resolved-URL queue windows to the receiver with 403/expiry retry; Cast button in Now Playing top bar, "Playing on " pill, crossfade/DJ/EQ settings gray out while casting; FOSS build stays GMS-free - **Windows SMTC (2026-07)**: System Media Transport Controls on Windows via `jmtc`/`nowplayingcenter` 0.0.3 (forked JMTC). The native `SMTCAdapter.dll` was hardened against the 1.0.x crash (Sentry SIMPMUSIC-DESKTOP-7, ~95k events): COM apartment tolerates `RPC_E_CHANGED_MODE`, `MediaPlayer` kept alive process-wide, and every exported call is exception-guarded so nothing crosses the JNA boundary as "Invalid memory access". JMTC is confined to a dedicated thread (off the AWT EDT), and `MediaType.Music` is set before display properties so title/artist render (not just the app name). Enabled in `JvmMediaPlayerHandlerImpl` for `Platform.Windows` (Linux MPRIS unchanged; macOS uses NowPlayingCenter). DLL built by GitHub Actions (`windows-latest`) in the NowPlayingCenter repo. - **VLC removed entirely (2026-07-27)**: `VlcPlayerAdapter`, `DefaultVlcDiscoverer`, `MacOsVlcDiscoverer` and `VlcModule` are deleted; `VlcModule.kt` became `DesktopPlayerModule.kt` (`loadVlcModule()` → `loadDesktopPlayerModule()`). The `vlcj` dependency, the `vlc-setup` Gradle plugin, every `vlcSetup*` task, the `vlc-natives/` tree and the VLC Conveyor inputs are all gone. `appResourcesRootDir` now points at `mpv-natives/`. libmpv is the only desktop backend. -- **Bundled libmpv (2026-07-27)**: `./gradlew :composeApp:mpvSetupAll` stages libmpv + its dependency closure into `mpv-natives/{linux-x64,macos-arm64,macos-x64,windows-x64,windows-arm64}/`, wired into installers via `mpv-natives` inputs in `conveyor.conf` (`to = "mpv"`). Sources: shinchiro `mpv-dev-*.7z` (Windows, self-contained), pkgforge-dev mpv-AppImage (Linux, sharun `$ORIGIN`), Homebrew + `dylibbundler` (macOS). **The macOS slice needs a macOS runner** — one per architecture — because the closure is gathered from a Homebrew install; `mpvSetupMacCi` skips itself on other hosts. Do NOT try to lift `IINA.app/Contents/Frameworks` instead: IINA 1.4.4 ships a version-skewed pair (libmpv needs `_pl_log_create_349`, bundled `libplacebo.338.dylib` exports `_pl_log_create_338`) and that libmpv fails `dlopen` under both RTLD_NOW and RTLD_LAZY. `MpvLibrary.bundledLibraryDirs()` resolves the staged folder (`mpv.bundled.path` → `compose.application.resources.dir` → `mpv/` found by walking up from the JAR → `mpv-natives/-`), mirroring `DefaultVlcDiscoverer`. +- **Bundled libmpv (2026-07-27)**: two entry points, deliberately split. `:composeApp:mpvBundleAll` runs **on a Mac, once per mpv bump** — it turns upstream mpv builds into loadable slices in `mpv-natives/-/`, packs them into tarballs and prints their SHA-256. Those are published to `maxrave-dev/simpmusic-files`. `:composeApp:mpvSetupAll` is what **CI** runs: it downloads those tarballs, verifies them against the digests pinned in `mpvNativesChecksums`, and unpacks them — no toolchain needed on the runner. Both workflows must call it before Conveyor, which is invoked by its own action and so never triggers the Gradle `dependsOn`. + - Sources: shinchiro `mpv-dev-*.7z` (Windows — the only one shipping a real `libmpv-2.dll`), mpv's own release `.zip` (macOS), pkgforge-dev mpv-AppImage (Linux, DwarFS payload). On macOS and Linux **libmpv is statically linked into the `mpv` executable**; those PIE binaries export the full client API and are renamed to `libmpv.dylib` / `libmpv.so.2`, with load-command paths repointed to `@loader_path` / `$ORIGIN`. + - Do NOT lift `IINA.app/Contents/Frameworks` instead: IINA 1.4.4 ships a version-skewed pair (libmpv needs `_pl_log_create_349`, bundled `libplacebo.338.dylib` exports `_pl_log_create_338`) and that libmpv fails `dlopen` under both RTLD_NOW and RTLD_LAZY. + - The Linux pruner must seed its reachability walk from the subdirectory plugins too (`lib/pulseaudio/`, `lib/alsa-lib/`, …) — they are `dlopen`ed, so no `DT_NEEDED` names them, and seeding only from libmpv once deleted `libsndfile`/`libasyncns` and shipped an unloadable slice. It asserts afterwards that every retained object resolves. + - `MpvLibrary.bundledLibraryDirs()` resolves the staged folder: `mpv.bundled.path` → `compose.application.resources.dir` → `mpv/` found by walking up from the JAR → `mpv-natives/-`. ## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b66b5fdd8..d112c4cfb 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -6,6 +6,7 @@ import org.gradle.api.file.RelativePath import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.net.URI +import java.security.MessageDigest import java.util.Properties val isFullBuild: Boolean = @@ -244,9 +245,18 @@ fun downloadIfMissing(url: String, target: java.io.File, logPrefix: String = "mp // Windows shinchiro/mpv-winbuild-cmake `mpv-dev-.7z` // → libmpv-2.dll with ffmpeg linked in. Nothing to patch. // Linux pkgforge-dev/mpv-AppImage -// → libmpv.so.2 + closure under shared/lib with RPATH=$ORIGIN -// (sharun), so nothing to patch there either. -// macOS Homebrew's mpv + dylibbundler, run on a macOS host. +// → no libmpv.so either; shared/bin/mpv is a PIE exporting the API, +// renamed to libmpv.so.2 and given an $ORIGIN/lib rpath. +// macOS mpv's own tagged release .zip (macos-15-arm / macos-15-intel) +// → Contents/MacOS/mpv renamed to libmpv.dylib, its +// @executable_path/lib/... load commands rewritten to +// @loader_path, then re-signed ad-hoc. +// +// On macOS and Linux mpv links libmpv STATICALLY into the `mpv` executable, so +// there is no shared library to copy. Those executables are PIE and export the +// full client API (54 `mpv_*` symbols), which is why renaming them works at +// all — and why the rename matters: JNA maps Native.load("mpv") to +// libmpv.dylib / libmpv.so. Only Windows publishes a real `mpv-dev` package. // // macOS is the odd one out and deliberately so. The obvious shortcut — lifting // IINA.app/Contents/Frameworks straight out of IINA's .dmg — DOES NOT WORK, @@ -263,14 +273,12 @@ fun downloadIfMissing(url: String, target: java.io.File, logPrefix: String = "mp // makes IINA itself work, that closure is not self-sufficient, and MpvLibrary // loads with RTLD_NOW by design, so there is no flag to hide behind. // -// Homebrew resolves mpv and libplacebo as one dependency graph, so its closure -// is self-consistent by construction. dylibbundler then copies that closure and -// rewrites every dependency to @loader_path/... in one pass. The dylibs must be -// re-signed ad-hoc afterwards: mutating a Mach-O invalidates its signature and -// macOS refuses to load an invalidly-signed dylib (hard failure on Apple -// Silicon). Cost of this route: the macOS slice needs a macOS runner, one per -// architecture — it cannot be produced from the Linux runner that builds -// every other slice. +// mpv's own release zip avoids that entirely: libmpv and its closure come out +// of one build, so they cannot be version-skewed against each other. The +// dylibs must be re-signed ad-hoc after patching — mutating a Mach-O +// invalidates its signature and macOS refuses to load an invalidly-signed +// dylib (a hard failure on Apple Silicon). That signing step is the only +// reason these tasks need a Mac. // // Conveyor stages one slice per machine — see the mpv-natives inputs in // conveyor.conf. @@ -321,6 +329,19 @@ fun runChecked(vararg command: String) { check(exit == 0) { "Command failed (exit $exit): ${command.joinToString(" ")}" } } +fun sha256(file: java.io.File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { stream -> + val buffer = ByteArray(1 shl 16) + while (true) { + val read = stream.read(buffer) + if (read <= 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } +} + // --------------------------------------------------------------------------- // macOS // --------------------------------------------------------------------------- @@ -457,6 +478,13 @@ fun extractMacMpvSlice(assetArch: String, outputDir: java.io.File) { dylib.setWritable(true) rewritten += rewriteExecutablePathRefs(dylib, "@loader_path/") } + // Assert rather than just log: if upstream ever switches to @rpath/ or @loader_path/ install + // names this silently rewrites nothing, signs the result, and publishes a slice whose dylibs + // resolve against an @executable_path that means nothing under the JVM. + check(rewritten > 0) { + "No @executable_path/lib/ entries found in $assetArch — mpv's macOS layout changed, " + + "rewriteExecutablePathRefs() needs updating before this slice can be published." + } logger.lifecycle("[mpv-multi] $assetArch: rewrote $rewritten install-name entries") codesignAdhoc(outputDir) } @@ -465,6 +493,7 @@ val mpvSetupMacArmCi by tasks.registering { group = "mpv-multi" description = "Cross-OS: populate mpv-natives/macos-arm64/ with libmpv + its dylib closure." val outputDir = rootDir.resolve("mpv-natives/macos-arm64/") + inputs.property("mpvVersion", mpvVersion) outputs.dir(outputDir) doLast { extractMacMpvSlice("macos-15-arm", outputDir) } } @@ -473,6 +502,7 @@ val mpvSetupMacX64Ci by tasks.registering { group = "mpv-multi" description = "Cross-OS: populate mpv-natives/macos-x64/ with Intel libmpv + its dylib closure." val outputDir = rootDir.resolve("mpv-natives/macos-x64/") + inputs.property("mpvVersion", mpvVersion) outputs.dir(outputDir) doLast { extractMacMpvSlice("macos-15-intel", outputDir) } } @@ -527,6 +557,7 @@ val mpvSetupWindowsX64Ci by tasks.registering { group = "mpv-multi" description = "Cross-OS: populate mpv-natives/windows-x64/ with libmpv-2.dll." val outputDir = rootDir.resolve("mpv-natives/windows-x64/") + inputs.property("mpvWinBuildSuffix", mpvWinBuildSuffix) outputs.dir(outputDir) doLast { extractWindowsMpvSlice("x86_64", outputDir) } } @@ -535,6 +566,7 @@ val mpvSetupWindowsArmCi by tasks.registering { group = "mpv-multi" description = "Cross-OS: populate mpv-natives/windows-arm64/ with ARM64 libmpv-2.dll." val outputDir = rootDir.resolve("mpv-natives/windows-arm64/") + inputs.property("mpvWinBuildSuffix", mpvWinBuildSuffix) outputs.dir(outputDir) doLast { extractWindowsMpvSlice("aarch64", outputDir) } } @@ -627,33 +659,113 @@ fun elfNeeded(file: java.io.File): List { * 350 shared objects / 373 MB. libmpv's own closure is 91 of them / 54 MB, and the rest would be * dead weight in every Linux installer. */ +/** + * Shared objects the host always provides, so a `DT_NEEDED` naming one is not a missing bundle + * entry. Deliberately short: anything outside the glibc/libgcc core is expected to ship with us. + */ +val systemProvidedSharedObjects = + setOf( + "libc.so.6", "libm.so.6", "libdl.so.2", "libpthread.so.0", "librt.so.1", + "libresolv.so.2", "libutil.so.1", "libnsl.so.1", "libcrypt.so.1", + "libgcc_s.so.1", "ld-linux-x86-64.so.2", "ld-linux-aarch64.so.1", + ) + +/** + * Give every shared object in [libDir] an rpath that reaches the rest of the bundle. + * + * sharun's tree is not flat: plugin hosts keep their real implementation in a subdirectory + * (`lib/pulseaudio/libpulsecommon-*.so`, `lib/alsa-lib/`, `lib/pipewire-0.3/`, `lib/spa-0.2/`, + * `lib/gbm/`). On a normal system those are found through each library's own RUNPATH; the copies + * here have none, so without this `libpulse.so.0` cannot see `libpulsecommon` even though both + * are in the bundle. + */ +fun setBundleRpaths(libDir: java.io.File) { + val subDirs = libDir.listFiles()?.filter { it.isDirectory }?.map { it.name }.orEmpty().sorted() + val topRpath = (listOf("\$ORIGIN") + subDirs.map { "\$ORIGIN/$it" }).joinToString(":") + libDir.walkTopDown().filter { it.isFile && it.name.contains(".so") }.forEach { so -> + val rpath = if (so.parentFile == libDir) topRpath else "\$ORIGIN:\$ORIGIN/.." + so.setWritable(true) + // Objects that are not ELF (stray data files) make patchelf fail; skip them quietly + // rather than aborting the whole slice. + ProcessBuilder("patchelf", "--set-rpath", rpath, so.absolutePath) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + .waitFor() + } +} + +/** + * Delete top-level shared objects in [libDir] that nothing in the bundle links against. + * + * sharun bundles whatever the mpv *player* needs — X11, wayland, pulse, GTK and more — which is + * 350 shared objects / 373 MB. libmpv's own closure is a fraction of that, and the rest would be + * dead weight in every Linux installer. + * + * Two things this must get right, both learned the hard way: + * + * - The walk seeds from [root] **and from every shared object in a subdirectory**. Those + * subdirectory objects are plugins loaded with `dlopen` at runtime, so nothing names them in a + * `DT_NEEDED` — but their own dependencies are very much needed. Seeding only from [root] + * deleted `libsndfile.so.1` and `libasyncns.so.0`, which `libpulsecommon-17.0.so` needs, and + * shipped a `libmpv.so.2` that failed to load. + * - It asserts afterwards that every retained object resolves. A pruner that guesses wrong + * should fail here, on the machine building the bundle, not on a user's machine. + */ fun pruneUnreachableSharedObjects(root: java.io.File, libDir: java.io.File) { - val present = libDir.listFiles()?.filter { it.isFile }?.associateBy { it.name }.orEmpty() + val allObjects = libDir.walkTopDown().filter { it.isFile && it.name.contains(".so") }.toList() + val byName = allObjects.associateBy { it.name } + val topLevel = allObjects.filter { it.parentFile == libDir } + val reachable = mutableSetOf() val queue = ArrayDeque() queue += root + // Plugins in subdirectories are dlopen'ed, never named in a DT_NEEDED — seed them explicitly + // so their dependencies survive the prune. + allObjects.filter { it.parentFile != libDir }.forEach { + reachable += it.name + queue += it + } while (queue.isNotEmpty()) { elfNeeded(queue.removeFirst()).forEach { name -> - if (reachable.add(name)) present[name]?.let { queue += it } + if (reachable.add(name)) byName[name]?.let { queue += it } } } + var freed = 0L - present.forEach { (name, file) -> - if (name !in reachable) { + var deleted = 0 + topLevel.forEach { file -> + if (file.name !in reachable) { freed += file.length() + deleted++ file.delete() } } logger.lifecycle( - "[mpv-multi] Pruned ${present.size - reachable.size} unreachable shared objects " + - "(${freed / 1048576} MB); kept ${reachable.size}", + "[mpv-multi] Pruned $deleted unreachable shared objects (${freed / 1048576} MB); " + + "kept ${allObjects.size - deleted}", ) + + // Post-condition: nothing left behind may reference something that is no longer here. + val remaining = libDir.walkTopDown().filter { it.isFile && it.name.contains(".so") }.toList() + root + val remainingNames = remaining.map { it.name }.toSet() + val dangling = + remaining.flatMap { obj -> + elfNeeded(obj) + .filter { it !in remainingNames && it !in systemProvidedSharedObjects } + .map { "${obj.name} → $it" } + } + check(dangling.isEmpty()) { + "Pruning left unresolvable dependencies — the slice would fail to load at runtime:\n" + + dangling.joinToString("\n") { " $it" } + } } val mpvSetupLinuxCi by tasks.registering { group = "mpv-multi" description = "Cross-OS: populate mpv-natives/linux-x64/ with libmpv + its .so closure." val outputDir = rootDir.resolve("mpv-natives/linux-x64/") + inputs.property("mpvAppImageTag", mpvAppImageTagEncoded) outputs.dir(outputDir) doLast { // patchelf is the one genuinely host-specific step. The binary carries NO @@ -754,6 +866,7 @@ val mpvSetupLinuxCi by tasks.registering { staged.setExecutable(true) runChecked("patchelf", "--set-rpath", "\$ORIGIN/lib", staged.absolutePath) pruneUnreachableSharedObjects(staged, outputDir.resolve("lib")) + setBundleRpaths(outputDir.resolve("lib")) logger.lifecycle( "[mpv-multi] linux-x64: staged libmpv.so.2 + " + "${outputDir.resolve("lib").listFiles()?.size ?: 0} shared objects", @@ -806,6 +919,10 @@ val mpvBundleAll by tasks.registering { ) } logger.lifecycle("[mpv-bundle] Packed ${mpvSlices.size} slices into ${dist.absolutePath}") + logger.lifecycle("[mpv-bundle] Paste these into mpvNativesChecksums:") + mpvSlices.forEach { slice -> + logger.lifecycle(" \"$slice\" to \"${sha256(dist.resolve("mpv-natives-$slice.tar.gz"))}\",") + } logger.lifecycle("[mpv-bundle] Publish with:") logger.lifecycle( " gh release create $mpvNativesTag ${dist.absolutePath}/*.tar.gz " + @@ -814,27 +931,62 @@ val mpvBundleAll by tasks.registering { } } +/** + * SHA-256 of every published tarball, filled in by `mpvBundleAll` after a bump. + * + * Pinned in the build script on purpose, rather than read from the release's own `SHA256SUMS` + * asset: a checksum served from the same place as the artifact catches corruption but not anyone + * able to replace release assets — and these files are unpacked straight into the tree Conveyor + * signs. The release tag is mutable, so this is the only thing actually pinning what gets shipped. + */ +val mpvNativesChecksums = + mapOf( + "linux-x64" to "ec64ce2c75c134b4283968a3f9993e40bb05589f406206a0e1d4536e08f62570", + "macos-arm64" to "9a44626c14526ed92ef913e876c2f2ef6fa640d946ab5e1b2bb4041d32893006", + "macos-x64" to "df4e0cc5f80b261a6e616febbca49c20f15f66f0c197dc1e3996f4e28dd5ac8f", + "windows-x64" to "7a3da0d920261d016a07ffed119e8f604084c7c0b4610301a8ec45445abe21f9", + "windows-arm64" to "1b4762a10e7aecfe3c12669df2e2ff7e19d374dd99c193b80889cfaf36cbe93d", + ) + + val mpvSetupAll by tasks.registering { group = "mpv-multi" description = "Populate mpv-natives/ from the prebuilt release tarballs. Runs anywhere; this is what CI uses." val outputRoot = rootDir.resolve("mpv-natives") + // Without declared inputs Gradle treats an existing output directory as up to date, so bumping + // the tag or the mpv version would silently keep shipping the previous natives. + inputs.property("mpvNativesTag", mpvNativesTag) + inputs.property("mpvNativesChecksums", mpvNativesChecksums) outputs.dir(outputRoot) doLast { val cache = mpvCacheDir.get().asFile mpvSlices.forEach { slice -> - val archive = cache.resolve("mpv-natives-$slice-$mpvVersion.tar.gz") + // Cache key includes the tag, not just the version: re-publishing corrected natives + // under a new tag at the same mpv version must not reuse the stale download. + val archive = cache.resolve("mpv-natives-$slice-$mpvNativesTag.tar.gz") downloadIfMissing( "https://github.com/$mpvNativesRepo/releases/download/$mpvNativesTag/mpv-natives-$slice.tar.gz", archive, logPrefix = "mpv-multi", ) + val expected = mpvNativesChecksums.getValue(slice) + val actual = sha256(archive) + check(expected != "PENDING") { + "No checksum pinned for $slice. Run `:composeApp:mpvBundleAll`, publish the " + + "archives, then paste the printed digests into mpvNativesChecksums." + } + check(actual == expected) { + // Delete it so a genuinely corrupt download can be retried rather than cached. + archive.delete() + "Checksum mismatch for mpv-natives-$slice.tar.gz\n expected $expected\n actual $actual" + } val target = outputRoot.resolve(slice) target.deleteRecursively() outputRoot.mkdirs() runChecked("tar", "-xzf", archive.absolutePath, "-C", outputRoot.absolutePath) check(target.isDirectory) { "$slice missing after unpacking ${archive.name}" } } - logger.lifecycle("[mpv-multi] Unpacked ${mpvSlices.size} native slices into mpv-natives/") + logger.lifecycle("[mpv-multi] Unpacked ${mpvSlices.size} verified native slices into mpv-natives/") } } diff --git a/conveyor.conf b/conveyor.conf index 0f829fe9a..c8e5b0ee7 100644 --- a/conveyor.conf +++ b/conveyor.conf @@ -134,6 +134,12 @@ app { windows { start-menu.group = "SimpMusic" url-schemes = [ "simpmusic" ] + + // DEBUG ONLY — remove before shipping a release. + // A Windows GUI binary has no console attached, so everything the app writes to + // stdout/stderr is discarded and a startup failure leaves nothing to look at. + // This attaches a console window that shows both. + console = true } linux { @@ -156,6 +162,12 @@ app { // natives by extracting them flat while keeping the original tree. // Not needed — MpvLibrary walks up from the JAR location to find mpv/. options += "--add-opens=java.base/java.nio=ALL-UNNAMED" + + // Without an explicit ceiling the JVM sizes its heap at 1/4 of physical RAM, + // so on a 32 GB machine it will happily grow to several GB before the GC feels + // any pressure — which is why resident memory sat around 1 GB. A music player's + // live set is nowhere near that; the cap makes the GC collect on a sane cadence. + options += "-Xmx512m" } jvm.mac.options += "--add-opens=java.desktop/sun.awt=ALL-UNNAMED" diff --git a/core b/core index 359419a41..06089d22d 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 359419a41b56dbc2ea5d0abfe5f06f24269c20b5 +Subproject commit 06089d22d956b1abe4e964dfebd4a7f9dbe3654c From 004519e9c1ed63997f4f18207c7c8428b0d39a26 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 11:35:21 +0700 Subject: [PATCH 13/47] feat(desktop): build Linux libmpv from source instead of extracting the AppImage --- composeApp/build.gradle.kts | 367 +++++++---------------------------- scripts/mpv-linux/Dockerfile | 100 ++++++++++ scripts/mpv-linux/stage.sh | 101 ++++++++++ 3 files changed, 271 insertions(+), 297 deletions(-) create mode 100644 scripts/mpv-linux/Dockerfile create mode 100755 scripts/mpv-linux/stage.sh diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index d112c4cfb..3815b2d25 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -244,9 +244,16 @@ fun downloadIfMissing(url: String, target: java.io.File, logPrefix: String = "mp // // Windows shinchiro/mpv-winbuild-cmake `mpv-dev-.7z` // → libmpv-2.dll with ffmpeg linked in. Nothing to patch. -// Linux pkgforge-dev/mpv-AppImage -// → no libmpv.so either; shared/bin/mpv is a PIE exporting the API, -// renamed to libmpv.so.2 and given an $ORIGIN/lib rpath. +// Linux built from source in a container — see scripts/mpv-linux/. +// → the ONE platform with no usable upstream. Every prebuilt Linux +// mpv targets "run mpv as its own process": the AppImage ships its +// own glibc + loader and exports the API from a PIE *executable*, +// which glibc flatly refuses to dlopen, and whose glibc would +// collide with the one the JVM has already mapped. Distro packages +// trade that for a version floor set by the distro. Building +// against Ubuntu 22.04 gives a real libmpv.so.2 that needs only +// glibc 2.34, so it loads in-process from Ubuntu 22.04 / Debian 11 +// upwards. // macOS mpv's own tagged release .zip (macos-15-arm / macos-15-intel) // → Contents/MacOS/mpv renamed to libmpv.dylib, its // @executable_path/lib/... load commands rewritten to @@ -293,14 +300,8 @@ val mpvCacheDir = layout.buildDirectory.dir("mpv-cache") val mpvVersion = "0.41.0" val mpvWinBuildTag = "20260610" val mpvWinBuildSuffix = "20260610-git-304426c" -// Percent-encoded because the release tag embeds an '@'. Kept as a literal rather than -// URLEncoder.encode(): in a build script `java` resolves to the JavaPluginExtension -// accessor, so `java.net.URLEncoder` is unresolvable in expression position. -val mpvAppImageTagEncoded = "v0.41.0%402026-07-01_1782914175" -val mpvAppImageVersion = "v0.41.0" -// Reads the AppImage's DwarFS payload. 0.15.6 handles DwarFS v2.5, which is what this -// AppImage carries; an older dwarfs reports "unsupported major version". -val dwarfsVersion = "0.15.6" +// Linux has no upstream pin: that slice is compiled from source by +// scripts/mpv-linux/Dockerfile, which pins mpv, FFmpeg and libplacebo itself. // Every extractor below finds the directory that actually holds the libmpv // artifact and copies its whole sibling set, rather than hard-coding upstream @@ -329,6 +330,15 @@ fun runChecked(vararg command: String) { check(exit == 0) { "Command failed (exit $exit): ${command.joinToString(" ")}" } } +/** Like [runChecked], but returns the command's trimmed stdout instead of forwarding it. */ +fun runCapturing(vararg command: String): String { + val process = ProcessBuilder(*command).redirectErrorStream(false).start() + val output = process.inputStream.bufferedReader().readText() + val exit = process.waitFor() + check(exit == 0) { "Command failed (exit $exit): ${command.joinToString(" ")}" } + return output.trim() +} + fun sha256(file: java.io.File): String { val digest = MessageDigest.getInstance("SHA-256") file.inputStream().use { stream -> @@ -575,301 +585,64 @@ val mpvSetupWindowsArmCi by tasks.registering { // Linux // --------------------------------------------------------------------------- -/** - * Offset of the payload appended after an ELF file, i.e. the end of the ELF proper. - * - * AppImages are an ELF runtime with a filesystem image concatenated onto it, and the image starts - * exactly where the section-header table ends: `e_shoff + e_shnum * e_shentsize`. - * - * Do NOT try to find the payload by scanning for its magic instead. This runtime embeds the - * strings `DWARFS_BLOCK_SIZE`, `DWARFS_CACHE_SIZE` and friends as environment-variable names, so - * the first `DWARFS` hit lands ~1.1 MB before the real image and every extractor then reports - * "unsupported major version". - */ -fun elfPayloadOffset(file: java.io.File): Long { - val header = ByteArray(64) - file.inputStream().use { check(it.read(header) == 64) { "${file.name} is too small to be an ELF" } } - check(header[0] == 0x7f.toByte() && header[1] == 'E'.code.toByte()) { "${file.name} is not an ELF file" } - // Hand-rolled little-endian reads rather than java.nio.ByteBuffer: inside a build script - // `java` resolves to the JavaPluginExtension accessor, so java.* only works in type position. - fun le(offset: Int, size: Int): Long { - var value = 0L - for (i in size - 1 downTo 0) value = (value shl 8) or (header[offset + i].toLong() and 0xff) - return value - } - val shoff = le(0x28, 8) - val shentsize = le(0x3a, 2) - val shnum = le(0x3c, 2) - return shoff + shentsize * shnum -} - -/** - * `DT_NEEDED` entries of an ELF file, i.e. the shared objects it links against directly. - * - * Enough of a parser to walk a dependency closure: section headers → `.dynamic` → `.dynstr`. - * Returns empty for anything that isn't an ELF with section headers. - */ -fun elfNeeded(file: java.io.File): List { - val d = file.readBytes() - if (d.size < 64 || d[0] != 0x7f.toByte() || d[1] != 'E'.code.toByte()) return emptyList() - fun le(off: Int, size: Int): Long { - var v = 0L - for (i in size - 1 downTo 0) v = (v shl 8) or (d[off + i].toLong() and 0xff) - return v - } - val shoff = le(0x28, 8).toInt() - val shentsize = le(0x3a, 2).toInt() - val shnum = le(0x3c, 2).toInt() - val shstrndx = le(0x3e, 2).toInt() - if (shnum == 0 || shoff == 0) return emptyList() - fun sectionName(i: Int) = le(shoff + i * shentsize, 4).toInt() - fun sectionOff(i: Int) = le(shoff + i * shentsize + 0x18, 8).toInt() - fun sectionSize(i: Int) = le(shoff + i * shentsize + 0x20, 8).toInt() - fun cstr(base: Int, offset: Int): String { - var e = base + offset - while (e < d.size && d[e] != 0.toByte()) e++ - return String(d, base + offset, e - (base + offset), Charsets.US_ASCII) - } - val shstrBase = sectionOff(shstrndx) - var dynamicIdx = -1 - var dynstrIdx = -1 - for (i in 0 until shnum) { - when (cstr(shstrBase, sectionName(i))) { - ".dynamic" -> dynamicIdx = i - ".dynstr" -> dynstrIdx = i - } - } - if (dynamicIdx < 0 || dynstrIdx < 0) return emptyList() - val dynOff = sectionOff(dynamicIdx) - val strBase = sectionOff(dynstrIdx) - val result = mutableListOf() - for (i in 0 until sectionSize(dynamicIdx) / 16) { - val tag = le(dynOff + i * 16, 8) - val value = le(dynOff + i * 16 + 8, 8).toInt() - if (tag == 0L) break - if (tag == 1L) result += cstr(strBase, value) // DT_NEEDED - } - return result -} - -/** - * Delete everything in `lib/` that [root] does not actually reach. - * - * sharun bundles whatever the mpv *player* needs — X11, wayland, pulse, GTK and more — which is - * 350 shared objects / 373 MB. libmpv's own closure is 91 of them / 54 MB, and the rest would be - * dead weight in every Linux installer. - */ -/** - * Shared objects the host always provides, so a `DT_NEEDED` naming one is not a missing bundle - * entry. Deliberately short: anything outside the glibc/libgcc core is expected to ship with us. - */ -val systemProvidedSharedObjects = - setOf( - "libc.so.6", "libm.so.6", "libdl.so.2", "libpthread.so.0", "librt.so.1", - "libresolv.so.2", "libutil.so.1", "libnsl.so.1", "libcrypt.so.1", - "libgcc_s.so.1", "ld-linux-x86-64.so.2", "ld-linux-aarch64.so.1", - ) - -/** - * Give every shared object in [libDir] an rpath that reaches the rest of the bundle. - * - * sharun's tree is not flat: plugin hosts keep their real implementation in a subdirectory - * (`lib/pulseaudio/libpulsecommon-*.so`, `lib/alsa-lib/`, `lib/pipewire-0.3/`, `lib/spa-0.2/`, - * `lib/gbm/`). On a normal system those are found through each library's own RUNPATH; the copies - * here have none, so without this `libpulse.so.0` cannot see `libpulsecommon` even though both - * are in the bundle. - */ -fun setBundleRpaths(libDir: java.io.File) { - val subDirs = libDir.listFiles()?.filter { it.isDirectory }?.map { it.name }.orEmpty().sorted() - val topRpath = (listOf("\$ORIGIN") + subDirs.map { "\$ORIGIN/$it" }).joinToString(":") - libDir.walkTopDown().filter { it.isFile && it.name.contains(".so") }.forEach { so -> - val rpath = if (so.parentFile == libDir) topRpath else "\$ORIGIN:\$ORIGIN/.." - so.setWritable(true) - // Objects that are not ELF (stray data files) make patchelf fail; skip them quietly - // rather than aborting the whole slice. - ProcessBuilder("patchelf", "--set-rpath", rpath, so.absolutePath) - .redirectOutput(ProcessBuilder.Redirect.DISCARD) - .redirectError(ProcessBuilder.Redirect.DISCARD) - .start() - .waitFor() - } -} - -/** - * Delete top-level shared objects in [libDir] that nothing in the bundle links against. - * - * sharun bundles whatever the mpv *player* needs — X11, wayland, pulse, GTK and more — which is - * 350 shared objects / 373 MB. libmpv's own closure is a fraction of that, and the rest would be - * dead weight in every Linux installer. - * - * Two things this must get right, both learned the hard way: - * - * - The walk seeds from [root] **and from every shared object in a subdirectory**. Those - * subdirectory objects are plugins loaded with `dlopen` at runtime, so nothing names them in a - * `DT_NEEDED` — but their own dependencies are very much needed. Seeding only from [root] - * deleted `libsndfile.so.1` and `libasyncns.so.0`, which `libpulsecommon-17.0.so` needs, and - * shipped a `libmpv.so.2` that failed to load. - * - It asserts afterwards that every retained object resolves. A pruner that guesses wrong - * should fail here, on the machine building the bundle, not on a user's machine. - */ -fun pruneUnreachableSharedObjects(root: java.io.File, libDir: java.io.File) { - val allObjects = libDir.walkTopDown().filter { it.isFile && it.name.contains(".so") }.toList() - val byName = allObjects.associateBy { it.name } - val topLevel = allObjects.filter { it.parentFile == libDir } - - val reachable = mutableSetOf() - val queue = ArrayDeque() - queue += root - // Plugins in subdirectories are dlopen'ed, never named in a DT_NEEDED — seed them explicitly - // so their dependencies survive the prune. - allObjects.filter { it.parentFile != libDir }.forEach { - reachable += it.name - queue += it - } - while (queue.isNotEmpty()) { - elfNeeded(queue.removeFirst()).forEach { name -> - if (reachable.add(name)) byName[name]?.let { queue += it } - } - } - - var freed = 0L - var deleted = 0 - topLevel.forEach { file -> - if (file.name !in reachable) { - freed += file.length() - deleted++ - file.delete() - } - } - logger.lifecycle( - "[mpv-multi] Pruned $deleted unreachable shared objects (${freed / 1048576} MB); " + - "kept ${allObjects.size - deleted}", - ) - - // Post-condition: nothing left behind may reference something that is no longer here. - val remaining = libDir.walkTopDown().filter { it.isFile && it.name.contains(".so") }.toList() + root - val remainingNames = remaining.map { it.name }.toSet() - val dangling = - remaining.flatMap { obj -> - elfNeeded(obj) - .filter { it !in remainingNames && it !in systemProvidedSharedObjects } - .map { "${obj.name} → $it" } - } - check(dangling.isEmpty()) { - "Pruning left unresolvable dependencies — the slice would fail to load at runtime:\n" + - dangling.joinToString("\n") { " $it" } - } -} - val mpvSetupLinuxCi by tasks.registering { group = "mpv-multi" - description = "Cross-OS: populate mpv-natives/linux-x64/ with libmpv + its .so closure." + description = "Cross-OS: build a real libmpv.so.2 in a container and stage it with its .so closure." val outputDir = rootDir.resolve("mpv-natives/linux-x64/") - inputs.property("mpvAppImageTag", mpvAppImageTagEncoded) + val dockerDir = rootDir.resolve("scripts/mpv-linux") + inputs.dir(dockerDir) + inputs.property("mpvVersion", mpvVersion) outputs.dir(outputDir) doLast { - // patchelf is the one genuinely host-specific step. The binary carries NO - // DT_RPATH/DT_RUNPATH at all — inside the AppImage a sharun wrapper sets - // LD_LIBRARY_PATH instead — so an rpath must be added before it can be dlopen()ed - // straight out of the staged folder. - // - // Skip rather than fail when it is missing: `mpvSetupAll` is normally run on a dev's - // own machine to get the app running, and a hard failure there would take the macOS - // and Windows slices down with it for a slice that machine cannot use anyway. CI runs - // on Linux, where patchelf is one apt package away. - if (!toolAvailable("patchelf")) { + // Docker, not a host toolchain. The point of the container is the OLD base image: + // linking against Ubuntu 22.04 pins the glibc floor at 2.34 no matter how new the + // machine running this is. Building on the host would silently bake in that host's + // glibc and produce a slice that only runs on equally-new systems — the exact trap + // the upstream AppImage fell into. + if (!toolAvailable("docker")) { logger.warn( - "[mpv-multi] Skipping the Linux slice: patchelf is not on PATH " + - "(`sudo apt-get install -y patchelf`, or `brew install patchelf` locally). " + + "[mpv-multi] Skipping the Linux slice: docker is not on PATH. " + "The other slices are unaffected.", ) return@doLast } - val cache = mpvCacheDir.get().asFile - val appImage = cache.resolve("mpv-$mpvAppImageVersion-x86_64.AppImage") - downloadIfMissing( - "https://github.com/pkgforge-dev/mpv-AppImage/releases/download/" + - "$mpvAppImageTagEncoded/mpv-$mpvAppImageVersion-anylinux-x86_64.AppImage", - appImage, - logPrefix = "mpv-multi", - ) - - // The payload is DwarFS, not SquashFS, and this runtime does not implement the classic - // `--appimage-extract` flag (no `--appimage-*` string appears anywhere in the binary), so - // neither unsquashfs nor self-extraction works. dwarfsextract reads it directly, and its - // upstream Linux build is a self-contained tarball — no apt package needed. - // On the Linux runner, fetch upstream's self-contained build so no distro package is - // needed. Anywhere else that binary cannot execute, so fall back to a dwarfsextract - // already on PATH (`brew install dwarfs`) and skip the slice if there is none. - val isLinuxHost = System.getProperty("os.name").lowercase().contains("linux") - val dwarfsExtract: String = - if (isLinuxHost) { - val dwarfsTar = cache.resolve("dwarfs-$dwarfsVersion-Linux-x86_64.tar.xz") - downloadIfMissing( - "https://github.com/mhx/dwarfs/releases/download/v$dwarfsVersion/" + - "dwarfs-$dwarfsVersion-Linux-x86_64.tar.xz", - dwarfsTar, - logPrefix = "mpv-multi", - ) - val toolsDir = cache.resolve("dwarfs-tools") - val binary = - toolsDir.walkTopDown().firstOrNull { it.isFile && it.name == "dwarfsextract" } ?: run { - toolsDir.deleteRecursively() - toolsDir.mkdirs() - runChecked("tar", "-xf", dwarfsTar.absolutePath, "-C", toolsDir.absolutePath) - toolsDir.walkTopDown().firstOrNull { it.isFile && it.name == "dwarfsextract" } - ?: error("dwarfsextract not found inside ${dwarfsTar.name}") - } - binary.setExecutable(true) - binary.absolutePath - } else { - if (!toolAvailable("dwarfsextract")) { - logger.warn( - "[mpv-multi] Skipping the Linux slice: dwarfsextract is not on PATH " + - "(`brew install dwarfs`). The other slices are unaffected.", - ) - return@doLast - } - "dwarfsextract" - } - - val extractDir = cache.resolve("mpv-appimage-extract") - extractDir.deleteRecursively() - extractDir.mkdirs() - val offset = elfPayloadOffset(appImage) - logger.lifecycle("[mpv-multi] DwarFS payload starts at offset $offset") - runChecked( - dwarfsExtract, - "-i", appImage.absolutePath, - "-O", offset.toString(), - "-o", extractDir.absolutePath, - ) - // sharun keeps the real binary in shared/bin and the closure in shared/lib; shared/bin/mpv - // is the 24 MB PIE that statically links libmpv and exports the full client API (54 - // `mpv_*` dynamic symbols), while bin/mpv is only the ~230 KB sharun launcher. - val sharedDir = extractDir.walkTopDown().firstOrNull { - it.isDirectory && it.name == "shared" && it.resolve("bin/mpv").isFile - } ?: error("shared/bin/mpv not found inside the extracted AppImage") - - outputDir.deleteRecursively() - outputDir.mkdirs() - project.copy { - from(sharedDir.resolve("lib")) - into(outputDir.resolve("lib")) + val tag = "simpmusic-libmpv:$mpvVersion" + logger.lifecycle("[mpv-multi] Building $tag (libplacebo + FFmpeg + mpv from source, ~20-40 min cold)") + runChecked("docker", "build", "-t", tag, dockerDir.absolutePath) + + // `docker create` + `cp` rather than `run`: nothing needs to execute, and this works + // the same whether or not the daemon can run x86-64 images interactively. + val container = runCapturing("docker", "create", tag) + check(container.isNotEmpty()) { "docker create returned no container id" } + try { + outputDir.deleteRecursively() + outputDir.mkdirs() + runChecked("docker", "cp", "$container:/out/.", outputDir.absolutePath) + } finally { + runChecked("docker", "rm", container) } - // Named libmpv.so.2 because that is one of MpvLibrary's CANDIDATE_NAMES; JNA passes a - // versioned .so name through unchanged on Linux. + + // The container already proved the slice loads (stage.sh runs a dlopen + + // mpv_initialize smoke test and fails the build otherwise). What it cannot prove is + // the file type, and that is the one thing the previous approach got wrong: it + // staged a PIE executable that no glibc will ever dlopen. Cheap to assert, so assert. val staged = outputDir.resolve("libmpv.so.2") - sharedDir.resolve("bin/mpv").copyTo(staged, overwrite = true) - staged.setWritable(true) - staged.setExecutable(true) - runChecked("patchelf", "--set-rpath", "\$ORIGIN/lib", staged.absolutePath) - pruneUnreachableSharedObjects(staged, outputDir.resolve("lib")) - setBundleRpaths(outputDir.resolve("lib")) + check(staged.isFile) { "libmpv.so.2 missing from the container output" } + val elfType = staged.inputStream().use { stream -> + val header = ByteArray(18) + check(stream.read(header) == header.size) { "libmpv.so.2 is truncated" } + // e_type is a little-endian u16 at offset 0x10. ET_DYN (3) covers both shared + // objects and PIE executables; PIE additionally carries a PT_INTERP segment, + // which is what dlopen rejects. A shared object has none. + (header[0x10].toInt() and 0xFF) or ((header[0x11].toInt() and 0xFF) shl 8) + } + check(elfType == 3) { "libmpv.so.2 is not ET_DYN (e_type=$elfType) — it cannot be dlopen()ed" } + + val libs = outputDir.resolve("lib").listFiles()?.size ?: 0 logger.lifecycle( - "[mpv-multi] linux-x64: staged libmpv.so.2 + " + - "${outputDir.resolve("lib").listFiles()?.size ?: 0} shared objects", + "[mpv-multi] linux-x64: staged libmpv.so.2 + $libs shared objects " + + "(${outputDir.walkTopDown().filter { it.isFile }.sumOf { it.length() } / 1048576} MB)", ) } } @@ -941,11 +714,11 @@ val mpvBundleAll by tasks.registering { */ val mpvNativesChecksums = mapOf( - "linux-x64" to "ec64ce2c75c134b4283968a3f9993e40bb05589f406206a0e1d4536e08f62570", - "macos-arm64" to "9a44626c14526ed92ef913e876c2f2ef6fa640d946ab5e1b2bb4041d32893006", - "macos-x64" to "df4e0cc5f80b261a6e616febbca49c20f15f66f0c197dc1e3996f4e28dd5ac8f", - "windows-x64" to "7a3da0d920261d016a07ffed119e8f604084c7c0b4610301a8ec45445abe21f9", - "windows-arm64" to "1b4762a10e7aecfe3c12669df2e2ff7e19d374dd99c193b80889cfaf36cbe93d", + "linux-x64" to "55e8118a8c4ef201a3b72a71eb20e8854b04c3793d0c9ea0cd7b17ac77aaeee7", + "macos-arm64" to "e527daac8f6cc196324ea6f0ce54d119d04450af9795ff2856c1891806c1d5e0", + "macos-x64" to "95170ea54e1f637fdee148a9efd97993c305a92e233b8478d3764fbecce3eb02", + "windows-x64" to "256f17cf402c7583b8684d5a7cf585ad1b59695469219671f4887b9d8d272a99", + "windows-arm64" to "30e04a117de0b7d6abc5f86d4231e9b4bffa3637f282ff9efe3dc66e6cc4fcba", ) diff --git a/scripts/mpv-linux/Dockerfile b/scripts/mpv-linux/Dockerfile new file mode 100644 index 000000000..faa897c18 --- /dev/null +++ b/scripts/mpv-linux/Dockerfile @@ -0,0 +1,100 @@ +# Builds a REAL libmpv.so.2 shared library against glibc 2.35 (Ubuntu 22.04). +# +# Why not reuse the upstream mpv AppImage: that bundle ships its own glibc and +# loader because it is meant to run mpv as its own process. We dlopen libmpv +# into a JVM that already has the system glibc mapped, and two glibcs cannot +# coexist in one process. The AppImage binary is also a PIE executable, which +# glibc refuses to dlopen outright. +# +# Ubuntu 22.04 is the oldest base that still builds mpv 0.41 without heroics, +# so the resulting slice runs on Ubuntu 22.04 / Debian 12 and newer. +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Build toolchain. meson from apt is 0.61 (mpv needs >=1.3), so it comes from pip. +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential git ca-certificates curl pkg-config python3 python3-pip \ + ninja-build nasm yasm patchelf \ + libass-dev libfreetype6-dev libfribidi-dev libharfbuzz-dev \ + libfontconfig1-dev \ + libasound2-dev libpulse-dev \ + libxml2-dev zlib1g-dev libbz2-dev liblzma-dev \ + libssl-dev \ + libdav1d-dev \ + && rm -rf /var/lib/apt/lists/* \ + && pip3 install --no-cache-dir "meson>=1.3.0" + +WORKDIR /build + +# --------------------------------------------------------------------------- +# libplacebo — mpv 0.41 hard-requires >=6.338.2; 22.04 ships 4.192. +# +# Built WITHOUT vulkan/shaderc/glslang/d3d11. Those exist to compile shaders for +# hardware renderers; SimpMusic drives mpv through the software render API +# (MPV_RENDER_API_TYPE_SW), so none of it is reachable. Dropping them also drops +# libshaderc/libglslang/libSPIRV-Tools from the closure — the single biggest +# chunk of the old AppImage-derived bundle. +# --------------------------------------------------------------------------- +RUN git clone --depth 1 --branch v7.351.0 --recursive \ + https://code.videolan.org/videolan/libplacebo.git && \ + cd libplacebo && \ + meson setup build \ + --prefix=/usr/local \ + --buildtype=release \ + -Ddefault_library=shared \ + -Dvulkan=disabled -Dopengl=disabled -Dd3d11=disabled \ + -Dshaderc=disabled -Dglslang=disabled \ + -Ddemos=false -Dtests=false && \ + ninja -C build -j$(nproc) && ninja -C build install && \ + cd .. && rm -rf libplacebo + +# --------------------------------------------------------------------------- +# FFmpeg — 22.04 ships 4.4, mpv 0.41 needs libavcodec >=60.31 (FFmpeg 6.1+). +# +# Decoders/demuxers are trimmed to what YouTube Music actually serves, plus the +# protocol stack for https. Encoders are dropped entirely: SimpMusic only ever +# plays back. +# --------------------------------------------------------------------------- +RUN git clone --depth 1 --branch n7.1.1 https://github.com/FFmpeg/FFmpeg.git ffmpeg && \ + cd ffmpeg && \ + ./configure \ + --prefix=/usr/local \ + --enable-shared --disable-static \ + --disable-programs --disable-doc --disable-debug \ + --disable-encoders --disable-muxers --disable-devices \ + --disable-filters --enable-filter=aresample,atempo,rubberband,scale,format,aformat,anull,null,volume,lavfi,acompressor,firequalizer,equalizer,highpass,lowpass,dynaudnorm,loudnorm,aecho \ + --enable-openssl --enable-protocol=https,http,tls,file,pipe,crypto,hls,data \ + --enable-libdav1d \ + --enable-version3 \ + && make -j$(nproc) && make install && \ + cd .. && rm -rf ffmpeg + +# --------------------------------------------------------------------------- +# mpv itself — the only artifact that matters is libmpv.so.2. +# +# libmpv is what we load; the mpv CLI player is not built. Video output goes +# through the software render API, so X11/Wayland/GPU backends are all off. +# --------------------------------------------------------------------------- +RUN git clone --depth 1 --branch v0.41.0 https://github.com/mpv-player/mpv.git && \ + cd mpv && \ + PKG_CONFIG_PATH=/usr/local/lib/x86_64-linux-gnu/pkgconfig:/usr/local/lib/pkgconfig \ + meson setup build \ + --prefix=/usr/local \ + --buildtype=release \ + -Dlibmpv=true -Dcplayer=false \ + -Dgpl=false \ + -Dx11=disabled -Dwayland=disabled -Ddrm=disabled -Dgbm=disabled \ + -Degl=disabled -Dgl=disabled -Dvulkan=disabled \ + -Dalsa=enabled -Dpulse=enabled \ + -Dlua=disabled -Djavascript=disabled \ + -Dlibarchive=disabled -Dlibbluray=disabled -Ddvdnav=disabled \ + -Dcdda=disabled -Duchardet=disabled \ + -Drubberband=disabled -Dzimg=disabled \ + -Dmanpage-build=disabled && \ + ninja -C build -j$(nproc) && ninja -C build install + +# Stage libmpv + its non-system closure into /out, laid out the way +# mpv-natives/linux-x64/ expects: libmpv.so.2 at the top, everything else in lib/. +COPY stage.sh /stage.sh +RUN bash /stage.sh diff --git a/scripts/mpv-linux/stage.sh b/scripts/mpv-linux/stage.sh new file mode 100755 index 000000000..f9ecd1e6b --- /dev/null +++ b/scripts/mpv-linux/stage.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Collect libmpv.so.2 and every non-system shared object it needs into /out. +# +# "System" here means the set every glibc-based desktop is guaranteed to have. +# Bundling those would be actively harmful: the JVM has already mapped the +# host's libc/libm/libpthread, and a second copy in one process is exactly the +# failure that killed the AppImage approach. +set -euo pipefail + +OUT=/out +mkdir -p "$OUT/lib" + +# libplacebo and FFmpeg installed into /usr/local/lib, which is not in the +# loader cache on a bare Ubuntu image. Without this, ldd reports every +# libav*/libplacebo as "not found" and they silently miss the closure — the +# staged slice then looks complete but cannot load. +echo "/usr/local/lib" > /etc/ld.so.conf.d/local.conf +echo "/usr/local/lib/x86_64-linux-gnu" >> /etc/ld.so.conf.d/local.conf +ldconfig + +SYSTEM_LIBS=" +libc.so.6 libm.so.6 libdl.so.2 libpthread.so.0 librt.so.1 libutil.so.1 +ld-linux-x86-64.so.2 libgcc_s.so.1 libstdc++.so.6 libresolv.so.2 +libz.so.1 libbz2.so.1.0 liblzma.so.5 +" + +is_system() { + local name="$1" + for s in $SYSTEM_LIBS; do [[ "$name" == "$s" ]] && return 0; done + return 1 +} + +LIBMPV=$(find /usr/local/lib -name 'libmpv.so.2*' -type f | head -1) +[[ -n "$LIBMPV" ]] || { echo "libmpv.so.2 not found after install"; exit 1; } +cp "$LIBMPV" "$OUT/libmpv.so.2" +chmod +w "$OUT/libmpv.so.2" + +# Walk the DT_NEEDED graph. ldd resolves transitively already, so one pass over +# its output is enough — but ldd also lists the system libs, hence the filter. +collect() { + ldd "$1" 2>/dev/null | awk '/=>/ {print $1, $3}' | while read -r name path; do + [[ -z "$path" || "$path" == "not" ]] && continue + is_system "$name" && continue + [[ -f "$OUT/lib/$name" ]] && continue + cp -L "$path" "$OUT/lib/$name" + chmod +w "$OUT/lib/$name" + collect "$OUT/lib/$name" + done +} +collect "$OUT/libmpv.so.2" + +# DT_RPATH, not DT_RUNPATH: RUNPATH applies only to the object that carries it, +# so a dependency-of-a-dependency would not be found. RPATH is inherited down +# the whole chain, which is what lets one entry here cover the entire closure. +patchelf --force-rpath --set-rpath '$ORIGIN/lib' "$OUT/libmpv.so.2" +for so in "$OUT"/lib/*.so*; do + patchelf --force-rpath --set-rpath '$ORIGIN' "$so" +done + +echo "=== staged ===" +ls -la "$OUT" +echo "--- lib/ ($(ls "$OUT/lib" | wc -l) objects, $(du -sh "$OUT/lib" | cut -f1)) ---" +ls "$OUT/lib" +# Fail here, in the builder, rather than on a user's machine. A slice that looks +# complete but cannot resolve its own closure is exactly what shipped before. +echo "=== resolution check ===" +missing=$(ldd "$OUT/libmpv.so.2" 2>/dev/null | grep "not found" || true) +for so in "$OUT"/lib/*.so*; do + missing+=$(ldd "$so" 2>/dev/null | grep "not found" || true) +done +if [[ -n "$missing" ]]; then + echo "UNRESOLVED dependencies remain:" + echo "$missing" | sort -u + exit 1 +fi +echo "(all resolved via \$ORIGIN — no LD_LIBRARY_PATH needed)" + +# Prove the staged library actually loads and initialises, in this container, +# with nothing but $ORIGIN to find its dependencies. +cat > /tmp/smoke.c <<'EOF' +#include +#include +int main(void) { + void *h = dlopen("/out/libmpv.so.2", RTLD_NOW | RTLD_LOCAL); + if (!h) { printf("dlopen FAILED: %s\n", dlerror()); return 1; } + unsigned long (*ver)(void) = dlsym(h, "mpv_client_api_version"); + void *(*create)(void) = dlsym(h, "mpv_create"); + int (*init)(void *) = dlsym(h, "mpv_initialize"); + if (!ver || !create || !init) { printf("dlsym FAILED\n"); return 1; } + unsigned long v = ver(); + printf("client api = %lu.%lu\n", v >> 16, v & 0xffff); + void *ctx = create(); + if (!ctx) { printf("mpv_create returned NULL\n"); return 1; } + int rc = init(ctx); + printf("mpv_initialize = %d %s\n", rc, rc == 0 ? "(OK)" : "(FAILED)"); + return rc == 0 ? 0 : 1; +} +EOF +gcc -o /tmp/smoke /tmp/smoke.c -ldl +echo "=== smoke test ===" +LC_NUMERIC=C /tmp/smoke From 1661786bf1d9557e9378e9eacb93d5eb5fdd47cc Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 11:35:21 +0700 Subject: [PATCH 14/47] chore(core): bump submodule for libmpv load fixes --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 06089d22d..596e26e9e 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 06089d22d956b1abe4e964dfebd4a7f9dbe3654c +Subproject commit 596e26e9eaba051455eccf9163163150b43abc4c From 153c015339fd65582e5f1e3a4da298496bbb3807 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 13:42:46 +0700 Subject: [PATCH 15/47] fix(desktop): strip AppleDouble sidecars from mpv native slices --- composeApp/build.gradle.kts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 3815b2d25..fac0a31e6 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -758,6 +758,24 @@ val mpvSetupAll by tasks.registering { outputRoot.mkdirs() runChecked("tar", "-xzf", archive.absolutePath, "-C", outputRoot.absolutePath) check(target.isDirectory) { "$slice missing after unpacking ${archive.name}" } + + // Drop AppleDouble sidecars — this is a correctness fix, not tidiness. + // + // Tarring a slice on macOS writes each file's xattrs out as a companion "._name" + // entry. Conveyor then signs them as ordinary bundle members and lists all of them + // in _CodeSignature/CodeResources. But when the user unzips the .app, macOS folds + // every "._name" back into the xattrs of "name" and deletes the sidecar — so the + // bundle that gets launched is missing 200 files the seal still expects, and + // Gatekeeper reports the app as "damaged and can't be opened". + // + // Only the macOS slices are ever affected in practice, but strip unconditionally: + // these files carry nothing but com.apple.provenance, and any slice can pick them + // up the moment it is packed on a Mac. + val appleDouble = target.walkTopDown().filter { it.isFile && it.name.startsWith("._") }.toList() + appleDouble.forEach { it.delete() } + if (appleDouble.isNotEmpty()) { + logger.lifecycle("[mpv-multi] $slice: stripped ${appleDouble.size} AppleDouble sidecars") + } } logger.lifecycle("[mpv-multi] Unpacked ${mpvSlices.size} verified native slices into mpv-natives/") } From e027443c621038ad8e2f0308f8b6add38524ce81 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 14:03:45 +0700 Subject: [PATCH 16/47] docs: document the from-source Linux libmpv build and macOS signing pitfall --- CLAUDE.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b9ed0d577..3f53dd169 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -360,7 +360,7 @@ Before implementing code, researching code, or answering technical questions, th - `MpvPlayer.kt` — one handle per media item; `vo=libmpv` + software render context - `MpvVideoSurfacePanel.kt` — mpv SW render API → `BufferedImage` → Swing, embedded in Compose via `SwingPanel` - `MpvPlayerAdapter.kt` — the `MediaPlayerInterface` implementation; separate YouTube audio/video URLs are merged into ONE source with an `edl://...;!new_stream;...` URL (mpv's equivalent of Android's `MergingMediaSource`) -- Natives bundled per platform in `mpv-natives/-/`, staged by `mpvSetupAll` +- Natives bundled per platform in `mpv-natives/-/`, staged by `mpvSetupAll` (Linux slice is compiled from source — `scripts/mpv-linux/`) - Supports crossfade transition with dual-player approach #### Crossfade Transition (Desktop) @@ -486,10 +486,16 @@ if (getPlatform() == Platform.Android) { - **Windows SMTC (2026-07)**: System Media Transport Controls on Windows via `jmtc`/`nowplayingcenter` 0.0.3 (forked JMTC). The native `SMTCAdapter.dll` was hardened against the 1.0.x crash (Sentry SIMPMUSIC-DESKTOP-7, ~95k events): COM apartment tolerates `RPC_E_CHANGED_MODE`, `MediaPlayer` kept alive process-wide, and every exported call is exception-guarded so nothing crosses the JNA boundary as "Invalid memory access". JMTC is confined to a dedicated thread (off the AWT EDT), and `MediaType.Music` is set before display properties so title/artist render (not just the app name). Enabled in `JvmMediaPlayerHandlerImpl` for `Platform.Windows` (Linux MPRIS unchanged; macOS uses NowPlayingCenter). DLL built by GitHub Actions (`windows-latest`) in the NowPlayingCenter repo. - **VLC removed entirely (2026-07-27)**: `VlcPlayerAdapter`, `DefaultVlcDiscoverer`, `MacOsVlcDiscoverer` and `VlcModule` are deleted; `VlcModule.kt` became `DesktopPlayerModule.kt` (`loadVlcModule()` → `loadDesktopPlayerModule()`). The `vlcj` dependency, the `vlc-setup` Gradle plugin, every `vlcSetup*` task, the `vlc-natives/` tree and the VLC Conveyor inputs are all gone. `appResourcesRootDir` now points at `mpv-natives/`. libmpv is the only desktop backend. - **Bundled libmpv (2026-07-27)**: two entry points, deliberately split. `:composeApp:mpvBundleAll` runs **on a Mac, once per mpv bump** — it turns upstream mpv builds into loadable slices in `mpv-natives/-/`, packs them into tarballs and prints their SHA-256. Those are published to `maxrave-dev/simpmusic-files`. `:composeApp:mpvSetupAll` is what **CI** runs: it downloads those tarballs, verifies them against the digests pinned in `mpvNativesChecksums`, and unpacks them — no toolchain needed on the runner. Both workflows must call it before Conveyor, which is invoked by its own action and so never triggers the Gradle `dependsOn`. - - Sources: shinchiro `mpv-dev-*.7z` (Windows — the only one shipping a real `libmpv-2.dll`), mpv's own release `.zip` (macOS), pkgforge-dev mpv-AppImage (Linux, DwarFS payload). On macOS and Linux **libmpv is statically linked into the `mpv` executable**; those PIE binaries export the full client API and are renamed to `libmpv.dylib` / `libmpv.so.2`, with load-command paths repointed to `@loader_path` / `$ORIGIN`. + - Sources: shinchiro `mpv-dev-*.7z` (Windows — the only one shipping a real `libmpv-2.dll`), mpv's own release `.zip` (macOS), and **for Linux a from-source container build** (see below). On macOS **libmpv is statically linked into the `mpv` executable**; that PIE binary exports the full client API and is renamed to `libmpv.dylib`, with load-command paths repointed to `@loader_path`. - Do NOT lift `IINA.app/Contents/Frameworks` instead: IINA 1.4.4 ships a version-skewed pair (libmpv needs `_pl_log_create_349`, bundled `libplacebo.338.dylib` exports `_pl_log_create_338`) and that libmpv fails `dlopen` under both RTLD_NOW and RTLD_LAZY. - - The Linux pruner must seed its reachability walk from the subdirectory plugins too (`lib/pulseaudio/`, `lib/alsa-lib/`, …) — they are `dlopen`ed, so no `DT_NEEDED` names them, and seeding only from libmpv once deleted `libsndfile`/`libasyncns` and shipped an unloadable slice. It asserts afterwards that every retained object resolves. + - **Every `._*` sidecar must be stripped after unpacking** (`mpvSetupAll` does this). Tarring a slice on macOS writes each file's xattrs out as a companion `._name`; Conveyor then signs them as ordinary bundle members and seals them in `_CodeSignature/CodeResources`, but macOS folds `._name` back into the xattrs of `name` and deletes the sidecar the moment Finder touches the app — unzipping it **or** dragging it out of the DMG. The launched bundle is then missing every sidecar the seal expects and Gatekeeper reports "SimpMusic is damaged and can't be opened" (`codesign --strict`: `a sealed resource is missing or invalid`). Only macOS is affected: it alone seals the whole app directory and re-checks it at launch. - `MpvLibrary.bundledLibraryDirs()` resolves the staged folder: `mpv.bundled.path` → `compose.application.resources.dir` → `mpv/` found by walking up from the JAR → `mpv-natives/-`. +- **Linux libmpv built from source (2026-07-28)**: the AppImage route is gone — `scripts/mpv-linux/Dockerfile` now compiles libplacebo 7.351 + FFmpeg 7.1.1 + mpv 0.41.0 on **Ubuntu 22.04**, and `mpvSetupLinuxCi` runs that container and copies `/out`. This deleted ~186 lines of DwarFS extraction, closure pruning and rpath rewriting from `composeApp/build.gradle.kts`. + - **Why the AppImage could never work**: every prebuilt Linux mpv targets "run mpv as its own process". `mpv-AppImage` ships its own glibc + `ld-linux`, and its "libmpv.so.2" was really the `mpv` **PIE executable** — glibc refuses to `dlopen` a PIE outright (`DF_1_PIE`), and even patched past that, its glibc 2.43 collides with the one the JVM already mapped. It only ever appeared to work on dev machines because JNA silently fell back to a system-wide libmpv. **Always log the resolved path (`NativeLibrary.getInstance(name).file`)** — that is the only thing distinguishing "using the bundle" from "quietly using /usr/lib". + - The container build targets glibc **2.34** → runs on Ubuntu 22.04 / Debian 11 and newer. Vulkan/shaderc/glslang/D3D11 are disabled in libplacebo and X11/Wayland/GPU in mpv, since playback goes through the software render API; that also drops `libshaderc`/`libglslang`/`libSPIRV-Tools` (the bulk of the old bundle) and removes libsixel entirely, which had been aborting the JVM. + - `stage.sh` deliberately does **not** bundle `libc`/`libm`/`libstdc++`/`ld-linux`, sets `DT_RPATH` (not `DT_RUNPATH` — RUNPATH is not inherited by transitive dependencies), and fails the build unless a `dlopen` + `mpv_initialize` smoke test passes. + - mpv built with `-Dlua=disabled` has no `ytdl_hook`, so the `ytdl` option genuinely does not exist there; `MpvPlayer` uses `optionalOption()` to treat `MPV_ERROR_OPTION_NOT_FOUND` as success. +- **JNA open flags are POSIX-only (2026-07-28)**: `MpvLibrary` passes `OPTION_OPEN_FLAGS = 2` (RTLD_NOW without RTLD_GLOBAL) **only when not on Windows**. JNA forwards the value verbatim to `LoadLibraryEx`, where `2` means `LOAD_LIBRARY_AS_DATAFILE`: the DLL maps as plain data, imports never resolve, and `GetProcAddress` returns nothing — surfacing as the misleading `Error looking up function 'mpv_client_api_version': The specified module could not be found`. ## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY) @@ -515,6 +521,6 @@ After completing any of the following types of changes, the AI agent **MUST** up *This document helps AI Agents quickly understand the SimpMusic project. Update regularly when there are major changes to architecture or structure.* -**Last updated**: 2026-07-18 +**Last updated**: 2026-07-28 **Project version**: Check latest release on GitHub **Maintained by**: maxrave-dev and contributors From fd7673966005dd672d2c9efee1770ee112c49466 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 23:14:29 +0700 Subject: [PATCH 17/47] fix(desktop): use the theme colour for the queue icon in the player bar --- .../kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt index 8cfd59298..22f24be5f 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt @@ -848,11 +848,11 @@ fun MiniPlayer( IconButton( onClick = { showQueueBottomSheet = true - } + }, ) { Icon( imageVector = Icons.AutoMirrored.Rounded.QueueMusic, - tint = Color.White, + tint = textColor, contentDescription = "", ) } From fce896834fb2cd86013522f79d660a45e3fdba85 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 23:14:29 +0700 Subject: [PATCH 18/47] fix(desktop): keep buffered and played sections of the timeline distinct --- .../com/maxrave/simpmusic/ui/screen/MiniPlayer.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt index 22f24be5f..ed121b2b9 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt @@ -750,10 +750,16 @@ fun MiniPlayer( ).clip( RoundedCornerShape(8.dp), ), - color = textColor, + // Three levels have to stay apart on one bar: the + // slider above draws played position solid, so + // buffered-but-unplayed must be dimmed and the + // unbuffered remainder dimmer still. At full + // buffer a solid colour here would blend into the + // played part and the bar would look uniform. + color = textColor.copy(alpha = 0.35f), trackColor = textColor.copy( - alpha = 0.4f, + alpha = 0.15f, ), strokeCap = StrokeCap.Round, drawStopIndicator = {}, From 4c422bad97410eacfd4c9bd6c6e0fb4bfa954cca Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 23:14:30 +0700 Subject: [PATCH 19/47] feat(desktop): tint the player bar timeline while crossfading --- .../maxrave/simpmusic/ui/screen/MiniPlayer.kt | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt index ed121b2b9..6c8f2b6fb 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt @@ -2,14 +2,22 @@ package com.maxrave.simpmusic.ui.screen import androidx.compose.animation.Animatable import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith @@ -99,13 +107,13 @@ import com.maxrave.domain.data.entities.SongEntity import com.maxrave.domain.manager.DataStoreManager import com.maxrave.domain.utils.connectArtists import com.maxrave.logger.Logger -import com.maxrave.simpmusic.ui.component.rememberHolderPainter import com.maxrave.simpmusic.Platform import com.maxrave.simpmusic.expect.toggleMiniPlayer import com.maxrave.simpmusic.expect.ui.PlatformBackdrop import com.maxrave.simpmusic.expect.ui.toImageBitmap import com.maxrave.simpmusic.extension.formatDuration import com.maxrave.simpmusic.extension.getColorFromPalette +import com.maxrave.simpmusic.extension.hsvToColor import com.maxrave.simpmusic.extension.toResizedBitmap import com.maxrave.simpmusic.getPlatform import com.maxrave.simpmusic.ui.component.ExplicitBadge @@ -114,6 +122,7 @@ import com.maxrave.simpmusic.ui.component.PlayPauseButton import com.maxrave.simpmusic.ui.component.PlayerControlLayout import com.maxrave.simpmusic.ui.component.QueueBottomSheet import com.maxrave.simpmusic.ui.component.liquidGlass +import com.maxrave.simpmusic.ui.component.rememberHolderPainter import com.maxrave.simpmusic.ui.theme.LocalIsDarkTheme import com.maxrave.simpmusic.ui.theme.typo import com.maxrave.simpmusic.viewModel.SharedViewModel @@ -562,6 +571,31 @@ fun MiniPlayer( // Desktop bottom bar surface follows the theme (haze over content), so text and controls // use the theme foreground token instead of the artwork-luminance colour. val textColor = MaterialTheme.colorScheme.onBackground + + // Crossfade: RGB rainbow color cycling while transitioning between tracks, mirroring the + // Now Playing screen so the desktop bar signals a crossfade the same way. + val crossfadeTransition = rememberInfiniteTransition(label = "miniPlayerCrossfadeRainbow") + val rainbowHue by crossfadeTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = + infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "miniPlayerRainbowHue", + ) + val progressColor by animateColorAsState( + targetValue = + if (timelineState.isCrossfading) { + hsvToColor(rainbowHue, 1f, 1f) + } else { + textColor + }, + animationSpec = tween(300), + label = "miniPlayerCrossfadeColor", + ) + var isSliding by rememberSaveable { mutableStateOf(false) } @@ -798,8 +832,8 @@ fun MiniPlayer( sliderState = sliderState, colors = SliderDefaults.colors().copy( - thumbColor = textColor, - activeTrackColor = textColor, + thumbColor = progressColor, + activeTrackColor = progressColor, inactiveTrackColor = Color.Transparent, ), thumbTrackGapSize = 0.dp, @@ -823,8 +857,8 @@ fun MiniPlayer( }, colors = SliderDefaults.colors().copy( - thumbColor = textColor, - activeTrackColor = textColor, + thumbColor = progressColor, + activeTrackColor = progressColor, inactiveTrackColor = Color.Transparent, ), enabled = true, From ac6a0fa11ecc9635766ac9da99f6654cd9b774e0 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 23:14:30 +0700 Subject: [PATCH 20/47] feat(desktop): expand the volume slider only on hover --- .../maxrave/simpmusic/ui/screen/MiniPlayer.kt | 173 ++++++++++-------- 1 file changed, 97 insertions(+), 76 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt index 6c8f2b6fb..60f38212b 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt @@ -29,7 +29,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -906,24 +908,6 @@ fun MiniPlayer( ) } } - IconButton( - onClick = { - // Toggle mute/unmute - val newVolume = if (controllerState.volume > 0f) 0f else 1f - sharedViewModel.onUIEvent(UIEvent.UpdateVolume(newVolume)) - }, - ) { - Icon( - imageVector = - if (controllerState.volume > 0f) { - Icons.AutoMirrored.Filled.VolumeUp - } else { - Icons.AutoMirrored.Filled.VolumeOff - }, - contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute", - ) - } - Spacer(Modifier.width(2.dp)) var isVolumeSliding by rememberSaveable { mutableStateOf(false) } @@ -935,66 +919,103 @@ fun MiniPlayer( volumeValue = controllerState.volume } } - CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { - Slider( - value = volumeValue, - onValueChangeFinished = { - isVolumeSliding = false - sharedViewModel.onUIEvent( - UIEvent.UpdateVolume(volumeValue.coerceIn(0f, 1f)), - ) - }, - onValueChange = { - isVolumeSliding = true - volumeValue = it - }, - valueRange = 0f..1f, - modifier = - Modifier - .padding(top = 3.dp) - .width(64.dp), - track = { sliderState -> - SliderDefaults.Track( - modifier = - Modifier - .height(5.dp), - enabled = true, - sliderState = sliderState, - colors = - SliderDefaults.colors().copy( - thumbColor = textColor, - activeTrackColor = textColor, - inactiveTrackColor = textColor.copy(alpha = 0.3f), - ), - thumbTrackGapSize = 0.dp, - drawTick = { _, _ -> }, - drawStopIndicator = null, - ) + // The slider only claims space while the pointer is over the volume cluster. + // `hoverable` sits on the Row wrapping both the icon and the slider so moving + // between them never drops the hover, and an in-progress drag keeps it open + // even when the pointer slips outside. + val volumeInteractionSource = remember { MutableInteractionSource() } + val isVolumeHovered by volumeInteractionSource.collectIsHoveredAsState() + Row( + modifier = Modifier.hoverable(volumeInteractionSource), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + // Toggle mute/unmute + val newVolume = if (controllerState.volume > 0f) 0f else 1f + sharedViewModel.onUIEvent(UIEvent.UpdateVolume(newVolume)) }, - thumb = { - SliderDefaults.Thumb( - modifier = - Modifier - .height(18.dp) - .width(8.dp) - .padding( - vertical = 4.dp, - ), - thumbSize = DpSize(8.dp, 8.dp), - interactionSource = - remember { - MutableInteractionSource() + ) { + Icon( + imageVector = + if (controllerState.volume > 0f) { + Icons.AutoMirrored.Filled.VolumeUp + } else { + Icons.AutoMirrored.Filled.VolumeOff + }, + contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute", + ) + } + AnimatedVisibility( + visible = isVolumeHovered || isVolumeSliding, + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut(), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(2.dp)) + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + Slider( + value = volumeValue, + onValueChangeFinished = { + isVolumeSliding = false + sharedViewModel.onUIEvent( + UIEvent.UpdateVolume(volumeValue.coerceIn(0f, 1f)), + ) }, - colors = - SliderDefaults.colors().copy( - thumbColor = textColor, - activeTrackColor = textColor, - inactiveTrackColor = textColor.copy(alpha = 0.3f), - ), - enabled = true, - ) - }, - ) + onValueChange = { + isVolumeSliding = true + volumeValue = it + }, + valueRange = 0f..1f, + modifier = + Modifier + .padding(top = 3.dp) + .width(64.dp), + track = { sliderState -> + SliderDefaults.Track( + modifier = + Modifier + .height(5.dp), + enabled = true, + sliderState = sliderState, + colors = + SliderDefaults.colors().copy( + thumbColor = textColor, + activeTrackColor = textColor, + inactiveTrackColor = textColor.copy(alpha = 0.3f), + ), + thumbTrackGapSize = 0.dp, + drawTick = { _, _ -> }, + drawStopIndicator = null, + ) + }, + thumb = { + SliderDefaults.Thumb( + modifier = + Modifier + .height(18.dp) + .width(8.dp) + .padding( + vertical = 4.dp, + ), + thumbSize = DpSize(8.dp, 8.dp), + interactionSource = + remember { + MutableInteractionSource() + }, + colors = + SliderDefaults.colors().copy( + thumbColor = textColor, + activeTrackColor = textColor, + inactiveTrackColor = textColor.copy(alpha = 0.3f), + ), + enabled = true, + ) + }, + ) + } + } + } } Spacer(Modifier.width(4.dp)) IconButton(onClick = { onClose() }) { From 0f2692fcaf1c691bf79d5a405fa8e0943fa12103 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 23:14:55 +0700 Subject: [PATCH 21/47] fix(ui): give force-dark screens a dark colour scheme, not just dark text --- .../kotlin/com/maxrave/simpmusic/App.kt | 21 ++++---- .../ui/navigation/graph/AppNavigationGraph.kt | 5 +- .../ui/navigation/graph/ListScreenGraph.kt | 13 +++-- .../com/maxrave/simpmusic/ui/theme/Theme.kt | 49 +++++++++++++++++++ 4 files changed, 68 insertions(+), 20 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/App.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/App.kt index 19078fa6a..3767d8ec0 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/App.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/App.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -83,7 +82,7 @@ import com.maxrave.simpmusic.ui.screen.MiniPlayer import com.maxrave.simpmusic.ui.screen.player.NowPlayingScreen import com.maxrave.simpmusic.ui.screen.player.NowPlayingScreenContent import com.maxrave.simpmusic.ui.theme.AppTheme -import com.maxrave.simpmusic.ui.theme.LocalForceDarkText +import com.maxrave.simpmusic.ui.theme.ForceDarkContent import com.maxrave.simpmusic.ui.theme.parseThemeColorHex import com.maxrave.simpmusic.ui.theme.fontFamily import com.maxrave.simpmusic.ui.theme.typo @@ -518,13 +517,15 @@ fun App(viewModel: SharedViewModel = koinInject()) { RoundedCornerShape(12.dp), ), ) { - NowPlayingScreenContent( - navController = navController, - sharedViewModel = viewModel, - isExpanded = true, - dismissIcon = Icons.AutoMirrored.Rounded.ArrowForwardIos, - ) { - isShowNowPlaylistScreen = false + ForceDarkContent { + NowPlayingScreenContent( + navController = navController, + sharedViewModel = viewModel, + isExpanded = true, + dismissIcon = Icons.AutoMirrored.Rounded.ArrowForwardIos, + ) { + isShowNowPlaylistScreen = false + } } } } @@ -534,7 +535,7 @@ fun App(viewModel: SharedViewModel = koinInject()) { } if (isShowNowPlaylistScreen && !isTabletLandscape) { - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { NowPlayingScreen( navController = navController, ) { diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/AppNavigationGraph.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/AppNavigationGraph.kt index cf68a5ab4..1490308c8 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/AppNavigationGraph.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/AppNavigationGraph.kt @@ -8,12 +8,11 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.maxrave.simpmusic.ui.navigation.destination.home.HomeDestination -import com.maxrave.simpmusic.ui.theme.LocalForceDarkText +import com.maxrave.simpmusic.ui.theme.ForceDarkContent import com.maxrave.simpmusic.ui.navigation.destination.library.LibraryDestination import com.maxrave.simpmusic.ui.navigation.destination.player.FullscreenDestination import com.maxrave.simpmusic.ui.navigation.destination.search.SearchDestination @@ -70,7 +69,7 @@ fun AppNavigationGraph( ) } composable { - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { FullscreenPlayer( navController, hideNavBar = hideNavBar, diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/ListScreenGraph.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/ListScreenGraph.kt index a70712a64..89f130624 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/ListScreenGraph.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/ListScreenGraph.kt @@ -3,7 +3,6 @@ package com.maxrave.simpmusic.ui.navigation.graph import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.CompositionLocalProvider import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable @@ -20,7 +19,7 @@ import com.maxrave.simpmusic.ui.screen.other.ArtistScreen import com.maxrave.simpmusic.ui.screen.other.MoreAlbumsScreen import com.maxrave.simpmusic.ui.screen.other.PlaylistScreen import com.maxrave.simpmusic.ui.screen.other.PodcastScreen -import com.maxrave.simpmusic.ui.theme.LocalForceDarkText +import com.maxrave.simpmusic.ui.theme.ForceDarkContent @ExperimentalMaterial3Api @ExperimentalFoundationApi @@ -30,7 +29,7 @@ fun NavGraphBuilder.listScreenGraph( ) { composable { entry -> val data = entry.toRoute() - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { AlbumScreen( browseId = data.browseId, navController = navController, @@ -39,7 +38,7 @@ fun NavGraphBuilder.listScreenGraph( } composable { entry -> val data = entry.toRoute() - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { ArtistScreen( channelId = data.channelId, navController = navController, @@ -48,7 +47,7 @@ fun NavGraphBuilder.listScreenGraph( } composable { entry -> val data = entry.toRoute() - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { LocalPlaylistScreen( id = data.id, navController = navController, @@ -66,7 +65,7 @@ fun NavGraphBuilder.listScreenGraph( } composable { entry -> val data = entry.toRoute() - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { PlaylistScreen( playlistId = data.playlistId, isYourYouTubePlaylist = data.isYourYouTubePlaylist, @@ -76,7 +75,7 @@ fun NavGraphBuilder.listScreenGraph( } composable { entry -> val data = entry.toRoute() - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { PodcastScreen( podcastId = data.podcastId, navController = navController, diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/theme/Theme.kt index 4de0635c6..a63db25e8 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/theme/Theme.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.ColorScheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable @@ -52,6 +53,12 @@ val LocalAppColors = staticCompositionLocalOf { DarkAppColors } /** True in dark theme. Provided by [AppTheme] so platform glass (blur/tint) can adapt reliably. */ val LocalIsDarkTheme = staticCompositionLocalOf { true } +/** + * The dark scheme to use for immersive screens while the app itself is on the light theme. + * Provided by [AppTheme], consumed by [ForceDarkContent]; null only outside of [AppTheme]. + */ +val LocalForcedDarkColorScheme = staticCompositionLocalOf { null } + /** Parses "RRGGBB" or "AARRGGBB" (optionally "#"-prefixed) into a [Color]; null if malformed. */ fun parseThemeColorHex(hex: String): Color? { val clean = hex.trim().removePrefix("#") @@ -130,6 +137,19 @@ fun AppTheme( style = PaletteStyle.TonalSpot, modifyColorScheme = { cs -> if (isDark) cs else cs.withNeutralLightSurfaces() }, ) + // Immersive screens stay dark even at light theme (see [ForceDarkContent]). Resolve their scheme + // once here instead of letting every such subtree build a palette of its own. + val forcedDarkScheme = + if (isDark) { + colorScheme + } else { + rememberDynamicColorScheme( + seedColor = seedColor, + isDark = true, + isAmoled = true, + style = PaletteStyle.TonalSpot, + ) + } SystemBarAppearanceEffect(isDark) MaterialExpressiveTheme( colorScheme = colorScheme, @@ -138,9 +158,38 @@ fun AppTheme( LocalContentColor provides colorScheme.onSurfaceVariant, LocalAppColors provides if (isDark) DarkAppColors else LightAppColors, LocalIsDarkTheme provides isDark, + LocalForcedDarkColorScheme provides forcedDarkScheme, content = content, ) }, typography = typo(colorScheme), ) } + +/** + * Wraps immersive screens — artist, album, playlist, local playlist, podcast and the players — which + * always draw over dark artwork and must therefore render dark even on the light theme. + * + * [LocalForceDarkText] alone is not enough: it only recolors text through [typo]. Icons, buttons and + * every other Material default read [LocalContentColor] and [MaterialTheme.colorScheme], which at + * light theme resolve to dark-on-light and turn grey and unreadable over the artwork. Providing the + * dark scheme here fixes the whole subtree in one place. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ForceDarkContent(content: @Composable () -> Unit) { + val darkScheme = LocalForcedDarkColorScheme.current ?: MaterialTheme.colorScheme + MaterialExpressiveTheme( + colorScheme = darkScheme, + content = { + CompositionLocalProvider( + LocalForceDarkText provides true, + LocalIsDarkTheme provides true, + LocalContentColor provides darkScheme.onSurfaceVariant, + LocalAppColors provides DarkAppColors, + content = content, + ) + }, + typography = typo(darkScheme, forceDark = true), + ) +} From d91636b86a45e472c9a401b8b275103b4637f21f Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 23:14:55 +0700 Subject: [PATCH 22/47] refactor(player): apply volume to the player before persisting it --- .../kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt index bdd6f8c23..8a2bd819a 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -817,8 +817,10 @@ class SharedViewModel( is UIEvent.UpdateVolume -> { val newVolume = uiEvent.newVolume - dataStoreManager.setPlayerVolume(newVolume) + // Apply to the player first: persisting to DataStore is a suspending disk write + // and must not sit between the user's gesture and the audible change. mediaPlayerHandler.onPlayerEvent(PlayerEvent.UpdateVolume(newVolume)) + dataStoreManager.setPlayerVolume(newVolume) } } } From 1e20f6b493d590281b13092f87dfd84db91c69aa Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 23:14:56 +0700 Subject: [PATCH 23/47] feat(desktop): show the current track in the tray menu and tooltip --- .../com/maxrave/simpmusic/DesktopApp.kt | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt index b37c79629..76a5a870d 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -193,14 +194,35 @@ fun runDesktopApp(args: Array = emptyArray()) { val quitAppString = stringResource(Res.string.quit_app) val openMiniPlayer = stringResource(Res.string.open_miniplayer) val closeMiniPlayer = stringResource(Res.string.close_miniplayer) + val appName = stringResource(Res.string.app_name) + val nowPlayingData by sharedViewModel.nowPlayingScreenData.collectAsState() + val nowPlayingTitle = nowPlayingData.nowPlayingTitle + val nowPlayingArtist = nowPlayingData.artistName + val hasTrack = nowPlayingTitle.isNotBlank() + // Tray menus are narrow, so long titles would stretch the popup. + val trayLabel: (String) -> String = { if (it.length > 40) it.take(39).trimEnd() + "…" else it } Tray( icon = painterResource(Res.drawable.circle_app_icon), - tooltip = stringResource(Res.string.app_name), + tooltip = + when { + hasTrack && nowPlayingArtist.isNotBlank() -> "$nowPlayingTitle — $nowPlayingArtist" + hasTrack -> nowPlayingTitle + else -> appName + }, primaryAction = { isVisible = true windowState.isMinimized = false }, ) { + // Disabled entries: while the window is hidden the tray is the only place showing what + // is playing. + if (hasTrack) { + Item(trayLabel(nowPlayingTitle), isEnabled = false) + if (nowPlayingArtist.isNotBlank()) { + Item(trayLabel(nowPlayingArtist), isEnabled = false) + } + Divider() + } if (!isVisible) { Item(openAppString) { isVisible = true From 314bcf24426a762a906b6fb5c1711cef5b807fd8 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 23:14:56 +0700 Subject: [PATCH 24/47] refactor(desktop): drop the mini player button from the Now Playing top bar --- .../simpmusic/ui/screen/player/NowPlayingScreen.kt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt index bba458250..4ceb264b7 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt @@ -1363,16 +1363,6 @@ fun NowPlayingScreenContent( } }, actions = { - // Desktop mini player button (JVM only) - if (getPlatform() == Platform.Desktop) { - IconButton(onClick = { toggleMiniPlayer() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.OpenInNew, - contentDescription = "Mini Player", - tint = Color.White, - ) - } - } IconButton(onClick = { showSheet = true }) { From 45260568682bbdc9b3bc2fee0af8bea26093e3cb Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 23:14:56 +0700 Subject: [PATCH 25/47] chore(core): bump submodule for volume handling and chart countries --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 596e26e9e..ccaa2d11c 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 596e26e9eaba051455eccf9163163150b43abc4c +Subproject commit ccaa2d11c358d817bc99634153f189213bed3496 From 870f6911628460b4b500e3db6f0a7909391b85dd Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 29 Jul 2026 14:26:59 +0700 Subject: [PATCH 26/47] perf(desktop): tune allocator and GC to cut resident memory --- conveyor.conf | 35 +++++ core | 2 +- desktopApp/MEMORY_TUNING.md | 286 ++++++++++++++++++++++++++++++++++++ desktopApp/build.gradle.kts | 13 ++ 4 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 desktopApp/MEMORY_TUNING.md diff --git a/conveyor.conf b/conveyor.conf index c8e5b0ee7..c131bc19e 100644 --- a/conveyor.conf +++ b/conveyor.conf @@ -122,6 +122,26 @@ app { info-plist.UIBackgroundModes = [ "audio", "fetch", "processing" ] url-schemes = [ "simpmusic" ] + // Turn off Apple's "nano" malloc zone — the macOS counterpart of the + // MALLOC_ARENA_MAX cap the AppImage's AppRun sets for glibc. + // + // The nano zone serves allocations under 256 bytes from its own region and + // has a history of not coalescing what it frees, so the region only grows; + // Electron and Chrome both hit this. mpv/FFmpeg allocate small structures + // constantly on every track change, which is exactly that pattern. + // + // LSEnvironment is applied by LaunchServices, so it covers Finder/Dock/ + // Spotlight/`open` launches — i.e. every normal launch of an installed + // .app — but NOT running Contents/MacOS/... directly from a shell. + // + // Declaring this dict also pins the process PATH to the bare + // "/usr/bin:/bin:/usr/sbin:/sbin". Checked before adding: the only + // ProcessBuilder use on this path is the Windows-only powershell/wmic VM + // probe in DesktopApp.kt, and link opening goes through Desktop.browse(), + // which LaunchServices resolves without consulting PATH. Revisit this if + // the macOS build ever shells out to a binary outside those four dirs. + info-plist.LSEnvironment.MallocNanoZone = "0" + // Raised from the implicit 11.0 when the desktop backend moved from VLC to mpv. // mpv's own macOS release builds target macOS 15: 96 of the 98 bundled arm64 dylibs // declare minos 15.0, and on Intel libmpv itself does — so Conveyor refuses to package @@ -168,6 +188,21 @@ app { // any pressure — which is why resident memory sat around 1 GB. A music player's // live set is nowhere near that; the cap makes the GC collect on a sane cadence. options += "-Xmx512m" + + // Hand the unused half of the heap back to the OS. + // + // The cap above bounds how large the heap may GROW; it says nothing about giving committed + // pages back. Measured mid-session: G1 was holding 204 MB committed while only 104 MB was + // live, and that 100 MB counts against RSS just like anything else. + // + // G1 only uncommits at the end of a concurrent cycle or a full GC. A music player allocates + // so little that neither ever fires on its own — the heap simply ratchets to its high-water + // mark and stays there. G1PeriodicGCInterval is the missing half: it runs a cycle when the + // VM has been idle for the interval, which is precisely when trimming is free. Setting the + // free-ratio bounds without it would be a no-op. + options += "-XX:MinHeapFreeRatio=20" + options += "-XX:MaxHeapFreeRatio=40" + options += "-XX:G1PeriodicGCInterval=60000" } jvm.mac.options += "--add-opens=java.desktop/sun.awt=ALL-UNNAMED" diff --git a/core b/core index ccaa2d11c..fa02b4a21 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit ccaa2d11c358d817bc99634153f189213bed3496 +Subproject commit fa02b4a21f308d0597640facecdd05a5368a98ba diff --git a/desktopApp/MEMORY_TUNING.md b/desktopApp/MEMORY_TUNING.md new file mode 100644 index 000000000..a81da3a90 --- /dev/null +++ b/desktopApp/MEMORY_TUNING.md @@ -0,0 +1,286 @@ +# Desktop memory tuning + +Why the desktop app's RAM use was cut, what each change does, and how to undo any of it. + +Investigated 2026-07-29 against `45260568` (dev). Every number below was measured on a live +nightly AppImage, not estimated. + +--- + +## The problem + +The desktop app settled at roughly **1 GB resident on all three platforms**, and on Linux it kept +climbing the longer a listening session ran — reaching **1.90 GB after 1h40m**. + +This had been reported across Windows, macOS and Linux, which was the clue that mattered: a bug +confined to one platform's code would not show up identically on three different OSes. + +## What it was NOT + +Two plausible explanations were tested and **disproved**, so nobody has to re-investigate them: + +| Suspicion | How it was ruled out | +| --- | --- | +| Java heap growing unbounded | `-Xmx512m` was already in force and honoured. `jcmd GC.heap_info` showed the heap **using only 121 MB of its 512 MB cap** while RSS was 1.9 GB. | +| Leaked `MpvPlayer` handles (precache / crossfade) | Each live handle owns an `Mpv-Event-Pump` thread, so handles are directly countable. Over a 30-minute, 60-sample run the count **stayed flat at 3** and thread totals oscillated (117–158) without accumulating. Handles are released correctly. | + +## What it actually was + +**Allocator fragmentation.** The native memory had been `free()`d correctly — the C allocator was +simply holding onto the pages instead of returning them to the OS. + +The decisive test was calling `malloc_trim(0)` on the running process: + +``` +RSS before : 920 MB +$1 = 1 <- glibc reports it released memory +RSS after : 753 MB <- 167 MB handed straight back to the OS +``` + +Memory that can be returned on demand was, by definition, already free. That is fragmentation, not +a leak. + +On Linux the amplifier was glibc's per-thread arenas: + +``` +nproc = 20 -> glibc arena ceiling = 8 x nproc = 160 +anon mappings aligned to a 64 MB boundary, counted = 161 (the arena signature) +RSS held by those mappings = 1451 MB +everything else (Skia, JNA, thread stacks, libs) = 233 MB +``` + +glibc spawns a fresh arena whenever it sees allocator lock contention, up to `8 x nproc`, and never +gives one back. mpv/FFmpeg allocate and free large demuxer/decoder buffers on every track, and with +150+ threads in the process those allocations spread across every arena available. + +**This is why RAM use depends on the user's core count** — a 4-core machine tops out at 32 arenas, +a 20-core machine at 160. It also explains why the three platforms differ in severity while sharing +the same symptom: Windows' NT heap and macOS' libmalloc have their own per-core caching, they just +scale less aggressively. + +--- + +## The changes + +Four changes across two repositories. Three apply to all platforms; one is Linux-specific. + +| # | Change | File | Platforms | +| --- | --- | --- | --- | +| 1 | `MemoryTrimmer` — returns free pages to the OS when playback goes idle | `core/media/media-jvm/.../memory/MemoryTrimmer.kt` (new) + `.../mpv/MpvPlayerAdapter.kt` | all | +| 2 | Let G1 uncommit unused heap | `conveyor.conf` (`jvm` block) | all | +| 3 | Disable Apple's nano malloc zone | `conveyor.conf` (`mac` block) | macOS | +| 4 | Cap glibc arenas | `desktopApp/build.gradle.kts` (`AppRun`) | Linux | + +Files 1 live in the **`core/` submodule**; the rest are in the parent repository. + +### 1. `MemoryTrimmer` + +Every desktop allocator can be asked to return free pages; only the spelling differs. + +| OS | Call | Available since | +| --- | --- | --- | +| Linux | `malloc_trim(0)` | glibc | +| macOS | `malloc_zone_pressure_relief(NULL, 0)` | OS X 10.7 | +| Windows | `HeapSetInformation(NULL, HeapOptimizeResources, ...)` | Windows 8.1 | + +Bound through JNA, which the module already uses for libmpv. Called from the `PAUSED` and `IDLE` +transitions in `MpvPlayerAdapter`, throttled to at most once per 60 s, dispatched off the service +thread. + +**It must only run while idle.** `malloc_trim` walks the heap holding the allocator lock, so every +thread that calls `malloc` — mpv's decoder threads included — blocks until it returns. Calling it +during playback would be audible. + +### 2. G1 heap uncommit + +``` +-XX:MinHeapFreeRatio=20 +-XX:MaxHeapFreeRatio=40 +-XX:G1PeriodicGCInterval=60000 +``` + +Measured mid-session: G1 held **204 MB committed while only 104 MB was live**. `-Xmx` bounds how far +the heap may grow, not how much it hands back. + +The periodic-GC flag is load-bearing, not decoration. G1 only uncommits at the end of a concurrent +cycle or a full GC, and a music player allocates so little that neither ever fires — the heap simply +ratchets to its high-water mark and stays. **Setting the free-ratio bounds without +`G1PeriodicGCInterval` is a no-op.** + +### 3. macOS nano zone + +``` +info-plist.LSEnvironment.MallocNanoZone = "0" +``` + +The nano zone serves sub-256-byte allocations from its own region and has a history of not +coalescing what it frees. Electron and Chrome both hit this. + +Two properties of `LSEnvironment` worth knowing: + +- It is applied by **LaunchServices**, so it covers Finder / Dock / Spotlight / `open` launches — + every normal launch of an installed `.app` — but **not** running `Contents/MacOS/...` directly + from a shell. +- Declaring it **pins `PATH`** to the bare `/usr/bin:/bin:/usr/sbin:/sbin`. This was checked before + adding: the only `ProcessBuilder` use on the macOS path is the Windows-only `powershell`/`wmic` VM + probe, and link opening goes through `Desktop.browse()`, which LaunchServices resolves without + consulting `PATH`. **Re-check this if the macOS build ever shells out to a binary outside those + four directories.** + +### 4. glibc arena cap (Linux) + +```sh +export MALLOC_ARENA_MAX=2 +``` + +Added to the generated `AppRun`, so it is Linux-only by construction — that file only exists inside +the AppImage. + +Measured effect: arenas **161 → 5**, and RSS over a comparable session went from 1.90 GB to roughly +0.93 GB. The cost is more allocator lock contention, which this workload does not appear to notice: +the audio path is native and its buffers are long-lived. + +### Deliberately not changed + +- **Windows Segment Heap.** Microsoft's docs state the segment heap "has, by default, backed all + process heaps for packaged apps since its inception", and this project ships Windows **only** as + `.msix` — so it always runs with package identity and is already opted in. The + `heap:HeapPolicy` manifest element is an opt-**out**. Worth confirming once on a real build with + System Informer's heap-flags column or WinDbg `!heap`. +- **`demuxer-max-bytes`.** Looks like an easy 32 MB per handle, but it is a *ceiling*, not an + allocation. With `cache-secs = 10`, a 320 kbps stream prefetches about **400 KB** — the ceiling is + never approached, so lowering it would save nothing and only risk underruns. +- **Coil's in-memory cache.** Left at its default. + +--- + +## Rollback + +Each change is independent. Undo only the one whose symptom you see. + +### Symptom → suspect + +| Symptom | Suspect | Go to | +| --- | --- | --- | +| Audio glitches or stutters **when pausing / stopping** | `MemoryTrimmer` | A | +| General sluggishness on Linux, higher CPU under load | `MALLOC_ARENA_MAX=2` (lock contention) | D | +| CPU spikes while the app sits idle | `G1PeriodicGCInterval` | B | +| macOS feels slower overall | `MallocNanoZone=0` | C | +| macOS: "command not found" / a helper process fails to launch | `LSEnvironment` pinned `PATH` | C | + +### A. Disable `MemoryTrimmer` + +Least invasive first — comment out the two call sites in `MpvPlayerAdapter.transitionToState` +(submodule `core/`); the class can stay: + +```kotlin +InternalState.PAUSED -> { + ... + // trimNativeMemory("paused") +} +InternalState.IDLE -> { + ... + // trimNativeMemory("idle") +} +``` + +To keep trimming but make it rarer, raise `MIN_INTERVAL_MS` in `MemoryTrimmer` instead. To remove it +entirely: delete `core/media/media-jvm/.../memory/MemoryTrimmer.kt`, plus the import, the +`trimNativeMemory` helper and both calls in `MpvPlayerAdapter.kt`. + +### B. Revert G1 heap flags + +In `conveyor.conf`, delete these three lines from the `jvm` block. **Keep `-Xmx512m`** — that is +older and unrelated: + +``` +options += "-XX:MinHeapFreeRatio=20" +options += "-XX:MaxHeapFreeRatio=40" +options += "-XX:G1PeriodicGCInterval=60000" +``` + +### C. Revert the macOS nano zone + +In `conveyor.conf`, delete this line from the `mac` block: + +``` +info-plist.LSEnvironment.MallocNanoZone = "0" +``` + +Removing it also restores normal `PATH` inheritance, so this is the fix for both macOS symptoms +above. If you want the memory benefit but need `PATH` back, keep the line and add `PATH` explicitly +to the same dict rather than dropping it. + +### D. Revert or relax the arena cap + +In `desktopApp/build.gradle.kts`, inside the `AppRun` heredoc, remove: + +```sh +export MALLOC_ARENA_MAX=2 +``` + +Prefer **relaxing before removing**: `2` is the aggressive end. Try `4`, then `8`. Even `8` is far +below the 160 this machine class defaults to, and it keeps most of the benefit while cutting +contention. + +### Revert everything + +```bash +git -C core checkout -- media/media-jvm/src/main/java/com/simpmusic/media_jvm/mpv/MpvPlayerAdapter.kt +rm -rf core/media/media-jvm/src/main/java/com/simpmusic/media_jvm/memory +git checkout -- conveyor.conf desktopApp/build.gradle.kts +``` + +(Adjust if these changes have already been committed — then it is a revert of those commits instead.) + +--- + +## Measuring again + +Take the reading **after listening for ~30 minutes**, not at startup. The whole point is behaviour +over a session. + +**Linux** + +```bash +pid=$(pgrep -f -i simpmusic | while read p; do [ "$(cat /proc/$p/comm)" = simpmusic ] && echo $p; done | head -1) +grep VmRSS /proc/$pid/status +jcmd $pid GC.heap_info +# count glibc arenas (64 MB-aligned anonymous mappings) +awk '/^[0-9a-f]/{split($1,a,"-"); if ($6=="") print a[1]}' /proc/$pid/maps | + while read x; do python3 -c "print(1 if int('$x',16)%(64*1024*1024)==0 else 0)"; done | grep -c 1 +``` + +**macOS** + +```bash +pid=$(pgrep -f SimpMusic | head -1) +ps -o rss= -p $pid | awk '{print "RSS: "int($1/1024)" MB"}' +vmmap -summary $pid | head -25 +jcmd $pid GC.heap_info +``` + +**Windows (PowerShell)** + +```powershell +Get-Process SimpMusic | Select-Object Name,Id,@{n="WorkingSet_MB";e={[int]($_.WorkingSet64/1MB)}} +jcmd GC.heap_info +``` + +### Reading the result + +The number that matters is **the ratio of heap to RSS**, not RSS alone. + +- Heap small (~150 MB) but RSS large → native memory dominates; the changes above are the relevant + lever. +- Heap large, close to the 512 MB cap → a Java-side problem instead; allocator tuning will not help + and the investigation should start from `GC.heap_info` and a heap dump. + +### Reference points (before any of these changes) + +| Platform | RSS | Notes | +| --- | --- | --- | +| Linux, 20 cores, 1h40m session | 1.90 GB | heap using 121 MB; 161 arenas holding 1451 MB | +| Linux, with `MALLOC_ARENA_MAX=2`, 19 min | 0.93 GB | 5 arenas; still grew ~11 MB/min | +| Linux, after a manual `malloc_trim(0)` | 0.75 GB | 167 MB returned instantly | +| macOS / Windows | ~1 GB | reported; not yet broken down | diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 36ef0a25b..6184db70a 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -408,6 +408,19 @@ tasks.register("packageConveyorAppImage") { |sed "s|Exec=bin/simpmusic|Exec=${'$'}APPIMAGE_PATH|" "${'$'}HERE/simpmusic.desktop" > "${'$'}DESKTOP_DIR/com-maxrave-simpmusic-MainKt.desktop" |update-desktop-database "${'$'}DESKTOP_DIR" 2>/dev/null || true | + |# Cap glibc's per-thread malloc arenas. + |# + |# glibc spawns a fresh 64 MB-aligned arena whenever it sees mutex contention, up to + |# 8 x nproc of them, and never gives an arena back to the OS. Measured on a 20-core + |# box: 161 arenas holding 1.45 GB of a 1.9 GB RSS, while the JVM heap was using only + |# 121 MB of its 512 MB cap. Pinning the count to 2 took that to ~5 arenas. The cost is + |# more allocator lock contention, which this workload does not notice - the audio path + |# is native and its buffers are long-lived. + |# + |# Linux-only by construction: this file only exists inside the AppImage. macOS and + |# Windows tune their allocators elsewhere, and MemoryTrimmer covers all three at runtime. + |export MALLOC_ARENA_MAX=2 + | |cd "${'$'}HERE" |exec bin/simpmusic "${'$'}@" | From efca3463095a7171b43c7ad701f7a0e3d094a056 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 29 Jul 2026 16:35:49 +0700 Subject: [PATCH 27/47] fix(ui): show a generic album label when the album name is unknown --- .../simpmusic/ui/component/ModalBottomSheet.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/ModalBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/ModalBottomSheet.kt index d6c743569..25383781a 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/ModalBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/ModalBottomSheet.kt @@ -1789,8 +1789,20 @@ fun NowPlayingBottomSheet( } ActionButton( icon = painterResource(Res.drawable.baseline_album_24), - text = if (uiState.songUIState.album == null) Res.string.no_album else null, - textString = uiState.songUIState.album?.name, + // Three states, not two. A track can carry an album ID with no title: the + // row it was parsed from links an album but never spells its name out. + // That case still navigates, so it must not read "No album" — but the name + // is genuinely unknown, so fall back to the generic label rather than + // showing a blank row. The parser deliberately leaves the name empty + // instead of inventing one, because a made-up title would travel out to + // MediaSession and into external scrobblers. + text = + when { + uiState.songUIState.album == null -> Res.string.no_album + uiState.songUIState.album?.name.isNullOrBlank() -> Res.string.album + else -> null + }, + textString = uiState.songUIState.album?.name?.takeIf { it.isNotBlank() }, enable = uiState.songUIState.album != null, ) { uiState.songUIState.album?.id?.let { id -> From 65fac8ca87aad49e7204f9981be20eeb92ec5fb8 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 29 Jul 2026 16:35:49 +0700 Subject: [PATCH 28/47] chore(core): bump submodule for song column parsing and album self-repair --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index fa02b4a21..07549785b 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit fa02b4a21f308d0597640facecdd05a5368a98ba +Subproject commit 07549785b64a8e8eba11e8586bd79439f9ebaba1 From a9bbd6acd94010d59a1507cccb8b7ff00428e94f Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 30 Jul 2026 08:57:47 +0700 Subject: [PATCH 29/47] feat(windows): remove debug terminal --- conveyor.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conveyor.conf b/conveyor.conf index c131bc19e..e6ae7d0ea 100644 --- a/conveyor.conf +++ b/conveyor.conf @@ -159,7 +159,7 @@ app { // A Windows GUI binary has no console attached, so everything the app writes to // stdout/stderr is discarded and a startup failure leaves nothing to look at. // This attaches a console window that shows both. - console = true + // console = true } linux { From 11f592693809023e4b5471b5addb2f36b0320045 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 30 Jul 2026 13:20:13 +0700 Subject: [PATCH 30/47] fix(desktop): drop the macOS native memory trim behind the launch crash --- core | 2 +- desktopApp/MEMORY_TUNING.md | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/core b/core index 07549785b..15b9ba2e0 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 07549785b64a8e8eba11e8586bd79439f9ebaba1 +Subproject commit 15b9ba2e055aa5b3e274c2797ac3b4b3ed41b208 diff --git a/desktopApp/MEMORY_TUNING.md b/desktopApp/MEMORY_TUNING.md index a81da3a90..53ac54286 100644 --- a/desktopApp/MEMORY_TUNING.md +++ b/desktopApp/MEMORY_TUNING.md @@ -62,11 +62,11 @@ scale less aggressively. ## The changes -Four changes across two repositories. Three apply to all platforms; one is Linux-specific. +Four changes across two repositories, each scoped to the platforms listed below. | # | Change | File | Platforms | | --- | --- | --- | --- | -| 1 | `MemoryTrimmer` — returns free pages to the OS when playback goes idle | `core/media/media-jvm/.../memory/MemoryTrimmer.kt` (new) + `.../mpv/MpvPlayerAdapter.kt` | all | +| 1 | `MemoryTrimmer` — returns free pages to the OS when playback goes idle | `core/media/media-jvm/.../memory/MemoryTrimmer.kt` (new) + `.../mpv/MpvPlayerAdapter.kt` | Linux, Windows | | 2 | Let G1 uncommit unused heap | `conveyor.conf` (`jvm` block) | all | | 3 | Disable Apple's nano malloc zone | `conveyor.conf` (`mac` block) | macOS | | 4 | Cap glibc arenas | `desktopApp/build.gradle.kts` (`AppRun`) | Linux | @@ -80,9 +80,20 @@ Every desktop allocator can be asked to return free pages; only the spelling dif | OS | Call | Available since | | --- | --- | --- | | Linux | `malloc_trim(0)` | glibc | -| macOS | `malloc_zone_pressure_relief(NULL, 0)` | OS X 10.7 | | Windows | `HeapSetInformation(NULL, HeapOptimizeResources, ...)` | Windows 8.1 | +**macOS is deliberately excluded.** It does have an equivalent — `malloc_zone_pressure_relief(NULL, 0)` +— and it shipped briefly, but it was traced to a startup crash and removed. A null zone means *every +registered zone*, not just the one mpv/FFmpeg allocate from, so the call also asks the zones behind +Metal, QuartzCore and Skia to give pages back. Running off a background dispatcher it can land inside +a `CATransaction` commit on the main thread; the process then dies with an uncaught NSException +raised in `-[MTLLayer blitCallback]`, which macOS 26+ turns into a hard crash. + +The window is narrow enough that anything perturbing timing hides it — attaching `log stream` was +enough — so it only reproduced on a real Finder/Dock launch, where activation and the window +animation hold the main thread inside CoreAnimation longer. Change 3 below already covers allocator +growth on macOS, so nothing is lost. **Do not re-add the macOS branch.** + Bound through JNA, which the module already uses for libmpv. Called from the `PAUSED` and `IDLE` transitions in `MpvPlayerAdapter`, throttled to at most once per 60 s, dispatched off the service thread. @@ -163,6 +174,7 @@ Each change is independent. Undo only the one whose symptom you see. | Symptom | Suspect | Go to | | --- | --- | --- | | Audio glitches or stutters **when pausing / stopping** | `MemoryTrimmer` | A | +| macOS: hard crash a few seconds after a Finder/Dock launch, stack inside `-[MTLLayer blitCallback]` | `MemoryTrimmer`'s macOS branch is back | A | | General sluggishness on Linux, higher CPU under load | `MALLOC_ARENA_MAX=2` (lock contention) | D | | CPU spikes while the app sits idle | `G1PeriodicGCInterval` | B | | macOS feels slower overall | `MallocNanoZone=0` | C | From 90ec8999658cc8ed2b31c9f737d4545788f33de7 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 30 Jul 2026 22:53:32 +0700 Subject: [PATCH 31/47] feat(settings): confirm before logging out of a linked account --- .../composeResources/values/strings.xml | 2 ++ .../simpmusic/ui/screen/home/SettingScreen.kt | 26 ++++++++++++++--- .../simpmusic/viewModel/SettingsViewModel.kt | 29 +++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index e0ef7b20d..5a1d93c63 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -517,6 +517,8 @@ Log in to enable Discord Rich Presence Enable rich presence Show media session info on your Discord profile + Log out from Discord + You will have to log in again to use this feature. YouTube Liked Music Keep service alive Keep music player service alive to avoid being killed by system diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt index 97feb54e4..3518894e7 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt @@ -256,6 +256,8 @@ import simpmusic.composeapp.generated.resources.local_tracking_title import simpmusic.composeapp.generated.resources.log_in_to_discord import simpmusic.composeapp.generated.resources.log_in_to_spotify import simpmusic.composeapp.generated.resources.log_out +import simpmusic.composeapp.generated.resources.log_out_from_discord +import simpmusic.composeapp.generated.resources.log_out_from_spotify import simpmusic.composeapp.generated.resources.log_out_warning import simpmusic.composeapp.generated.resources.logged_in import simpmusic.composeapp.generated.resources.lrclib @@ -1520,7 +1522,14 @@ fun SettingScreen( modifier = Modifier.padding(vertical = 8.dp), ) SettingItem( - title = stringResource(Res.string.log_in_to_spotify), + // The title follows the state: a row that still reads "Log in" while logged in + // gives no clue that tapping it signs you out. + title = + if (spotifyLoggedIn) { + stringResource(Res.string.log_out_from_spotify) + } else { + stringResource(Res.string.log_in_to_spotify) + }, subtitle = if (spotifyLoggedIn) { stringResource(Res.string.logged_in) @@ -1529,7 +1538,9 @@ fun SettingScreen( }, onClick = { if (spotifyLoggedIn) { - viewModel.setSpotifyLogIn(false) + viewModel.confirmLogOut( + confirmLabel = runBlocking { getString(Res.string.log_out_from_spotify) }, + ) { viewModel.setSpotifyLogIn(false) } } else { navController.navigate(SpotifyLoginDestination) } @@ -1568,7 +1579,12 @@ fun SettingScreen( modifier = Modifier.padding(vertical = 8.dp), ) SettingItem( - title = stringResource(Res.string.log_in_to_discord), + title = + if (discordLoggedIn) { + stringResource(Res.string.log_out_from_discord) + } else { + stringResource(Res.string.log_in_to_discord) + }, subtitle = if (discordLoggedIn) { stringResource(Res.string.logged_in) @@ -1577,7 +1593,9 @@ fun SettingScreen( }, onClick = { if (discordLoggedIn) { - viewModel.logOutDiscord() + viewModel.confirmLogOut( + confirmLabel = runBlocking { getString(Res.string.log_out_from_discord) }, + ) { viewModel.logOutDiscord() } } else { navController.navigate(DiscordLoginDestination) } diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt index b382a58aa..b390a4bcf 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt @@ -44,12 +44,15 @@ import simpmusic.composeapp.generated.resources.Res import simpmusic.composeapp.generated.resources.backup_create_failed import simpmusic.composeapp.generated.resources.backup_create_success import simpmusic.composeapp.generated.resources.backup_in_progress +import simpmusic.composeapp.generated.resources.cancel import simpmusic.composeapp.generated.resources.clear_canvas_cache import simpmusic.composeapp.generated.resources.clear_downloaded_cache import simpmusic.composeapp.generated.resources.clear_player_cache import simpmusic.composeapp.generated.resources.clear_thumbnail_cache +import simpmusic.composeapp.generated.resources.log_out_confirm_message import simpmusic.composeapp.generated.resources.restore_failed import simpmusic.composeapp.generated.resources.restore_in_progress +import simpmusic.composeapp.generated.resources.warning class SettingsViewModel( private val dataStoreManager: DataStoreManager, @@ -751,6 +754,32 @@ class SettingsViewModel( _basicAlertData.value = alertData } + /** + * Asks before signing out of a linked account. + * + * Every one of these rows sits inside a long settings list and does its work on a single tap, + * with no undo — and getting back in is not symmetric with getting out, since it means a full + * web login. The confirmation is cheap next to that. + * + * @param confirmLabel names the service on the confirming button, because the dialog is the + * only thing on screen at that moment and "Log out" alone does not say out of what. + */ + fun confirmLogOut( + confirmLabel: String, + onConfirm: () -> Unit, + ) { + viewModelScope.launch { + setBasicAlertData( + SettingBasicAlertState( + title = getString(Res.string.warning), + message = getString(Res.string.log_out_confirm_message), + confirm = confirmLabel to onConfirm, + dismiss = getString(Res.string.cancel), + ), + ) + } + } + private fun getUsingProxy() { viewModelScope.launch { dataStoreManager.usingProxy.collectLatest { usingProxy -> From 8191438612a4980e3332a59d4bd6bac2cbe35083 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 30 Jul 2026 22:53:57 +0700 Subject: [PATCH 32/47] feat(lastfm): add Last.fm module gated by the full build --- .gitignore | 2 + androidApp/src/main/AndroidManifest.xml | 13 + .../maxrave/simpmusic/SimpMusicApplication.kt | 2 + composeApp/build.gradle.kts | 27 ++- .../com/maxrave/simpmusic/DesktopApp.kt | 2 + .../simpmusic/DesktopDeepLinkHandler.kt | 5 + .../simpmusic/WindowsProtocolRegistrar.kt | 44 +++- conveyor.conf | 6 +- lastfm-empty/build.gradle.kts | 44 ++++ .../kotlin/org/simpmusic/lastfm/Lastfm.kt | 80 +++++++ lastfm/build.gradle.kts | 52 ++++ .../kotlin/org/simpmusic/lastfm/Lastfm.kt | 147 ++++++++++++ .../org/simpmusic/lastfm/LastfmClient.kt | 222 ++++++++++++++++++ settings.gradle.kts | 2 + 14 files changed, 632 insertions(+), 16 deletions(-) create mode 100644 lastfm-empty/build.gradle.kts create mode 100644 lastfm-empty/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt create mode 100644 lastfm/build.gradle.kts create mode 100644 lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt create mode 100644 lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/LastfmClient.kt diff --git a/.gitignore b/.gitignore index c6077e489..8dc746cb3 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ sentry.properties /.claude/settings.local.json /androidApp/build/ /androidApp/cache/ +/lastfm/build/ +/lastfm-empty/build/ # libmpv native libraries (downloaded by `./gradlew :composeApp:mpvSetupAll`) /mpv-natives/ /.omc/ diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index fb9a57766..2a7347faa 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -159,6 +159,19 @@ + + + + + + + + + + diff --git a/androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt b/androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt index 6e73ae3e6..be6627209 100644 --- a/androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt +++ b/androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt @@ -35,6 +35,7 @@ import org.koin.core.context.loadKoinModules import org.koin.core.context.startKoin import org.koin.core.logger.Level import org.simpmusic.crashlytics.configCrashlytics +import org.simpmusic.lastfm.configLastfm import java.lang.reflect.Field class SimpMusicApplication : @@ -50,6 +51,7 @@ class SimpMusicApplication : super.onCreate() AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) configCrashlytics(this, BuildKonfig.sentryDsn) + configLastfm(BuildKonfig.lastfmApiKey, BuildKonfig.lastfmSecret) startKoin { androidLogger(level = Level.DEBUG) androidContext(this@SimpMusicApplication) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index fac0a31e6..e9f0eb612 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -122,6 +122,15 @@ kotlin { api(projects.domain) implementation(projects.data) + // Last.fm (gated: real scrobbler for full builds, no-op stub for FOSS builds). + // `api` rather than `implementation` so :androidApp can hand it the credentials from + // BuildKonfig at startup, the same way it does for Sentry. + if (isFullBuild) { + api(projects.lastfm) + } else { + api(projects.lastfmEmpty) + } + // Navigation Compose implementation(libs.navigation.compose) @@ -805,12 +814,28 @@ buildkonfig { "sentryDsn", properties.getProperty("SENTRY_DSN") ?: "", ) + buildConfigField( + STRING, + "lastfmApiKey", + properties.getProperty("LASTFM_API_KEY") ?: "", + ) + buildConfigField( + STRING, + "lastfmSecret", + properties.getProperty("LASTFM_SECRET") ?: "", + ) } catch (e: Exception) { - println("Failed to load SENTRY_DSN from local.properties: ${e.message}") + println("Failed to load secrets from local.properties: ${e.message}") buildConfigField(STRING, "sentryDsn", "") + buildConfigField(STRING, "lastfmApiKey", "") + buildConfigField(STRING, "lastfmSecret", "") } } else { buildConfigField(STRING, "sentryDsn", "") + // A FOSS build ships no Last.fm credentials, so `isLastfmAvailable()` stays false and + // the feature hides itself. The stub module is linked in this flavour anyway. + buildConfigField(STRING, "lastfmApiKey", "") + buildConfigField(STRING, "lastfmSecret", "") } } } diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt index 76a5a870d..b1af0dc1d 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt @@ -53,6 +53,7 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.core.context.loadKoinModules import org.koin.core.context.startKoin +import org.simpmusic.lastfm.configLastfm import org.koin.java.KoinJavaComponent.inject import org.koin.mp.KoinPlatform.getKoin import simpmusic.composeapp.generated.resources.Res @@ -131,6 +132,7 @@ fun runDesktopApp(args: Array = emptyArray()) { changeLanguageNative(language) VersionManager.initialize() + configLastfm(BuildKonfig.lastfmApiKey, BuildKonfig.lastfmSecret) if (BuildKonfig.sentryDsn.isNotEmpty()) { Sentry.init { options -> options.dsn = BuildKonfig.sentryDsn diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopDeepLinkHandler.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopDeepLinkHandler.kt index d35645d45..66a356b92 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopDeepLinkHandler.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopDeepLinkHandler.kt @@ -133,6 +133,11 @@ object DesktopDeepLinkHandler { Uri.parse(convertedUrl) } + // wordbyword://lastfm-auth?token=xxx → pass through untouched. It must NOT be rewritten + // to simpmusic.org like the branch above does: App.kt matches on this exact scheme to + // read the Last.fm request token. + parsed.scheme == "wordbyword" -> parsed + // https://simpmusic.org/app/... or YouTube URLs → pass through else -> parsed } diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/WindowsProtocolRegistrar.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/WindowsProtocolRegistrar.kt index 1fe25da48..82ec32eb2 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/WindowsProtocolRegistrar.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/WindowsProtocolRegistrar.kt @@ -20,7 +20,14 @@ import com.maxrave.logger.Logger object WindowsProtocolRegistrar { private const val TAG = "WindowsProtocolRegistrar" private const val SCHEME = "simpmusic" - private const val REG_KEY = "HKCU\\Software\\Classes\\$SCHEME" + + /** + * The Last.fm auth callback. Its scheme is fixed by the callback URL registered on the Last.fm + * API account, so it cannot be folded into "simpmusic". + */ + private const val LASTFM_SCHEME = "wordbyword" + + private fun regKeyOf(scheme: String) = "HKCU\\Software\\Classes\\$scheme" fun register() { if (!System.getProperty("os.name", "").contains("Windows", ignoreCase = true)) return @@ -30,33 +37,46 @@ object WindowsProtocolRegistrar { return } + register(SCHEME, "URL:SimpMusic Protocol", exePath) + register(LASTFM_SCHEME, "URL:SimpMusic Last.fm Callback", exePath) + } + + private fun register( + scheme: String, + description: String, + exePath: String, + ) { + val regKey = regKeyOf(scheme) try { - if (isAlreadyRegistered(exePath)) { - Logger.d(TAG, "Protocol handler already registered with correct path") + if (isAlreadyRegistered(scheme, exePath)) { + Logger.d(TAG, "$scheme:// already registered with correct path") return } - Logger.d(TAG, "Registering simpmusic:// protocol handler -> $exePath") + Logger.d(TAG, "Registering $scheme:// protocol handler -> $exePath") // Main key with protocol description - regAdd(REG_KEY, null, "URL:SimpMusic Protocol") - regAdd(REG_KEY, "URL Protocol", "") + regAdd(regKey, null, description) + regAdd(regKey, "URL Protocol", "") // DefaultIcon - regAdd("$REG_KEY\\DefaultIcon", null, "\"$exePath\",0") + regAdd("$regKey\\DefaultIcon", null, "\"$exePath\",0") // shell\open\command - regAdd("$REG_KEY\\shell\\open\\command", null, "\"$exePath\" \"%1\"") + regAdd("$regKey\\shell\\open\\command", null, "\"$exePath\" \"%1\"") - Logger.d(TAG, "Protocol handler registered successfully") + Logger.d(TAG, "$scheme:// registered successfully") } catch (e: Exception) { - Logger.e(TAG, "Failed to register protocol handler: ${e.message}") + Logger.e(TAG, "Failed to register $scheme:// handler: ${e.message}") } } - private fun isAlreadyRegistered(currentExePath: String): Boolean { + private fun isAlreadyRegistered( + scheme: String, + currentExePath: String, + ): Boolean { return try { - val result = regQuery("$REG_KEY\\shell\\open\\command", null) + val result = regQuery("${regKeyOf(scheme)}\\shell\\open\\command", null) // Registry stores path with quotes: "C:\path\to\SimpMusic.exe" "%1" // Normalize both for comparison val normalizedExe = currentExePath.replace("\\", "/").lowercase() diff --git a/conveyor.conf b/conveyor.conf index e6ae7d0ea..3d060cb2e 100644 --- a/conveyor.conf +++ b/conveyor.conf @@ -120,7 +120,7 @@ app { mac { info-plist.LSApplicationCategoryType = "public.app-category.music" info-plist.UIBackgroundModes = [ "audio", "fetch", "processing" ] - url-schemes = [ "simpmusic" ] + url-schemes = [ "simpmusic", "wordbyword" ] // Turn off Apple's "nano" malloc zone — the macOS counterpart of the // MALLOC_ARENA_MAX cap the AppImage's AppRun sets for glibc. @@ -153,7 +153,7 @@ app { windows { start-menu.group = "SimpMusic" - url-schemes = [ "simpmusic" ] + url-schemes = [ "simpmusic", "wordbyword" ] // DEBUG ONLY — remove before shipping a release. // A Windows GUI binary has no console attached, so everything the app writes to @@ -169,7 +169,7 @@ app { // desktopApp/.../Main.kt. Was "java-lang-Thread" (a stale guess that broke // once AWT started initializing on a coroutine worker thread). desktop-file.StartupWMClass = "SimpMusic" - url-schemes = [ "simpmusic" ] + url-schemes = [ "simpmusic", "wordbyword" ] } // --- Build settings ------------------------------------------------------- diff --git a/lastfm-empty/build.gradle.kts b/lastfm-empty/build.gradle.kts new file mode 100644 index 000000000..6d1423e8d --- /dev/null +++ b/lastfm-empty/build.gradle.kts @@ -0,0 +1,44 @@ +import com.android.build.gradle.internal.tasks.CompileArtProfileTask + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.android.lint) +} + +kotlin { + android { + namespace = "org.simpmusic.lastfm" + compileSdk = 37 + minSdk = 26 + } + val xcfName = "lastfmKit" + + iosArm64 { + binaries.framework { + baseName = xcfName + } + } + + iosSimulatorArm64 { + binaries.framework { + baseName = xcfName + } + } + + jvm() + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + // Logger only — no Ktor, no okio, no API key. A FOSS build carries no Last.fm code. + implementation(projects.common) + } + } + } +} + +tasks.withType { + enabled = false +} diff --git a/lastfm-empty/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt b/lastfm-empty/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt new file mode 100644 index 000000000..1e509f602 --- /dev/null +++ b/lastfm-empty/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt @@ -0,0 +1,80 @@ +package org.simpmusic.lastfm + +import com.maxrave.logger.Logger + +// NON-LASTFM build: direct scrobbling is not available in this build flavour, because a FOSS build +// ships no API secret. Every declaration mirrors the real module so callers never need to branch on +// build flavour — `isLastfmAvailable()` returning false is what hides the feature in the UI. + +data class LastfmSession( + val username: String, + val sessionKey: String, +) + +data class LastfmTrack( + val artist: String, + val track: String, + val album: String? = null, + val albumArtist: String? = null, + val durationSeconds: Int? = null, +) + +sealed interface LastfmOutcome { + data object Ok : LastfmOutcome + + data class Ignored( + val code: Int, + val message: String, + ) : LastfmOutcome + + data class Error( + val code: Int, + val message: String, + ) : LastfmOutcome { + val needsReauth: Boolean get() = code == ERROR_INVALID_SESSION + + val retryable: Boolean get() = code == 11 || code == 16 || code == 29 + } + + companion object { + const val ERROR_INVALID_SESSION = 9 + } +} + +private const val TAG = "Lastfm" + +private const val UNAVAILABLE = "NON-LASTFM build: Last.fm is not available" + +fun configLastfm( + key: String, + secret: String, +) { + Logger.d(TAG, UNAVAILABLE) +} + +fun isLastfmAvailable(): Boolean = false + +fun authorizeUrl(): String? = null + +suspend fun completeLogin(token: String): LastfmSession? { + Logger.d(TAG, UNAVAILABLE) + return null +} + +suspend fun updateNowPlaying( + sessionKey: String, + track: LastfmTrack, +): LastfmOutcome = LastfmOutcome.Error(0, UNAVAILABLE) + +suspend fun scrobble( + sessionKey: String, + track: LastfmTrack, + startedAtEpochSeconds: Long, +): LastfmOutcome = LastfmOutcome.Error(0, UNAVAILABLE) + +suspend fun setLoved( + sessionKey: String, + artist: String, + track: String, + loved: Boolean, +): LastfmOutcome = LastfmOutcome.Error(0, UNAVAILABLE) diff --git a/lastfm/build.gradle.kts b/lastfm/build.gradle.kts new file mode 100644 index 000000000..ae31647a3 --- /dev/null +++ b/lastfm/build.gradle.kts @@ -0,0 +1,52 @@ +import com.android.build.gradle.internal.tasks.CompileArtProfileTask + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.android.lint) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + android { + namespace = "org.simpmusic.lastfm" + compileSdk = 37 + minSdk = 26 + } + val xcfName = "lastfmKit" + + iosArm64 { + binaries.framework { + baseName = xcfName + } + } + + iosSimulatorArm64 { + binaries.framework { + baseName = xcfName + } + } + + jvm() + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(projects.common) + implementation(projects.ktorExt) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.kotlinx.serialization.json) + // MD5 for api_sig. okio hashes on every target this module builds for, + // so no expect/actual is needed just to sign a request. + implementation(libs.okio) + } + } + } +} + +tasks.withType { + enabled = false +} diff --git a/lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt b/lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt new file mode 100644 index 000000000..08115927e --- /dev/null +++ b/lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt @@ -0,0 +1,147 @@ +package org.simpmusic.lastfm + +import com.maxrave.logger.Logger + +// LAST.FM build: the real scrobbler. `lastfm-empty` mirrors every declaration in this file as a +// no-op, so callers never branch on the build flavour — see `cast` / `cast-empty` for the same +// arrangement. + +/** A logged-in Last.fm account. The session key has no expiry; the user revokes it from last.fm. */ +data class LastfmSession( + val username: String, + val sessionKey: String, +) + +/** What SimpMusic knows about a track, in the shape Last.fm's parameters expect. */ +data class LastfmTrack( + val artist: String, + val track: String, + val album: String? = null, + val albumArtist: String? = null, + val durationSeconds: Int? = null, +) + +/** + * The outcome of a write call. + * + * [Ignored] is the case worth spelling out: Last.fm answers `status="ok"` even when it threw the + * scrobble away, and only says so in `ignoredMessage`. Treating a 200 as success would hide bad + * metadata forever — codes 1 and 2 mean the artist or track name failed Last.fm's filter, which is + * exactly what a polluted artist like "13M plays" produces. + */ +sealed interface LastfmOutcome { + data object Ok : LastfmOutcome + + data class Ignored( + val code: Int, + val message: String, + ) : LastfmOutcome + + data class Error( + val code: Int, + val message: String, + ) : LastfmOutcome { + /** Code 9 means the stored session key is dead — the user has to log in again. */ + val needsReauth: Boolean get() = code == ERROR_INVALID_SESSION + + /** 11 offline, 16 temporary, 29 rate limited. Every other code is a malformed request. */ + val retryable: Boolean get() = code == 11 || code == 16 || code == 29 + } + + companion object { + const val ERROR_INVALID_SESSION = 9 + } +} + +private const val TAG = "Lastfm" + +private var apiKey: String = "" +private var sharedSecret: String = "" + +/** + * Hands the module its credentials. + * + * They arrive from the caller rather than living here because BuildKonfig generates into + * `composeApp`, which depends on this module and not the other way round — the same reason + * `configCrashlytics` takes the Sentry DSN as a parameter. + */ +fun configLastfm( + key: String, + secret: String, +) { + apiKey = key + sharedSecret = secret + Logger.d(TAG, "Last.fm configured: ${if (isLastfmAvailable()) "credentials present" else "no credentials"}") +} + +/** + * Whether this build can talk to Last.fm at all. + * + * False in a FOSS build, where the empty module answers for this function, and also false in a full + * build whose `local.properties` carries no key — so the settings entry hides itself rather than + * offering a login that could never succeed. + */ +fun isLastfmAvailable(): Boolean = apiKey.isNotEmpty() && sharedSecret.isNotEmpty() + +/** + * Step one: where to send the user so they can grant access. + * + * This is Last.fm's **web** flow, and the missing `token` parameter is the whole point of it. + * Last.fm generates the token itself and redirects to the callback registered on the API account + * with `?token=` appended. The desktop flow — call `auth.getToken` first, then open this URL with + * `&token=` already on it — tells Last.fm the app is holding the token, so it renders a "return to + * the application" page and never calls the callback at all. + * + * No `cb` parameter is passed either: the callback on the API account is the one to use. + */ +fun authorizeUrl(): String? { + if (!isLastfmAvailable()) return null + return "https://www.last.fm/api/auth/?api_key=$apiKey" +} + +/** + * Step two: trade the token that came back on the callback for a session key. + * + * A token is user- and account-specific, lives 60 minutes, and is consumed by this call — so a + * failure here means sending the user through [authorizeUrl] again, not retrying the same token. + */ +suspend fun completeLogin(token: String): LastfmSession? { + if (!isLastfmAvailable()) return null + return LastfmClient.getSession(apiKey, sharedSecret, token) +} + +/** Tells Last.fm what is playing right now. Does not affect charts, and is not worth retrying. */ +suspend fun updateNowPlaying( + sessionKey: String, + track: LastfmTrack, +): LastfmOutcome { + if (!isLastfmAvailable()) return LastfmOutcome.Error(0, "Last.fm is not configured") + return LastfmClient.updateNowPlaying(apiKey, sharedSecret, sessionKey, track) +} + +/** + * Records a play. + * + * [startedAtEpochSeconds] is when playback STARTED. Last.fm's own docs disagree with themselves + * here — the method page says the track started, the scrobbling guide says it finished — and every + * scrobbler in the wild sends the start time, so that is what this takes. + */ +suspend fun scrobble( + sessionKey: String, + track: LastfmTrack, + startedAtEpochSeconds: Long, +): LastfmOutcome { + if (!isLastfmAvailable()) return LastfmOutcome.Error(0, "Last.fm is not configured") + return LastfmClient.scrobble(apiKey, sharedSecret, sessionKey, track, startedAtEpochSeconds) +} + +/** Mirrors a like onto Last.fm's loved tracks. */ +suspend fun setLoved( + sessionKey: String, + artist: String, + track: String, + loved: Boolean, +): LastfmOutcome { + if (!isLastfmAvailable()) return LastfmOutcome.Error(0, "Last.fm is not configured") + return LastfmClient.setLoved(apiKey, sharedSecret, sessionKey, artist, track, loved) +} diff --git a/lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/LastfmClient.kt b/lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/LastfmClient.kt new file mode 100644 index 000000000..f5f9eddf4 --- /dev/null +++ b/lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/LastfmClient.kt @@ -0,0 +1,222 @@ +package org.simpmusic.lastfm + +import com.maxrave.ktorext.getEngine +import com.maxrave.logger.Logger +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.forms.submitForm +import io.ktor.client.statement.bodyAsText +import io.ktor.http.Parameters +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okio.ByteString.Companion.encodeUtf8 + +private const val TAG = "LastfmClient" +private const val ENDPOINT = "https://ws.audioscrobbler.com/2.0/" + +/** + * Every call Last.fm needs, signed and parsed. + * + * Responses are read as loose [JsonObject]s instead of `@Serializable` classes on purpose. Last.fm's + * JSON is a mechanical translation of its XML: numbers arrive as strings, attributes hide under + * `@attr`, and a field is an object with one element but an array with several. A schema that + * mirrors all of that breaks the moment a response has two entries instead of one, and there is + * nothing here worth reading past a handful of fields. + */ +internal object LastfmClient { + private val json = Json { ignoreUnknownKeys = true } + + private val client = + HttpClient(getEngine()) { + install(HttpTimeout) { + connectTimeoutMillis = 15_000 + requestTimeoutMillis = 15_000 + socketTimeoutMillis = 15_000 + } + } + + /** + * Builds `api_sig`. + * + * Parameters are ordered by name, concatenated as ``, the shared secret is appended + * and the whole thing is MD5'd. `format` is excluded because the spec says so — signing it is + * the classic reason every request comes back "Invalid method signature supplied" (code 13). + */ + private fun sign( + params: Map, + secret: String, + ): String = + params + .filterKeys { it != "format" && it != "callback" } + .entries + // sortedBy, not toSortedMap: the latter is a JDK collection and does not exist in + // common Kotlin. Parameter names are ASCII, so String ordering is the alphabetical + // ordering the spec asks for. + .sortedBy { it.key } + .joinToString(separator = "") { "${it.key}${it.value}" } + .plus(secret) + .encodeUtf8() + .md5() + .hex() + + /** + * Signs [params], posts them as a form and hands back the parsed body. + * + * Everything goes over POST, including the two auth calls: the per-method pages call them GET + * but the authentication spec calls them POST, and POST is accepted for both. + */ + private suspend fun call( + params: Map, + secret: String, + ): JsonObject? = + runCatching { + val signed = params + ("api_sig" to sign(params, secret)) + ("format" to "json") + val body = + client + .submitForm( + url = ENDPOINT, + formParameters = + Parameters.build { + signed.forEach { (key, value) -> append(key, value) } + }, + ).bodyAsText() + json.parseToJsonElement(body).jsonObject + }.onFailure { + Logger.e(TAG, "Request failed: ${it.message}") + }.getOrNull() + + /** Reads a field that Last.fm may send as either a JSON string or a JSON number. */ + private fun JsonObject.intOrNull(key: String): Int? = this[key]?.jsonPrimitive?.content?.toIntOrNull() + + private fun JsonObject.stringOrNull(key: String): String? = this[key]?.jsonPrimitive?.content + + /** Turns an `{"error": N, "message": "..."}` body into an outcome, or null when the call worked. */ + private fun JsonObject.asErrorOrNull(): LastfmOutcome.Error? { + val code = intOrNull("error") ?: return null + return LastfmOutcome.Error(code, stringOrNull("message") ?: "Unknown Last.fm error") + } + + /** + * Reads the `ignoredMessage` that rides along with an otherwise successful write. + * + * Code 0 means nothing was ignored. 1 and 2 mean the artist or track name failed Last.fm's + * filter, 3 and 4 mean the timestamp was too far in the past or future, 5 means the daily + * scrobble limit is spent. + */ + private fun JsonObject.ignoredOrNull(): LastfmOutcome.Ignored? { + val ignored = this["ignoredMessage"]?.jsonObject ?: return null + val code = ignored.intOrNull("code") ?: return null + if (code == 0) return null + return LastfmOutcome.Ignored(code, ignored.stringOrNull("#text").orEmpty()) + } + + suspend fun getSession( + apiKey: String, + secret: String, + token: String, + ): LastfmSession? { + val response = + call( + mapOf("method" to "auth.getSession", "api_key" to apiKey, "token" to token), + secret, + ) ?: return null + response.asErrorOrNull()?.let { + Logger.e(TAG, "auth.getSession failed: ${it.code} ${it.message}") + return null + } + val session = response["session"]?.jsonObject ?: return null + val name = session.stringOrNull("name") ?: return null + val key = session.stringOrNull("key") ?: return null + return LastfmSession(username = name, sessionKey = key) + } + + suspend fun updateNowPlaying( + apiKey: String, + secret: String, + sessionKey: String, + track: LastfmTrack, + ): LastfmOutcome { + val params = + buildMap { + put("method", "track.updateNowPlaying") + put("api_key", apiKey) + put("sk", sessionKey) + put("artist", track.artist) + put("track", track.track) + track.album?.takeIf { it.isNotBlank() }?.let { put("album", it) } + track.albumArtist?.takeIf { it.isNotBlank() }?.let { put("albumArtist", it) } + track.durationSeconds?.takeIf { it > 0 }?.let { put("duration", it.toString()) } + } + val response = call(params, secret) ?: return LastfmOutcome.Error(-1, "Network error") + response.asErrorOrNull()?.let { return it } + val nowPlaying = response["nowplaying"]?.jsonObject ?: return LastfmOutcome.Ok + return nowPlaying.ignoredOrNull() ?: LastfmOutcome.Ok + } + + /** + * Sends one play. + * + * The parameters are indexed (`artist[0]`) even though a single scrobble may be sent with plain + * names. Indexing one entry is the same shape a batch uses, so growing this into an offline + * queue later — Last.fm takes up to 50 per request — costs no rewrite. + * + * `duration` is always sent: the method page lists it as optional but the scrobbling guide lists + * it as required, and sending it satisfies both. + */ + suspend fun scrobble( + apiKey: String, + secret: String, + sessionKey: String, + track: LastfmTrack, + startedAtEpochSeconds: Long, + ): LastfmOutcome { + val params = + buildMap { + put("method", "track.scrobble") + put("api_key", apiKey) + put("sk", sessionKey) + put("artist[0]", track.artist) + put("track[0]", track.track) + put("timestamp[0]", startedAtEpochSeconds.toString()) + track.album?.takeIf { it.isNotBlank() }?.let { put("album[0]", it) } + track.albumArtist?.takeIf { it.isNotBlank() }?.let { put("albumArtist[0]", it) } + track.durationSeconds?.takeIf { it > 0 }?.let { put("duration[0]", it.toString()) } + put("chosenByUser[0]", "1") + } + val response = call(params, secret) ?: return LastfmOutcome.Error(-1, "Network error") + response.asErrorOrNull()?.let { return it } + + val scrobbles = response["scrobbles"]?.jsonObject ?: return LastfmOutcome.Ok + // One scrobble comes back as an object, several as an array — read whichever arrived. + val first = + when (val entry = scrobbles["scrobble"]) { + is JsonArray -> entry.firstOrNull()?.jsonObject + is JsonObject -> entry + else -> null + } + return first?.ignoredOrNull() ?: LastfmOutcome.Ok + } + + suspend fun setLoved( + apiKey: String, + secret: String, + sessionKey: String, + artist: String, + track: String, + loved: Boolean, + ): LastfmOutcome { + val params = + mapOf( + "method" to if (loved) "track.love" else "track.unlove", + "api_key" to apiKey, + "sk" to sessionKey, + "artist" to artist, + "track" to track, + ) + val response = call(params, secret) ?: return LastfmOutcome.Error(-1, "Network error") + return response.asErrorOrNull() ?: LastfmOutcome.Ok + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index b49aca15a..20eb71f0b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -60,6 +60,8 @@ include( ":crashlytics-empty", ":cast", ":cast-empty", + ":lastfm", + ":lastfm-empty", ":kizzy", ) From 48cff13568a5fcb53eb784e802c0dd4975c2b7f2 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 30 Jul 2026 22:54:54 +0700 Subject: [PATCH 33/47] feat(lastfm): add login screen and scrobbling settings --- .../composeResources/values/strings.xml | 14 + .../kotlin/com/maxrave/simpmusic/App.kt | 12 + .../login/LastfmLoginDestination.kt | 13 + .../ui/navigation/graph/LoginScreenGraph.kt | 11 + .../simpmusic/ui/screen/home/SettingScreen.kt | 59 ++++ .../ui/screen/login/LastfmLoginScreen.kt | 287 ++++++++++++++++++ .../simpmusic/viewModel/LogInViewModel.kt | 102 +++++++ .../simpmusic/viewModel/SettingsViewModel.kt | 57 +++- .../simpmusic/viewModel/SharedViewModel.kt | 30 ++ 9 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/destination/login/LastfmLoginDestination.kt create mode 100644 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/LastfmLoginScreen.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 5a1d93c63..0fcfcd765 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -518,7 +518,21 @@ Enable rich presence Show media session info on your Discord profile Log out from Discord + Last.fm + Log in to Last.fm + Log out from Last.fm You will have to log in again to use this feature. + Log in to scrobble what you listen to + Enable scrobbling + Send what you play to your Last.fm profile + Open the Last.fm page and grant SimpMusic access to your account. + SimpMusic opens again on its own once you have granted access. + Open Last.fm + Could not log in to Last.fm. Please try again. + Did not come back automatically? + Paste the link your browser was redirected to + Continue with this link + Logged in as %1$s YouTube Liked Music Keep service alive Keep music player service alive to avoid being killed by system diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/App.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/App.kt index 3767d8ec0..2ae75604a 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/App.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/App.kt @@ -180,6 +180,18 @@ fun App(viewModel: SharedViewModel = koinInject()) { navController.navigate( NotificationDestination, ) + } else if (data.scheme == "wordbyword" && data.host == "lastfm-auth") { + // Last.fm sends the user back here after they approve access, carrying the request + // token: wordbyword://lastfm-auth?token=xxx. The callback is fixed on the API + // account, which is why the scheme is not "simpmusic". + val token = data.getQueryParameter("token") + Logger.d("MainActivity", "Last.fm callback, token present: ${!token.isNullOrEmpty()}") + viewModel.setIntent(null) + // Deliberately no navigation: the login screen is almost certainly already open — + // the browser was opened from it — and navigating would stack a second copy on top + // of it. The token is handed straight to the shared view model, and the screen + // closes itself when it sees a session key appear. + token?.let { viewModel.completeLastfmLogin(it) } } else if (data.host == "simpmusic.org" || data.scheme == "simpmusic") { // https://simpmusic.org/app/watch?v=VIDEO_ID // https://simpmusic.org/app/playlist?list=PLAYLIST_ID diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/destination/login/LastfmLoginDestination.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/destination/login/LastfmLoginDestination.kt new file mode 100644 index 000000000..826f51bc5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/destination/login/LastfmLoginDestination.kt @@ -0,0 +1,13 @@ +package com.maxrave.simpmusic.ui.navigation.destination.login + +import kotlinx.serialization.Serializable + +/** + * Carries nothing on purpose. + * + * The Last.fm callback is handled by `SharedViewModel` when the browser returns, not routed here — + * passing the token through navigation would open a second copy of this screen on top of the one + * the user opened their browser from. + */ +@Serializable +data object LastfmLoginDestination diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/LoginScreenGraph.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/LoginScreenGraph.kt index a2c89c9b1..2d6b5d3ce 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/LoginScreenGraph.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/LoginScreenGraph.kt @@ -5,9 +5,11 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import com.maxrave.simpmusic.ui.navigation.destination.login.DiscordLoginDestination +import com.maxrave.simpmusic.ui.navigation.destination.login.LastfmLoginDestination import com.maxrave.simpmusic.ui.navigation.destination.login.LoginDestination import com.maxrave.simpmusic.ui.navigation.destination.login.SpotifyLoginDestination import com.maxrave.simpmusic.ui.screen.login.DiscordLoginScreen +import com.maxrave.simpmusic.ui.screen.login.LastfmLoginScreen import com.maxrave.simpmusic.ui.screen.login.LoginScreen import com.maxrave.simpmusic.ui.screen.login.SpotifyLoginScreen @@ -43,4 +45,13 @@ fun NavGraphBuilder.loginScreenGraph( showBottomNavigation = showBottomBar, ) } + + composable { + LastfmLoginScreen( + innerPadding = innerPadding, + navController = navController, + hideBottomNavigation = hideBottomBar, + showBottomNavigation = showBottomBar, + ) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt index 3518894e7..00c6a64af 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt @@ -119,6 +119,7 @@ import com.maxrave.simpmusic.ui.component.RippleIconButton import com.maxrave.simpmusic.ui.component.SettingItem import com.maxrave.simpmusic.ui.navigation.destination.home.CreditDestination import com.maxrave.simpmusic.ui.navigation.destination.login.DiscordLoginDestination +import com.maxrave.simpmusic.ui.navigation.destination.login.LastfmLoginDestination import com.maxrave.simpmusic.ui.navigation.destination.login.LoginDestination import com.maxrave.simpmusic.ui.navigation.destination.login.SpotifyLoginDestination import com.maxrave.simpmusic.ui.theme.md_theme_dark_primary @@ -232,7 +233,13 @@ import simpmusic.composeapp.generated.resources.guest import simpmusic.composeapp.generated.resources.help_build_lyrics_database import simpmusic.composeapp.generated.resources.help_build_lyrics_database_description import simpmusic.composeapp.generated.resources.http +import simpmusic.composeapp.generated.resources.enable_scrobbling import simpmusic.composeapp.generated.resources.intro_login_to_discord +import simpmusic.composeapp.generated.resources.intro_login_to_lastfm +import simpmusic.composeapp.generated.resources.lastfm_integration +import simpmusic.composeapp.generated.resources.log_in_to_lastfm +import simpmusic.composeapp.generated.resources.logged_in_as +import simpmusic.composeapp.generated.resources.scrobbling_info import simpmusic.composeapp.generated.resources.intro_login_to_spotify import simpmusic.composeapp.generated.resources.invalid import simpmusic.composeapp.generated.resources.invalid_api_key @@ -257,6 +264,7 @@ import simpmusic.composeapp.generated.resources.log_in_to_discord import simpmusic.composeapp.generated.resources.log_in_to_spotify import simpmusic.composeapp.generated.resources.log_out import simpmusic.composeapp.generated.resources.log_out_from_discord +import simpmusic.composeapp.generated.resources.log_out_from_lastfm import simpmusic.composeapp.generated.resources.log_out_from_spotify import simpmusic.composeapp.generated.resources.log_out_warning import simpmusic.composeapp.generated.resources.logged_in @@ -470,6 +478,9 @@ fun SettingScreen( val customThemeColorHex by sharedViewModel.getCustomThemeColor().collectAsStateWithLifecycle(DataStoreManager.DEFAULT_THEME_COLOR_HEX) var showColorPickerDialog by rememberSaveable { mutableStateOf(false) } val discordLoggedIn by viewModel.discordLoggedIn.collectAsStateWithLifecycle() + val lastfmLoggedIn by viewModel.lastfmLoggedIn.collectAsStateWithLifecycle() + val lastfmUsername by viewModel.lastfmUsername.collectAsStateWithLifecycle() + val lastfmScrobbleEnabled by viewModel.lastfmScrobbleEnabled.collectAsStateWithLifecycle() val richPresenceEnabled by viewModel.richPresenceEnabled.collectAsStateWithLifecycle() val keepServiceAlive by viewModel.keepServiceAlive.collectAsStateWithLifecycle() @@ -1614,6 +1625,54 @@ fun SettingScreen( ) } } + // Hidden entirely when the build carries no Last.fm credentials — a FOSS build, or a full + // build whose local.properties has no key. + if (viewModel.lastfmAvailable) { + item(key = "lastfm") { + Column { + Text( + text = stringResource(Res.string.lastfm_integration), + style = typo().labelMedium, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(vertical = 8.dp), + ) + SettingItem( + title = + if (lastfmLoggedIn) { + stringResource(Res.string.log_out_from_lastfm) + } else { + stringResource(Res.string.log_in_to_lastfm) + }, + subtitle = + if (lastfmLoggedIn) { + stringResource(Res.string.logged_in_as, lastfmUsername) + } else { + stringResource(Res.string.intro_login_to_lastfm) + }, + onClick = { + if (lastfmLoggedIn) { + viewModel.confirmLogOut( + confirmLabel = runBlocking { getString(Res.string.log_out_from_lastfm) }, + ) { viewModel.logOutLastfm() } + } else { + navController.navigate(LastfmLoginDestination) + } + }, + ) + SettingItem( + title = stringResource(Res.string.enable_scrobbling), + subtitle = stringResource(Res.string.scrobbling_info), + switch = (lastfmScrobbleEnabled to { viewModel.setLastfmScrobbleEnabled(it) }), + isEnable = lastfmLoggedIn, + onDisable = { + if (lastfmScrobbleEnabled) { + viewModel.setLastfmScrobbleEnabled(false) + } + }, + ) + } + } + } item(key = "sponsor_block") { Column { Text( diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/LastfmLoginScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/LastfmLoginScreen.kt new file mode 100644 index 000000000..13cca2377 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/LastfmLoginScreen.kt @@ -0,0 +1,287 @@ +package com.maxrave.simpmusic.ui.screen.login + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +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.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.maxrave.simpmusic.ui.component.RippleIconButton +import com.maxrave.simpmusic.ui.theme.typo +import com.maxrave.simpmusic.viewModel.LastfmLoginState +import com.maxrave.simpmusic.viewModel.LogInViewModel +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import simpmusic.composeapp.generated.resources.Res +import simpmusic.composeapp.generated.resources.baseline_arrow_back_ios_new_24 +import simpmusic.composeapp.generated.resources.lastfm_login_failed +import simpmusic.composeapp.generated.resources.lastfm_login_step_1 +import simpmusic.composeapp.generated.resources.lastfm_login_step_2 +import simpmusic.composeapp.generated.resources.lastfm_open_authorize_page +import simpmusic.composeapp.generated.resources.lastfm_paste_callback_confirm +import simpmusic.composeapp.generated.resources.lastfm_paste_callback_hint +import simpmusic.composeapp.generated.resources.lastfm_paste_callback_title +import simpmusic.composeapp.generated.resources.log_in_to_lastfm +import simpmusic.composeapp.generated.resources.login_success +import simpmusic.composeapp.generated.resources.scrobbling_info + +/** Desktop is far wider than any reading measure — the column stops here and centres. */ +private val CONTENT_MAX_WIDTH = 420.dp + +/** + * Last.fm's desktop auth flow, which is what SimpMusic uses on every platform. + * + * There is no WebView here on purpose — unlike the Discord and Spotify screens, this never sees the + * user's password. SimpMusic asks Last.fm for a request token, sends the user to Last.fm's own page + * in their browser, and trades the approved token for a session key when they come back. + * + * @param token supplied when the user returns through the `wordbyword://lastfm-auth` callback; the + * login then finishes on its own and the screen closes. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LastfmLoginScreen( + innerPadding: PaddingValues, + navController: NavController, + viewModel: LogInViewModel = koinViewModel(), + hideBottomNavigation: () -> Unit, + showBottomNavigation: () -> Unit, +) { + val state by viewModel.lastfmState.collectAsStateWithLifecycle() + val loggedIn by viewModel.lastfmLoggedIn.collectAsStateWithLifecycle() + val uriHandler = LocalUriHandler.current + var callbackInput by rememberSaveable { mutableStateOf("") } + + LaunchedEffect(Unit) { + hideBottomNavigation() + } + + DisposableEffect(Unit) { + onDispose { + viewModel.resetLastfmState() + showBottomNavigation() + } + } + + // Closes on a stored session key rather than on this screen's own state, because the callback + // is handled by SharedViewModel — the browser redirect never reaches this screen. + LaunchedEffect(loggedIn) { + if (loggedIn) { + navController.navigateUp() + } + } + + LaunchedEffect(state) { + when (val current = state) { + is LastfmLoginState.AwaitingApproval -> uriHandler.openUri(current.authorizeUrl) + is LastfmLoginState.LoggedIn -> viewModel.makeToast(getString(Res.string.login_success)) + is LastfmLoginState.Failed -> viewModel.makeToast(getString(Res.string.lastfm_login_failed)) + else -> Unit + } + } + + val busy = state is LastfmLoginState.CompletingLogin + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(innerPadding.calculateTopPadding() + 96.dp)) + + Column( + modifier = Modifier.widthIn(max = CONTENT_MAX_WIDTH), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(Res.string.scrobbling_info), + style = typo().bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(32.dp)) + + if (busy) { + CircularProgressIndicator() + } else { + NumberedStep( + number = "1", + text = stringResource(Res.string.lastfm_login_step_1), + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { viewModel.startLastfmLogin() }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = CircleShape, + ) { + Text( + text = stringResource(Res.string.lastfm_open_authorize_page), + // typo() bakes a colour into the style, so a Text inside a filled + // button keeps the body colour and disappears into the container + // unless the colour is set here. + style = typo().labelMedium, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + + // Everything below only matters once the browser is open. + if (state is LastfmLoginState.AwaitingApproval) { + Spacer(Modifier.height(28.dp)) + NumberedStep( + number = "2", + text = stringResource(Res.string.lastfm_login_step_2), + ) + + Spacer(Modifier.height(28.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(Modifier.height(20.dp)) + + // Last resort when the redirect never reaches the app at all: no handler + // registered for the scheme (common on Linux), or a browser that will not + // hand off to a local app. The address bar still shows the callback, so let + // the user bring it over by hand. + Text( + text = stringResource(Res.string.lastfm_paste_callback_title), + style = typo().labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = callbackInput, + onValueChange = { callbackInput = it }, + singleLine = true, + textStyle = typo().bodySmall, + placeholder = { + Text( + text = stringResource(Res.string.lastfm_paste_callback_hint), + style = typo().bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(10.dp)) + OutlinedButton( + onClick = { viewModel.completeLastfmLoginFromCallback(callbackInput) }, + enabled = callbackInput.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = CircleShape, + ) { + Text( + text = stringResource(Res.string.lastfm_paste_callback_confirm), + style = typo().labelSmall, + color = + if (callbackInput.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } + + Spacer(Modifier.height(48.dp)) + } + + TopAppBar( + modifier = Modifier.align(Alignment.TopCenter), + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + title = { + Text( + text = stringResource(Res.string.log_in_to_lastfm), + style = typo().titleMedium, + ) + }, + navigationIcon = { + Box(Modifier.padding(horizontal = 5.dp)) { + RippleIconButton( + Res.drawable.baseline_arrow_back_ios_new_24, + Modifier.size(32.dp), + true, + ) { + navController.navigateUp() + } + } + }, + ) + } +} + +/** A step marker plus its instruction, so the two actions read as an ordered pair. */ +@Composable +private fun NumberedStep( + number: String, + text: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(24.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = number, + style = typo().labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + Text( + text = text, + style = typo().bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LogInViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LogInViewModel.kt index 00858b80d..733d4cf1b 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LogInViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LogInViewModel.kt @@ -5,8 +5,13 @@ import com.maxrave.domain.manager.DataStoreManager import com.maxrave.simpmusic.viewModel.base.BaseViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import org.simpmusic.lastfm.authorizeUrl +import org.simpmusic.lastfm.completeLogin class LogInViewModel( private val dataStoreManager: DataStoreManager, @@ -64,4 +69,101 @@ class LogInViewModel( dataStoreManager.setDiscordToken(token) } } + + private val _lastfmState: MutableStateFlow = MutableStateFlow(LastfmLoginState.Idle) + val lastfmState: StateFlow get() = _lastfmState.asStateFlow() + + /** + * True once a session key is stored, whoever put it there. + * + * The callback can be handled outside this screen — [SharedViewModel.completeLastfmLogin] takes + * it when the browser returns — so the screen watches the stored result instead of only its own + * state. That is what lets it close itself in both paths: the redirect, and the pasted link. + */ + val lastfmLoggedIn: StateFlow = + dataStoreManager.lastfmSessionKey + .map { it.isNotEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + /** + * Sends the user to Last.fm to approve access. + * + * Nothing is fetched first: in the web flow Last.fm mints the token itself and hands it back on + * the callback, so the app has no token to hold on to until the user returns. + */ + fun startLastfmLogin() { + val url = authorizeUrl() + _lastfmState.value = + if (url != null) { + LastfmLoginState.AwaitingApproval(authorizeUrl = url) + } else { + LastfmLoginState.Failed + } + } + + /** Exchanges the token that arrived on the callback for a session key. */ + fun completeLastfmLogin(token: String) { + viewModelScope.launch { + if (token.isEmpty()) return@launch + _lastfmState.value = LastfmLoginState.CompletingLogin + val session = completeLogin(token) + if (session != null) { + dataStoreManager.setLastfmSession( + sessionKey = session.sessionKey, + username = session.username, + ) + _lastfmState.value = LastfmLoginState.LoggedIn(session.username) + } else { + _lastfmState.value = LastfmLoginState.Failed + } + } + } + + /** + * Finishes the login from whatever the user pasted back. + * + * The callback deep link cannot be relied on: a Linux desktop may have no handler registered for + * the scheme, a browser may refuse to hand off to a local app, and the approval may even have + * happened on a different device. In all of those the user can still see the redirect target in + * their address bar, so accept it directly. + * + * Takes either the whole callback URL (`wordbyword://lastfm-auth?token=abc`) or a bare token, + * because which of the two a user manages to copy is not something to be strict about. + */ + fun completeLastfmLoginFromCallback(input: String) { + val trimmed = input.trim() + if (trimmed.isEmpty()) return + val token = + if (trimmed.contains("token=")) { + trimmed.substringAfter("token=").substringBefore("&").trim() + } else { + trimmed + } + if (token.isEmpty()) { + _lastfmState.value = LastfmLoginState.Failed + return + } + completeLastfmLogin(token) + } + + fun resetLastfmState() { + _lastfmState.value = LastfmLoginState.Idle + } +} + +sealed interface LastfmLoginState { + data object Idle : LastfmLoginState + + /** The browser is open and the user has not come back yet. */ + data class AwaitingApproval( + val authorizeUrl: String, + ) : LastfmLoginState + + data object CompletingLogin : LastfmLoginState + + data class LoggedIn( + val username: String, + ) : LastfmLoginState + + data object Failed : LastfmLoginState } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt index b390a4bcf..f92e79243 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.koin.core.component.inject +import org.simpmusic.lastfm.isLastfmAvailable import simpmusic.composeapp.generated.resources.Res import simpmusic.composeapp.generated.resources.backup_create_failed import simpmusic.composeapp.generated.resources.backup_create_success @@ -165,6 +166,22 @@ class SettingsViewModel( private val _richPresenceEnabled = MutableStateFlow(false) val richPresenceEnabled: StateFlow = _richPresenceEnabled + /** + * False in a FOSS build, and in a full build with no API key in `local.properties`. The whole + * Last.fm block in settings is hidden when it is false, rather than offering a login that + * could never succeed. + */ + val lastfmAvailable: Boolean = isLastfmAvailable() + + private val _lastfmUsername = MutableStateFlow("") + val lastfmUsername: StateFlow = _lastfmUsername + + private val _lastfmLoggedIn = MutableStateFlow(false) + val lastfmLoggedIn: StateFlow = _lastfmLoggedIn + + private val _lastfmScrobbleEnabled = MutableStateFlow(false) + val lastfmScrobbleEnabled: StateFlow = _lastfmScrobbleEnabled + private val _keepServiceAlive = MutableStateFlow(false) val keepServiceAlive: StateFlow = _keepServiceAlive @@ -276,6 +293,8 @@ class SettingsViewModel( getExplicitContentEnabled() getDiscordLoggedIn() getDiscordRichPresenceEnabled() + getLastfmSession() + getLastfmScrobbleEnabled() getKeepServiceAlive() getKeepYouTubePlaylistOffline() getCombineLocalAndYouTubeLiked() @@ -464,6 +483,39 @@ class SettingsViewModel( } } + private fun getLastfmSession() { + viewModelScope.launch { + dataStoreManager.lastfmSessionKey.collect { key -> + _lastfmLoggedIn.value = key.isNotEmpty() + } + } + viewModelScope.launch { + dataStoreManager.lastfmUsername.collect { username -> + _lastfmUsername.value = username + } + } + } + + fun logOutLastfm() { + viewModelScope.launch { + dataStoreManager.setLastfmSession(sessionKey = "", username = "") + } + } + + private fun getLastfmScrobbleEnabled() { + viewModelScope.launch { + dataStoreManager.lastfmScrobbleEnabled.collect { enabled -> + _lastfmScrobbleEnabled.value = enabled == DataStoreManager.TRUE + } + } + } + + fun setLastfmScrobbleEnabled(enabled: Boolean) { + viewModelScope.launch { + dataStoreManager.setLastfmScrobbleEnabled(enabled) + } + } + fun logOutDiscord() { viewModelScope.launch { dataStoreManager.setDiscordToken("") @@ -758,8 +810,9 @@ class SettingsViewModel( * Asks before signing out of a linked account. * * Every one of these rows sits inside a long settings list and does its work on a single tap, - * with no undo — and getting back in is not symmetric with getting out, since it means a full - * web login. The confirmation is cheap next to that. + * with no undo — and getting back in is not symmetric with getting out: Last.fm sends the user + * through a browser again, YouTube and Spotify through a full web login. The confirmation is + * cheap next to that. * * @param confirmLabel names the service on the confirming button, because the dialog is the * only thing on screen at that moment and "Log out" alone does not say out of what. diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt index 8a2bd819a..3a0dfe7cd 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -91,10 +91,14 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.getString +import org.simpmusic.lastfm.completeLogin import simpmusic.composeapp.generated.resources.Res import simpmusic.composeapp.generated.resources.added_to_queue import simpmusic.composeapp.generated.resources.added_to_youtube_liked import simpmusic.composeapp.generated.resources.error +import simpmusic.composeapp.generated.resources.lastfm_login_failed +import simpmusic.composeapp.generated.resources.login_success import simpmusic.composeapp.generated.resources.play_next import simpmusic.composeapp.generated.resources.removed_from_youtube_liked import simpmusic.composeapp.generated.resources.shared @@ -459,6 +463,32 @@ class SharedViewModel( _intent.value = intent } + /** + * Finishes a Last.fm login from the `wordbyword://lastfm-auth` callback. + * + * It lands here, and not on the login screen, because the callback arrives at the app rather + * than at any one screen: the user left for their browser, and the screen they left from may + * not even exist any more if the process was killed. Routing the token onwards through + * navigation would push a second copy of the login screen on top of the one already open. + * + * The screen learns it succeeded by watching the stored session key, not by being told. + */ + fun completeLastfmLogin(token: String) { + if (token.isEmpty()) return + viewModelScope.launch { + val session = completeLogin(token) + if (session != null) { + dataStoreManager.setLastfmSession( + sessionKey = session.sessionKey, + username = session.username, + ) + makeToast(getString(Res.string.login_success)) + } else { + makeToast(getString(Res.string.lastfm_login_failed)) + } + } + } + fun showNotificationPermissionDialog() { _showNotificationPermissionDialog.value = true } From 2c15e65c466cdfb853c25f9a1e757a6660640a2d Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 30 Jul 2026 22:55:01 +0700 Subject: [PATCH 34/47] chore(core): bump submodule for artist parsing and Last.fm scrobbling --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 15b9ba2e0..8fb0ce277 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 15b9ba2e055aa5b3e274c2797ac3b4b3ed41b208 +Subproject commit 8fb0ce2776bc1ebc3d07f3c4ce378321a47cda39 From 9c6c6414a8a5e3fd8918666e2dfeef836e4cdf52 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 30 Jul 2026 22:55:01 +0700 Subject: [PATCH 35/47] docs: document the Last.fm integration and its auth pitfalls --- CLAUDE.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3f53dd169..6890f9de7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,6 +118,14 @@ Service modules: - **crashlytics-empty/**: FOSS version without tracking #### 5. **cast/** & **cast-empty/** +#### 6. **lastfm/** & **lastfm-empty/** +- **lastfm/**: direct Last.fm scrobbling for the Full build. KMP (android + jvm + ios), package `org.simpmusic.lastfm`. Signs `api_sig` with okio's MD5; talks to `ws.audioscrobbler.com/2.0/` over form-urlencoded POST +- **lastfm-empty/**: FOSS no-op stub with the identical public API — `isLastfmAvailable()` returns `false`, which hides the whole settings block. A FOSS build ships no API secret, so it ships no Last.fm code either +- Selected via `isFullBuild` in `core/data/build.gradle.kts` (playback hooks) and `composeApp/build.gradle.kts` (UI); credentials come from `LASTFM_API_KEY`/`LASTFM_SECRET` in `local.properties` via BuildKonfig, and are handed in with `configLastfm(key, secret)` at startup — the same shape as `configCrashlytics(context, dsn)` +- Auth is Last.fm's **web flow** on every platform: open `last.fm/api/auth/?api_key=X` with **no token**, the user approves in their own browser, Last.fm redirects to the callback with `?token=`, then `auth.getSession`. The app never sees a password. **Do not switch to the desktop flow** (`auth.getToken` first, then open the same URL with `&token=` on it): that tells Last.fm the app already holds the token, so it renders a "return to the application" page and the callback is never called — which looks exactly like a broken redirect +- The callback registered on the API account is `wordbyword://lastfm-auth`, handled by an intent-filter on Android and by Conveyor `url-schemes` + `WindowsProtocolRegistrar` on Desktop; the login screen also accepts the callback URL pasted by hand, for hosts where no scheme handler exists + +#### 7. **cast/** & **cast-empty/** - **cast/**: Google Cast support for the Full build (`media3-cast` + `play-services-cast-framework`, `CastOptionsProvider`, `CastIconButton` Compose wrapper for `MediaRouteButton`) - **cast-empty/**: FOSS no-op stub with identical public API (package `org.simpmusic.cast`), keeping GMS out of F-Droid builds - Selected via the `isFullBuild` Gradle property (same pattern as crashlytics) in `core/media/media3/build.gradle.kts` and `composeApp/build.gradle.kts` androidMain @@ -495,6 +503,15 @@ if (getPlatform() == Platform.Android) { - The container build targets glibc **2.34** → runs on Ubuntu 22.04 / Debian 11 and newer. Vulkan/shaderc/glslang/D3D11 are disabled in libplacebo and X11/Wayland/GPU in mpv, since playback goes through the software render API; that also drops `libshaderc`/`libglslang`/`libSPIRV-Tools` (the bulk of the old bundle) and removes libsixel entirely, which had been aborting the JVM. - `stage.sh` deliberately does **not** bundle `libc`/`libm`/`libstdc++`/`ld-linux`, sets `DT_RPATH` (not `DT_RUNPATH` — RUNPATH is not inherited by transitive dependencies), and fails the build unless a `dlopen` + `mpv_initialize` smoke test passes. - mpv built with `-Dlua=disabled` has no `ytdl_hook`, so the `ytdl` option genuinely does not exist there; `MpvPlayer` uses `optionalOption()` to treat `MPV_ERROR_OPTION_NOT_FOUND` as success. +- **Last.fm scrobbling (2026-07-30, Full build only)**: `lastfm`/`lastfm-empty` module pair gated by `isFullBuild`, following the `cast`/`crashlytics` shape. `LastfmScrobbler` (in `core/data/.../lastfm/`) lives in `commonMain` and is driven by both player handlers, because Android and Desktop run entirely separate ones. It sends `track.updateNowPlaying` where the Discord RPC is updated, and `track.scrobble` off the existing 5-second position-persist tick — a track over 30s scrobbles at half its length or 4 minutes, whichever comes first. + - **`status="ok"` does not mean accepted.** Last.fm answers OK while discarding a scrobble and only says so in `ignoredMessage`: code 1 = artist name filtered, 2 = track name filtered, 3/4 = timestamp too far past/future, 5 = daily limit. Codes 1 and 2 are how bad metadata surfaces, so they are logged loudly rather than dropped. + - **The two auth flows are not interchangeable, and picking the wrong one silently kills the callback.** Web flow: send the user to `last.fm/api/auth/?api_key=X` with no token; Last.fm mints it and redirects to the registered callback with `?token=`. Desktop flow: call `auth.getToken`, then open that URL with `&token=` already on it; Last.fm then shows "return to the application" and never redirects. SimpMusic uses the **web** flow because it has a registered callback and deep-link handlers on every platform. + - **The callback token does NOT travel through navigation.** `App.kt` hands it straight to `SharedViewModel.completeLastfmLogin()`, and `LastfmLoginScreen` closes itself by watching the stored session key. Navigating to the login screen with the token instead pushes a *second* copy on top of the one the user opened their browser from, so the `navigateUp()` after a successful login only peels off that copy and lands back on a login screen — it looks exactly like "logged in but still stuck on the login screen". The other three login screens never hit this because they embed a WebView and never leave the app; Desktop has no real WebView (`Cookies.jvm.kt` is a placeholder), which is why Last.fm uses the system browser at all. + - **`toSortedMap()` does not exist in common Kotlin** (it is a JDK collection) — sort the signature parameters with `entries.sortedBy { it.key }`. + - **`format` must be excluded from `api_sig`.** Parameters are sorted by name, concatenated ``, secret appended, MD5'd — but signing `format` (or `callback`) yields "Invalid method signature supplied" (code 13) on every request. + - Error codes worth branching on: `9` invalid session key → clear the stored session and make the user log in again; `11`/`16`/`29` → transient, retryable; everything else is a malformed request. + - Two places where Last.fm's own docs contradict themselves, resolved conservatively: `timestamp` is the time the track **started** (the method page says started, the scrobbling guide says finished — every scrobbler in the wild sends the start), and `duration` is **always sent** (optional on one page, required on the other). + - Responses are parsed as loose `JsonObject`s, not `@Serializable` classes: Last.fm's JSON is a translation of its XML, so numbers arrive as strings, attributes hide under `@attr`, and a field is an object with one entry but an array with several. - **JNA open flags are POSIX-only (2026-07-28)**: `MpvLibrary` passes `OPTION_OPEN_FLAGS = 2` (RTLD_NOW without RTLD_GLOBAL) **only when not on Windows**. JNA forwards the value verbatim to `LoadLibraryEx`, where `2` means `LOAD_LIBRARY_AS_DATAFILE`: the DLL maps as plain data, imports never resolve, and `GetProcAddress` returns nothing — surfacing as the misleading `Error looking up function 'mpv_client_api_version': The specified module could not be found`. ## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY) @@ -521,6 +538,6 @@ After completing any of the following types of changes, the AI agent **MUST** up *This document helps AI Agents quickly understand the SimpMusic project. Update regularly when there are major changes to architecture or structure.* -**Last updated**: 2026-07-28 +**Last updated**: 2026-07-30 **Project version**: Check latest release on GitHub **Maintained by**: maxrave-dev and contributors From 69aafc92801a6802c39634611f595f5e4303fd2a Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 30 Jul 2026 22:55:01 +0700 Subject: [PATCH 36/47] chore(gradle): refresh daemon JVM toolchain URLs --- gradle/gradle-daemon-jvm.properties | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index e6ea456e4..8918ccd51 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -1,13 +1,13 @@ #This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d1cdf34033d69f8d4f43c91ee68af29f/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/edffc3a65db006275a86e12a20758477/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d1cdf34033d69f8d4f43c91ee68af29f/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/edffc3a65db006275a86e12a20758477/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5ad03827a75ecec71a5e787edfc1bca4/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/3c9b911fffca526315550d45861cdc3f/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d1cdf34033d69f8d4f43c91ee68af29f/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/edffc3a65db006275a86e12a20758477/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5ea5df3bae5cc1f1132a244de5c3feda/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/fe06cac13d0fc1321b8e034d546e8a06/redirect +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3d1170c4a2befa97615d1af000be56d5/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ca83b5b0bec882d8b275143b559827cc/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3d1170c4a2befa97615d1af000be56d5/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ca83b5b0bec882d8b275143b559827cc/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c02bd9659ddf98ccc4a6e505ef8f115a/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/7a3a7d66b6f767017b5cd881cb98a25a/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3d1170c4a2befa97615d1af000be56d5/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ca83b5b0bec882d8b275143b559827cc/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/94348e25b52626019fb834c17f3e4f7f/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/b0969eea61055d4bb6628e83c68a2359/redirect toolchainVendor=MICROSOFT toolchainVersion=21 From 4fc9e096255934602085fddc7f3ac97f99a2c354 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 30 Jul 2026 23:00:10 +0700 Subject: [PATCH 37/47] fix(ui): take the bottom sheet title, artist and like label from the dark palette --- .../maxrave/simpmusic/ui/component/ModalBottomSheet.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/ModalBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/ModalBottomSheet.kt index 25383781a..3400c1faf 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/ModalBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/ModalBottomSheet.kt @@ -1685,6 +1685,10 @@ fun NowPlayingBottomSheet( Text( text = uiState.songUIState.title, style = typo().labelMedium, + // typo() bakes a colour into the style, computed from the app's own + // scheme — on this always-dark sheet that reads as washed out next + // to the ActionButton rows below, which take their colour from here. + color = rememberSurfaceDarkColors().content, maxLines = 1, modifier = Modifier @@ -1698,6 +1702,7 @@ fun NowPlayingBottomSheet( .toListName() .connectArtists(), style = typo().bodyMedium, + color = rememberSurfaceDarkColors().subtitle, maxLines = 1, modifier = Modifier @@ -1991,6 +1996,9 @@ fun CheckBoxActionButton( stringResource(Res.string.like) }, style = typo().labelSmall, + // Matches [ActionButton], which this sits directly above in every sheet that uses + // both — without it the label alone falls back to the colour typo() carries. + color = rememberSurfaceDarkColors().content, modifier = Modifier .padding(start = 10.dp) From 12acba2b56b744f77339d5e406bf27cef83f5e72 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Fri, 31 Jul 2026 10:22:44 +0700 Subject: [PATCH 38/47] feat(cicd): update workflow --- .github/workflows/android-release.yml | 16 ++++++++++++++++ .github/workflows/android.yml | 8 ++++++++ .github/workflows/desktop-package.yml | 8 ++++++++ 3 files changed, 32 insertions(+) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index b3590e74e..ca227aa1c 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -43,6 +43,14 @@ jobs: echo 'SENTRY_DSN=${{ secrets.SENTRY_DSN }}' > ./local.properties echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties + - name: Update Last.fm secrets + env: + LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} + LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} + run: | + echo "LASTFM_API_KEY=${{ secrets.LASTFM_API_KEY }}" >> ./local.properties + echo "LASTFM_SECRET=${{ secrets.LASTFM_SECRET }}" >> ./local.properties + - name: Update environment variables env: BASE_64_SIGNING_KEY: ${{ secrets.BASE_64_SIGNING_KEY }} @@ -148,6 +156,14 @@ jobs: echo 'SENTRY_DSN=${{ secrets.SENTRY_DSN_JVM }}' > ./local.properties echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties + - name: Update Last.fm secrets + env: + LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} + LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} + run: | + echo "LASTFM_API_KEY=${{ secrets.LASTFM_API_KEY }}" >> ./local.properties + echo "LASTFM_SECRET=${{ secrets.LASTFM_SECRET }}" >> ./local.properties + - name: Generate aboutLibraries.json run: ./gradlew exportLibraryDefinitions diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c6099dc5a..ca13b3789 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -49,6 +49,14 @@ jobs: echo 'SENTRY_DSN=${{ secrets.SENTRY_DSN }}' > ./local.properties echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties + - name: Update Last.fm secrets + env: + LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} + LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} + run: | + echo "LASTFM_API_KEY=${{ secrets.LASTFM_API_KEY }}" >> ./local.properties + echo "LASTFM_SECRET=${{ secrets.LASTFM_SECRET }}" >> ./local.properties + - name: Generate aboutLibraries.json run: ./gradlew exportLibraryDefinitions diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 283edabb8..a5aca7e67 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -73,6 +73,14 @@ jobs: echo 'SENTRY_DSN=${{ secrets.SENTRY_DSN_JVM }}' > ./local.properties echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties + - name: Update Last.fm secrets + env: + LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} + LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} + run: | + echo "LASTFM_API_KEY=${{ secrets.LASTFM_API_KEY }}" >> ./local.properties + echo "c=${{ secrets.LASTFM_SECRET }}" >> ./local.properties + - name: Generate aboutLibraries.json run: ./gradlew exportLibraryDefinitions From f7205d529828f2381ee1f2d38f936e7dd249d1f5 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Fri, 31 Jul 2026 10:26:08 +0700 Subject: [PATCH 39/47] feat(cicd): update workflow --- .github/workflows/desktop-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index a5aca7e67..b2dd02917 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -79,7 +79,7 @@ jobs: LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} run: | echo "LASTFM_API_KEY=${{ secrets.LASTFM_API_KEY }}" >> ./local.properties - echo "c=${{ secrets.LASTFM_SECRET }}" >> ./local.properties + echo "LASTFM_SECRET=${{ secrets.LASTFM_SECRET }}" >> ./local.properties - name: Generate aboutLibraries.json run: ./gradlew exportLibraryDefinitions From 2cae0ca08b77cf473fba020946f5b31c36405d66 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Fri, 31 Jul 2026 14:39:52 +0700 Subject: [PATCH 40/47] fix(desktop): register URL schemes correctly and restore link opening on Linux --- CLAUDE.md | 4 +- .../com/maxrave/simpmusic/DesktopApp.kt | 39 +++++++++-- .../maxrave/simpmusic/expect/OpenUrl.jvm.kt | 65 ++++++++++++++++++- conveyor.conf | 37 ++++++++--- desktopApp/build.gradle.kts | 8 ++- 5 files changed, 135 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6890f9de7..e03c749c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -513,6 +513,8 @@ if (getPlatform() == Platform.Android) { - Two places where Last.fm's own docs contradict themselves, resolved conservatively: `timestamp` is the time the track **started** (the method page says started, the scrobbling guide says finished — every scrobbler in the wild sends the start), and `duration` is **always sent** (optional on one page, required on the other). - Responses are parsed as loose `JsonObject`s, not `@Serializable` classes: Last.fm's JSON is a translation of its XML, so numbers arrive as strings, attributes hide under `@attr`, and a field is an object with one entry but an array with several. - **JNA open flags are POSIX-only (2026-07-28)**: `MpvLibrary` passes `OPTION_OPEN_FLAGS = 2` (RTLD_NOW without RTLD_GLOBAL) **only when not on Windows**. JNA forwards the value verbatim to `LoadLibraryEx`, where `2` means `LOAD_LIBRARY_AS_DATAFILE`: the DLL maps as plain data, imports never resolve, and `GetProcAddress` returns nothing — surfacing as the misleading `Error looking up function 'mpv_client_api_version': The specified module could not be found`. +- **Desktop URL schemes were never actually registered (2026-07-31)**: `url-schemes` belongs at the **top level** of `app` in `conveyor.conf`. Conveyor binds it on `AppConfig`, not on `MacConfig`/`WindowsConfig`/`LinuxConfig` — compare `MacConfigAccess.getUrlSchemes()` (reads `appConfig`) with the `getFileAssociations()` beside it (reads `mac`). It had been written as `mac.url-schemes` / `windows.url-schemes` / `linux.url-schemes` since May 2026; HOCON accepts unknown keys silently, so all three sat inert and **every packaged build shipped with no `CFBundleURLTypes` at all** — macOS never routed `simpmusic://` either, not just the Last.fm callback. Proven by diffing two `mac-app` builds that differed only in where the key was written. The same misplacement had parked `desktop-file.Categories` / `Comment[en]` / `StartupWMClass` *beside* the `"Desktop Entry"` group instead of inside it, so those never reached the generated `.desktop` either. Two more links in the same chain: the argv filter in `runDesktopApp` matched a fixed list (`simpmusic://`, `http://`, `https://`) and therefore discarded `wordbyword://lastfm-auth?token=…` on Windows and Linux — it now matches any `scheme://` — and the AppImage's own `.desktop` (written by `packageConveyorAppImage`, which is what actually reaches users since AppRun installs it into `~/.local/share/applications`) now declares `x-scheme-handler/wordbyword` alongside `simpmusic`. +- **Bundled glib disabled `java.awt.Desktop` on Linux (2026-07-31)**: `mpv-natives/linux-x64/lib/libglib-2.0.so.0` is glib 2.72 (built on Ubuntu 22.04) and is missing from `SYSTEM_LIBS` in `scripts/mpv-linux/stage.sh`, so it ships in the bundle and claims the glib soname the moment JNA loads libmpv at startup. AWT's `XDesktopPeer.init()` can then no longer dlopen the **system** `libgio-2.0.so.0`: on a glib 2.80 host (Ubuntu 24.04) it dies with `libgobject-2.0.so.0: undefined symbol: g_dir_unref`, and the JDK reports the whole Desktop API unsupported for the rest of the process. All 23 external-link call sites broke at once — `openUrl()` was an `if` with no `else` so it silently did nothing, while Compose's `LocalUriHandler` calls `Desktop.getDesktop()` on its first line and threw `UnsupportedOperationException` straight out of the click handler, crashing the app. Arrived with the from-source Linux mpv build (2026-07-28); before that JNA quietly fell back to a system-wide libmpv, so the system glib was the only one mapped and links worked. Worked around by calling `Desktop.isDesktopSupported()` at the top of `runDesktopApp` — `XDesktopPeer` caches that probe, so running it before libmpv loads lets the system gio/gobject win the soname race. `OpenUrl.jvm.kt` additionally gained a per-OS launcher fallback (`xdg-open` → `gio open` → `$BROWSER`) and a toast, so it can no longer fail in silence. **The actual cure is to stop bundling glib** — add it to `SYSTEM_LIBS`, which needs the Linux tarball rebuilt, republished and re-pinned in `mpvNativesChecksums`. ## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY) @@ -538,6 +540,6 @@ After completing any of the following types of changes, the AI agent **MUST** up *This document helps AI Agents quickly understand the SimpMusic project. Update regularly when there are major changes to architecture or structure.* -**Last updated**: 2026-07-30 +**Last updated**: 2026-07-31 **Project version**: Check latest release on GitHub **Maintained by**: maxrave-dev and contributors diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt index b1af0dc1d..cbb905449 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt @@ -66,11 +66,39 @@ import simpmusic.composeapp.generated.resources.open_miniplayer import simpmusic.composeapp.generated.resources.quit_app import simpmusic.composeapp.generated.resources.time_out_check_internet_connection_or_change_piped_instance_in_settings +/** + * Any `scheme://…` command-line argument. RFC 3986 §3.1 allows ALPHA followed by + * ALPHA / DIGIT / "+" / "-" / ".". + */ +private val DEEP_LINK_ARG = Regex("^[A-Za-z][A-Za-z0-9+.\\-]*://.+") + @OptIn(ExperimentalMaterial3Api::class) fun runDesktopApp(args: Array = emptyArray()) { // Install crash dialog handler first — catches all uncaught exceptions CrashDialog.install() + // Force AWT to probe the Desktop API now, BEFORE anything can load libmpv. + // + // On Linux the bundled libmpv drags in its own libglib-2.0.so.0 (2.72, built on + // Ubuntu 22.04) through the $ORIGIN/lib rpath. Once that soname is taken, AWT's + // XDesktopPeer.init() can no longer dlopen the SYSTEM libgio-2.0.so.0 — on a host + // with newer glib (2.80 on Ubuntu 24.04) it dies with + // "libgobject-2.0.so.0: undefined symbol: g_dir_unref" — and the JDK then reports + // the whole Desktop API as unsupported for the rest of the process. Every external + // link breaks: openUrl() silently does nothing, and Compose's LocalUriHandler + // throws UnsupportedOperationException out of the click handler and crashes the app. + // + // XDesktopPeer caches the result of that probe on the first call, so calling it here + // — while only the system glib is mapped — pins it to "supported" and lets the system + // gio/gobject win the soname race. AWT is already initialized at this point anyway + // (forceLinuxWmClass() in Main.kt touches the toolkit before we get here), so this + // costs nothing. + // + // This is a workaround, not the cure: the real fix is to stop bundling glib in + // scripts/mpv-linux/stage.sh, which needs the Linux mpv tarball rebuilt and + // republished. Keep this call until that lands. + java.awt.Desktop.isDesktopSupported() + System.setProperty("compose.swing.render.on.graphics", "true") System.setProperty("compose.interop.blending", "true") System.setProperty("compose.layers.type", "COMPONENT") @@ -90,10 +118,13 @@ fun runDesktopApp(args: Array = emptyArray()) { } // Handle URI passed as command-line argument (Windows/Linux, or explicit invocation) // Note: macOS does NOT pass URI as args — it uses Apple Events via setOpenURIHandler - val deepLinkArg = - args.firstOrNull()?.takeIf { arg -> - arg.startsWith("simpmusic://") || arg.startsWith("http://") || arg.startsWith("https://") - } + // + // Matched by shape rather than against a fixed list of schemes. The list used to be + // simpmusic:// + http:// + https://, which silently dropped the Last.fm callback + // (wordbyword://lastfm-auth?token=…): the OS launched us with the token, this filter + // discarded it, and the app merely came to the foreground while the login sat there + // waiting forever. Any scheme we register with the OS must survive this line. + val deepLinkArg = args.firstOrNull()?.takeIf { DEEP_LINK_ARG.matches(it) } // Single-instance guard — MUST run before startKoin. The DataStore Koin // singleton is `createdAtStart`, so a second Windows instance would touch // ~/.simpmusic/settings.preferences_pb and crash with an "Unable to rename diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/OpenUrl.jvm.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/OpenUrl.jvm.kt index d445919c0..106d09f32 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/OpenUrl.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/OpenUrl.jvm.kt @@ -1,5 +1,6 @@ package com.maxrave.simpmusic.expect +import com.maxrave.logger.Logger import multiplatform.network.cmptoast.showToast import java.awt.Desktop import java.awt.Toolkit @@ -7,9 +8,67 @@ import java.awt.datatransfer.Clipboard import java.awt.datatransfer.StringSelection import java.net.URI +private const val TAG = "OpenUrl" + +/** + * Opens [url] in the user's browser. Never throws, and never fails silently. + * + * `java.awt.Desktop` is tried first because it is the one path that honours the user's + * configured default handler on every OS — but it cannot be relied on. On Linux it hangs off a + * dlopen of the system libgio, which fails outright once another native library has claimed the + * libglib soname (see the Desktop API warm-up at the top of `runDesktopApp`). This used to be + * the ONLY path, wrapped in an `if` with no `else`, so the moment the Desktop API went + * unsupported every external link in the app quietly did nothing — no error, no log, no toast. + * + * Hence the per-OS launcher below it, and a toast when even that fails. + */ actual fun openUrl(url: String) { - if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { - Desktop.getDesktop().browse(URI(url)) + if (openWithDesktopApi(url)) return + if (openWithSystemLauncher(url)) return + + Logger.e(TAG, "Could not open $url by any means") + showToast("Could not open the link") +} + +private fun openWithDesktopApi(url: String): Boolean = + runCatching { + if (!Desktop.isDesktopSupported()) return@runCatching false + val desktop = Desktop.getDesktop() + if (!desktop.isSupported(Desktop.Action.BROWSE)) return@runCatching false + desktop.browse(URI(url)) + true + }.getOrElse { error -> + Logger.w(TAG, "Desktop.browse failed for $url: ${error.message}") + false + } + +/** + * Hands the URL to whatever the platform ships for the job. + * + * The Linux list is ordered by how likely each one is to be present: `xdg-open` is the + * convention but is not guaranteed to be installed, `gio` comes with glib so it is on every + * desktop that runs GTK at all, and `$BROWSER` is the last thing a minimal setup may still + * honour. A successful `start()` only proves the binary exists — waiting for an exit code would + * block the caller (these run on the AWT event thread), so we take that as good enough. + */ +private fun openWithSystemLauncher(url: String): Boolean { + val os = System.getProperty("os.name", "").lowercase() + val candidates = + when { + os.contains("mac") -> listOf(listOf("open", url)) + os.contains("windows") -> listOf(listOf("rundll32", "url.dll,FileProtocolHandler", url)) + else -> + listOfNotNull( + listOf("xdg-open", url), + listOf("gio", "open", url), + System.getenv("BROWSER")?.takeIf { it.isNotBlank() }?.let { listOf(it, url) }, + ) + } + + return candidates.any { command -> + runCatching { ProcessBuilder(command).start() } + .onFailure { error -> Logger.w(TAG, "${command.first()} failed for $url: ${error.message}") } + .isSuccess } } @@ -21,4 +80,4 @@ actual fun shareUrl( val clipboard: Clipboard = Toolkit.getDefaultToolkit().systemClipboard clipboard.setContents(stringSelection, null) showToast("Copied to clipboard") -} \ No newline at end of file +} diff --git a/conveyor.conf b/conveyor.conf index 3d060cb2e..12e9f04fa 100644 --- a/conveyor.conf +++ b/conveyor.conf @@ -67,6 +67,21 @@ app { consistency-checks = warn } + // Custom URI schemes to register the app for. "simpmusic" backs the deep links + // from simpmusic.org; "wordbyword" is the Last.fm auth callback, whose scheme is + // fixed by the callback URL registered on the Last.fm API account. + // + // This key MUST stay at the top level of `app`. Conveyor binds url-schemes on + // AppConfig, not on MacConfig/WindowsConfig/LinuxConfig — compare + // `MacConfigAccess.getUrlSchemes()` (reads appConfig) with the + // `getFileAssociations()` right next to it (reads mac). We previously set + // `mac.url-schemes` / `windows.url-schemes` / `linux.url-schemes`; HOCON accepts + // unknown keys silently, so all three sat inert and the packaged apps carried NO + // CFBundleURLTypes at all — verified by diffing two mac-app builds that differed + // only in where this key was written. Nothing ever registered, not even + // "simpmusic", from May 2026 until this was moved. + url-schemes = [ "simpmusic", "wordbyword" ] + // Explicit machines list. Two architectures explicitly dropped because // of missing native dependencies upstream: // @@ -120,7 +135,6 @@ app { mac { info-plist.LSApplicationCategoryType = "public.app-category.music" info-plist.UIBackgroundModes = [ "audio", "fetch", "processing" ] - url-schemes = [ "simpmusic", "wordbyword" ] // Turn off Apple's "nano" malloc zone — the macOS counterpart of the // MALLOC_ARENA_MAX cap the AppImage's AppRun sets for glibc. @@ -153,7 +167,6 @@ app { windows { start-menu.group = "SimpMusic" - url-schemes = [ "simpmusic", "wordbyword" ] // DEBUG ONLY — remove before shipping a release. // A Windows GUI binary has no console attached, so everything the app writes to @@ -163,13 +176,19 @@ app { } linux { - desktop-file."Comment[en]" = "A FOSS YouTube Music client" - desktop-file.Categories = "AudioVideo;Audio;Player;Music;" - // Must match the runtime WM_CLASS forced by forceLinuxWmClass() in - // desktopApp/.../Main.kt. Was "java-lang-Thread" (a stale guess that broke - // once AWT started initializing on a coroutine worker thread). - desktop-file.StartupWMClass = "SimpMusic" - url-schemes = [ "simpmusic", "wordbyword" ] + // Entries belong INSIDE the "Desktop Entry" group — that group is the .desktop + // file's real section, and it is where Conveyor puts Name/Exec/Icon/Comment. + // These three used to sit one level up (desktop-file.Categories, etc.), which + // put them beside the group rather than in it, so none of them reached the + // generated file — same silent-misplacement bug as url-schemes above. + desktop-file."Desktop Entry" { + "Comment[en]" = "A FOSS YouTube Music client" + Categories = "AudioVideo;Audio;Player;Music;" + // Must match the runtime WM_CLASS forced by forceLinuxWmClass() in + // desktopApp/.../Main.kt. Was "java-lang-Thread" (a stale guess that broke + // once AWT started initializing on a coroutine worker thread). + StartupWMClass = "SimpMusic" + } } // --- Build settings ------------------------------------------------------- diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 6184db70a..d5d52c63c 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -370,6 +370,12 @@ tasks.register("packageConveyorAppImage") { val versionName = libs.versions.version.name.get() val desktopFile = appDir.resolve("simpmusic.desktop") + // This file, not Conveyor's, is what actually reaches the user: AppRun installs it into + // ~/.local/share/applications and runs update-desktop-database. So every scheme listed in + // `app.url-schemes` in conveyor.conf has to be repeated here as an x-scheme-handler MIME + // type, or xdg-open finds no handler for it and the redirect dies in the browser. + // "wordbyword" is the Last.fm auth callback and was missing, which is why Last.fm login + // could never come back to the app on Linux. desktopFile.writeText( """[Desktop Entry] |Type=Application @@ -381,7 +387,7 @@ tasks.register("packageConveyorAppImage") { |Terminal=false |Categories=Audio;AudioVideo; |StartupWMClass=SimpMusic - |MimeType=x-scheme-handler/simpmusic; + |MimeType=x-scheme-handler/simpmusic;x-scheme-handler/wordbyword; | """.trimMargin(), ) From 569933374a3ccdd7236fa2728796c46894fc2cd5 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 1 Aug 2026 13:09:16 +0700 Subject: [PATCH 41/47] chore(core): bump submodule for queue restore, video crossfade skip and Compose video render --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 8fb0ce277..f9f7f9316 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 8fb0ce2776bc1ebc3d07f3c4ce378321a47cda39 +Subproject commit f9f7f931649ffa57fa6f5237383c4a40c0f4a525 From 5412bd1985965d856d221616ae8fcb20175e33d1 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 1 Aug 2026 13:09:17 +0700 Subject: [PATCH 42/47] docs: update CLAUDE.md for Compose video render and video crossfade rules --- CLAUDE.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e03c749c8..5471d616f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -349,7 +349,6 @@ Before implementing code, researching code, or answering technical questions, th - Custom title bar (disabled in VM environments) - **Limitations**: - No offline playback - - No video playback ### External APIs - YouTube Music: Hidden/unofficial API (may change anytime) @@ -366,14 +365,14 @@ Before implementing code, researching code, or answering technical questions, th - `MpvLibrary.kt` — JNA binding for libmpv's C client API, hand-mapped against client API 2.x. Struct layouts are read by raw offset, so a MAJOR client-API bump needs them re-verified - `MpvPlayer.kt` — one handle per media item; `vo=libmpv` + software render context -- `MpvVideoSurfacePanel.kt` — mpv SW render API → `BufferedImage` → Swing, embedded in Compose via `SwingPanel` +- `MpvVideoFrameSource.kt` — mpv SW render API → immutable `BufferedImage` snapshots published via `StateFlow`, drawn by plain Compose `Image` (`MpvVideoFrames` in `media-jvm-ui`); replaced the `SwingPanel`-embedded `MpvVideoSurfacePanel` on 2026-08-01 - `MpvPlayerAdapter.kt` — the `MediaPlayerInterface` implementation; separate YouTube audio/video URLs are merged into ONE source with an `edl://...;!new_stream;...` URL (mpv's equivalent of Android's `MergingMediaSource`) - Natives bundled per platform in `mpv-natives/-/`, staged by `mpvSetupAll` (Linux slice is compiled from source — `scripts/mpv-linux/`) - Supports crossfade transition with dual-player approach #### Crossfade Transition (Desktop) - Configurable duration: 1-15 seconds (default: 5 seconds) -- Audio-only: Crossfade is skipped for video playback +- Skipped when the NEXT track will play as video (`isVideo()` + watch-video setting on) — same rule as Android since 2026-08-01 - Settings persisted via DataStore ### Android Player (Media3/ExoPlayer) @@ -516,6 +515,9 @@ if (getPlatform() == Platform.Android) { - **Desktop URL schemes were never actually registered (2026-07-31)**: `url-schemes` belongs at the **top level** of `app` in `conveyor.conf`. Conveyor binds it on `AppConfig`, not on `MacConfig`/`WindowsConfig`/`LinuxConfig` — compare `MacConfigAccess.getUrlSchemes()` (reads `appConfig`) with the `getFileAssociations()` beside it (reads `mac`). It had been written as `mac.url-schemes` / `windows.url-schemes` / `linux.url-schemes` since May 2026; HOCON accepts unknown keys silently, so all three sat inert and **every packaged build shipped with no `CFBundleURLTypes` at all** — macOS never routed `simpmusic://` either, not just the Last.fm callback. Proven by diffing two `mac-app` builds that differed only in where the key was written. The same misplacement had parked `desktop-file.Categories` / `Comment[en]` / `StartupWMClass` *beside* the `"Desktop Entry"` group instead of inside it, so those never reached the generated `.desktop` either. Two more links in the same chain: the argv filter in `runDesktopApp` matched a fixed list (`simpmusic://`, `http://`, `https://`) and therefore discarded `wordbyword://lastfm-auth?token=…` on Windows and Linux — it now matches any `scheme://` — and the AppImage's own `.desktop` (written by `packageConveyorAppImage`, which is what actually reaches users since AppRun installs it into `~/.local/share/applications`) now declares `x-scheme-handler/wordbyword` alongside `simpmusic`. - **Bundled glib disabled `java.awt.Desktop` on Linux (2026-07-31)**: `mpv-natives/linux-x64/lib/libglib-2.0.so.0` is glib 2.72 (built on Ubuntu 22.04) and is missing from `SYSTEM_LIBS` in `scripts/mpv-linux/stage.sh`, so it ships in the bundle and claims the glib soname the moment JNA loads libmpv at startup. AWT's `XDesktopPeer.init()` can then no longer dlopen the **system** `libgio-2.0.so.0`: on a glib 2.80 host (Ubuntu 24.04) it dies with `libgobject-2.0.so.0: undefined symbol: g_dir_unref`, and the JDK reports the whole Desktop API unsupported for the rest of the process. All 23 external-link call sites broke at once — `openUrl()` was an `if` with no `else` so it silently did nothing, while Compose's `LocalUriHandler` calls `Desktop.getDesktop()` on its first line and threw `UnsupportedOperationException` straight out of the click handler, crashing the app. Arrived with the from-source Linux mpv build (2026-07-28); before that JNA quietly fell back to a system-wide libmpv, so the system glib was the only one mapped and links worked. Worked around by calling `Desktop.isDesktopSupported()` at the top of `runDesktopApp` — `XDesktopPeer` caches that probe, so running it before libmpv loads lets the system gio/gobject win the soname race. `OpenUrl.jvm.kt` additionally gained a per-OS launcher fallback (`xdg-open` → `gio open` → `$BROWSER`) and a toast, so it can no longer fail in silence. **The actual cure is to stop bundling glib** — add it to `SYSTEM_LIBS`, which needs the Linux tarball rebuilt, republished and re-pinned in `mpvNativesChecksums`. +- **Crossfade skips video tracks (2026-08-01)**: both `CrossfadeExoPlayerAdapter` (Android) and `MpvPlayerAdapter` (Desktop) skip the crossfade path when the NEXT track will play as video (`isVideo()` + watch-video setting on — the same condition that builds a merged audio+video source). The merged two-URL source is error-prone to prepare mid-fade and used to cut the outgoing song short or jump straight to the video at 0:00; such transitions now take the normal (non-crossfade) path. The Android check for the CURRENT track being video stays removed (commit `9da155d7`). +- **Desktop video renders through Compose, SwingPanel removed (2026-08-01)**: `MpvVideoSurfacePanel` (JPanel + `SwingPanel` embedding) became `MpvVideoFrameSource` — the mpv SW render loop is unchanged, but finished frames are published as immutable `BufferedImage` snapshots on a `StateFlow` and drawn by a plain Compose `Image` (`MpvVideoFrames` in `media-jvm-ui`, converted with `toComposeImageBitmap()` off the UI thread; the UI reports its size via `setTargetSize()`). This kills the whole SwingPanel bug class: always-on-top z-order, one-frame-late repositioning while scrolling (the flicker that exposed the transparent window), and AWT's single-parent rule that made NowPlaying/Fullscreen/Artist screens fight over the one panel (video "randomly missing until next/prev"). `MpvPlayerAdapter.currentVideoSurface: StateFlow` is now `currentVideoFrames: StateFlow` and is set unconditionally during crossfade — the old null-guard kept a dead panel from a released player on screen (the "black video" bug). + ## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY) After completing any of the following types of changes, the AI agent **MUST** update this CLAUDE.md file: @@ -540,6 +542,6 @@ After completing any of the following types of changes, the AI agent **MUST** up *This document helps AI Agents quickly understand the SimpMusic project. Update regularly when there are major changes to architecture or structure.* -**Last updated**: 2026-07-31 +**Last updated**: 2026-08-01 **Project version**: Check latest release on GitHub **Maintained by**: maxrave-dev and contributors From 22c7eab602e557f4dda0e7b3dd4bab509b1cb38d Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 1 Aug 2026 17:16:59 +0700 Subject: [PATCH 43/47] chore(core): bump submodule for playlist import repository --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index f9f7f9316..11d12b1f9 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit f9f7f931649ffa57fa6f5237383c4a40c0f4a525 +Subproject commit 11d12b1f9643384a04ab0f817277109fdb70310b From a4a836aa159cb03343c163f6c46bced58811165f Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 1 Aug 2026 17:17:06 +0700 Subject: [PATCH 44/47] feat(import): import playlists from a converted file in settings --- .../composeResources/values/strings.xml | 8 ++ .../maxrave/simpmusic/di/ViewModelModule.kt | 6 + .../simpmusic/ui/screen/home/SettingScreen.kt | 136 ++++++++++++++++++ .../simpmusic/viewModel/ImportViewModel.kt | 66 +++++++++ docs/import-format-v1.md | 130 +++++++++++++++++ 5 files changed, 346 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/ImportViewModel.kt create mode 100644 docs/import-format-v1.md diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 0fcfcd765..75c6d8cab 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -584,4 +584,12 @@ Listen to downloaded music Playing on %1$s Not available while casting + Import playlists + Import playlists converted from Spotify or another app at simpmusic.org + Reading file… + %1$d of %2$d songs + Created %1$d playlists and imported %2$d songs. + %1$d entries were skipped because their song was missing from the file. + Import failed + This file is not a valid SimpMusic import file. Convert your library at simpmusic.org first. \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/di/ViewModelModule.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/di/ViewModelModule.kt index 1386c4719..803bddeb3 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/di/ViewModelModule.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/di/ViewModelModule.kt @@ -4,6 +4,7 @@ import com.maxrave.simpmusic.viewModel.AlbumViewModel import com.maxrave.simpmusic.viewModel.AnalyticsViewModel import com.maxrave.simpmusic.viewModel.ArtistViewModel import com.maxrave.simpmusic.viewModel.HomeViewModel +import com.maxrave.simpmusic.viewModel.ImportViewModel import com.maxrave.simpmusic.viewModel.LibraryDynamicPlaylistViewModel import com.maxrave.simpmusic.viewModel.LibraryViewModel import com.maxrave.simpmusic.viewModel.LocalPlaylistViewModel @@ -67,6 +68,11 @@ val viewModelModule = get(), ) } + viewModel { + ImportViewModel( + get(), + ) + } viewModel { AlbumViewModel( get(), diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt index 00c6a64af..1c8665b24 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton @@ -83,6 +84,7 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController @@ -101,6 +103,7 @@ import com.maxrave.common.VIDEO_QUALITY import com.maxrave.domain.extension.now import com.maxrave.domain.manager.DataStoreManager import com.maxrave.domain.manager.DataStoreManager.Values.TRUE +import com.maxrave.domain.repository.ImportProgress import com.maxrave.domain.utils.LocalResource import com.maxrave.logger.Logger import com.maxrave.simpmusic.Platform @@ -126,6 +129,7 @@ import com.maxrave.simpmusic.ui.theme.md_theme_dark_primary import com.maxrave.simpmusic.ui.theme.parseThemeColorHex import com.maxrave.simpmusic.ui.theme.typo import com.maxrave.simpmusic.utils.VersionManager +import com.maxrave.simpmusic.viewModel.ImportViewModel import com.maxrave.simpmusic.viewModel.SettingAlertState import com.maxrave.simpmusic.viewModel.SettingBasicAlertState import com.maxrave.simpmusic.viewModel.SettingsViewModel @@ -233,6 +237,13 @@ import simpmusic.composeapp.generated.resources.guest import simpmusic.composeapp.generated.resources.help_build_lyrics_database import simpmusic.composeapp.generated.resources.help_build_lyrics_database_description import simpmusic.composeapp.generated.resources.http +import simpmusic.composeapp.generated.resources.import_data +import simpmusic.composeapp.generated.resources.import_failed +import simpmusic.composeapp.generated.resources.import_playlists_from_other_apps +import simpmusic.composeapp.generated.resources.import_progress_songs +import simpmusic.composeapp.generated.resources.import_reading_file +import simpmusic.composeapp.generated.resources.import_result +import simpmusic.composeapp.generated.resources.import_result_skipped import simpmusic.composeapp.generated.resources.enable_scrobbling import simpmusic.composeapp.generated.resources.intro_login_to_discord import simpmusic.composeapp.generated.resources.intro_login_to_lastfm @@ -278,6 +289,7 @@ import simpmusic.composeapp.generated.resources.never import simpmusic.composeapp.generated.resources.no_account import simpmusic.composeapp.generated.resources.normalize_volume import simpmusic.composeapp.generated.resources.not_available_while_casting +import simpmusic.composeapp.generated.resources.ok import simpmusic.composeapp.generated.resources.open_system_equalizer import simpmusic.composeapp.generated.resources.openai import simpmusic.composeapp.generated.resources.openai_api_compatible @@ -414,6 +426,23 @@ fun SettingScreen( } } + // Import playlists converted on the web. Unlike restore, the file is read through Calf's + // KmpFile rather than a Uri, so no expect/actual is needed. The type stays All because a + // converted .json arrives with whatever MIME its source assigned it, and an application/json + // filter would hide it on some hosts. + val importViewModel: ImportViewModel = koinViewModel() + val importState by importViewModel.importState.collectAsStateWithLifecycle() + val importLauncher = + rememberFilePickerLauncher( + type = + FilePickerFileType.All, + selectionMode = FilePickerSelectionMode.Single, + ) { file -> + file.firstOrNull()?.let { + importViewModel.import(it, pl) + } + } + // Open equalizer val resultLauncher = openEqResult(viewModel.getAudioSessionId()) @@ -2200,6 +2229,15 @@ fun SettingScreen( } }, ) + SettingItem( + title = stringResource(Res.string.import_data), + subtitle = stringResource(Res.string.import_playlists_from_other_apps), + onClick = { + coroutineScope.launch { + importLauncher.launch() + } + }, + ) } } item(key = "about_us") { @@ -2305,6 +2343,12 @@ fun SettingScreen( EndOfPage() } } + importState?.let { progress -> + ImportProgressDialog( + progress = progress, + onDismiss = importViewModel::dismiss, + ) + } val basisAlertData by viewModel.basicAlertData.collectAsStateWithLifecycle() if (basisAlertData != null) { val alertBasicState = basisAlertData ?: return @@ -2894,4 +2938,96 @@ fun SettingScreen( containerColor = Color.Transparent, ), ) +} + +/** + * Progress and outcome of a playlist import. + * + * Only dismissible once the import has finished — cancelling mid-write would leave the database + * half-populated with no way to tell the user which half. + */ +@Composable +private fun ImportProgressDialog( + progress: ImportProgress, + onDismiss: () -> Unit, +) { + val finished = progress is ImportProgress.Success || progress is ImportProgress.Error + AlertDialog( + onDismissRequest = { if (finished) onDismiss() }, + properties = + DialogProperties( + dismissOnBackPress = finished, + dismissOnClickOutside = finished, + ), + title = { + Text( + text = + stringResource( + if (progress is ImportProgress.Error) Res.string.import_failed else Res.string.import_data, + ), + style = typo().titleSmall, + ) + }, + text = { + Column { + when (progress) { + is ImportProgress.Preparing -> { + Text( + text = stringResource(Res.string.import_reading_file), + style = typo().bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + is ImportProgress.Importing -> { + Text( + text = stringResource(Res.string.import_progress_songs, progress.processed, progress.total), + style = typo().bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + LinearProgressIndicator( + progress = { + if (progress.total > 0) progress.processed.toFloat() / progress.total else 0f + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + is ImportProgress.Success -> { + Text( + text = + stringResource( + Res.string.import_result, + progress.result.playlistsCreated, + progress.result.songsImported, + ), + style = typo().bodyMedium, + ) + if (progress.result.skippedEntries > 0) { + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(Res.string.import_result_skipped, progress.result.skippedEntries), + style = typo().bodySmall, + ) + } + } + + is ImportProgress.Error -> { + Text( + text = progress.message, + style = typo().bodyMedium, + ) + } + } + } + }, + confirmButton = { + if (finished) { + TextButton(onClick = onDismiss) { + Text(text = stringResource(Res.string.ok)) + } + } + }, + ) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/ImportViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/ImportViewModel.kt new file mode 100644 index 000000000..4d32981fd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/ImportViewModel.kt @@ -0,0 +1,66 @@ +package com.maxrave.simpmusic.viewModel + +import androidx.lifecycle.viewModelScope +import com.maxrave.domain.repository.ImportProgress +import com.maxrave.domain.repository.ImportRepository +import com.maxrave.simpmusic.viewModel.base.BaseViewModel +import com.mohamedrejeb.calf.core.PlatformContext +import com.mohamedrejeb.calf.io.KmpFile +import com.mohamedrejeb.calf.io.readByteArray +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import simpmusic.composeapp.generated.resources.Res +import simpmusic.composeapp.generated.resources.import_invalid_file + +/** + * Drives an import of a file produced by the SimpMusic web converter. + * + * The picked file is read here rather than in the repository because only the app module knows + * what a picked file is. [KmpFile] comes from the same Calf picker the backup/restore flow uses, + * and its `readByteArray` is already cross-platform, so no expect/actual is needed. + */ +class ImportViewModel( + private val importRepository: ImportRepository, +) : BaseViewModel() { + private val _importState: MutableStateFlow = MutableStateFlow(null) + + /** `null` while idle; otherwise the latest step of the running or finished import. */ + val importState: StateFlow = _importState.asStateFlow() + + private var importJob: Job? = null + + fun import( + file: KmpFile, + context: PlatformContext, + ) { + importJob?.cancel() + importJob = + viewModelScope.launch { + _importState.value = ImportProgress.Preparing + val invalidFileMessage = getString(Res.string.import_invalid_file) + val json = + withContext(Dispatchers.IO) { + runCatching { file.readByteArray(context).decodeToString() } + }.getOrElse { throwable -> + log("import: cannot read picked file - ${throwable.message}") + _importState.value = ImportProgress.Error(invalidFileMessage) + return@launch + } + importRepository.import(json, invalidFileMessage).collect { progress -> + _importState.value = progress + } + } + } + + /** Back to idle, which is what dismisses the progress/result dialog. */ + fun dismiss() { + importJob?.cancel() + importJob = null + _importState.value = null + } +} diff --git a/docs/import-format-v1.md b/docs/import-format-v1.md new file mode 100644 index 000000000..3f2883455 --- /dev/null +++ b/docs/import-format-v1.md @@ -0,0 +1,130 @@ +# SimpMusic import format v1 + +This is the contract between the **SimpMusic web converter** and the **SimpMusic app**. + +The web converter takes an export from another service — an Exportify CSV from Spotify, a backup +from another YouTube Music client, and so on — matches every track to a YouTube Music `videoId`, +and emits a single JSON file in the shape below. The app does **no matching of its own**: it parses +the file and writes rows. If a track has no `videoId` by the time the file is written, it must not +appear in the file at all. + +The file is produced by the converter at and consumed by +`ImportRepository` in the app (see `core/domain/.../repository/ImportRepository.kt`). + +## Envelope + +```json +{ + "songs": [ ... ], + "playlists": [ ... ] +} +``` + +There is deliberately **no** `version` field and **no** `source` field. Do not add envelope fields; +the app parses with `ignoreUnknownKeys = true`, so extra keys are silently dropped rather than +rejected, and a converter that relies on them will fail silently. + +Both keys are optional in the parser and default to an empty list, but a file where **both** lists +are empty is rejected by the app as "not a valid import file" — that is what makes picking the +wrong file produce an error instead of a silent "imported 0 songs". + +## `songs[]` + +`songs` is the deduplicated catalogue: **each `videoId` appears exactly once**. Playlists never +inline a song; they reference it by `videoId`. + +```json +{ + "videoId": "dQw4w9WgXcQ", + "title": "Never Gonna Give You Up", + "artistName": ["Rick Astley"], + "artistId": ["UCuAXFkgsw1L7xaCfnd5JJOw"], + "albumName": "Whenever You Need Somebody", + "albumId": "MPREb_xxx", + "duration": "3:33", + "durationSeconds": 213, + "isExplicit": false, + "thumbnails": "https://lh3.googleusercontent.com/...", + "videoType": "MUSIC_VIDEO_TYPE_ATV" +} +``` + +| Field | Type | Required | Meaning | +|---|---|---|---| +| `videoId` | string | **yes** | YouTube video id. Primary key of the `song` table. Must be unique across `songs`. | +| `title` | string | **yes** | Track title as it should be shown. | +| `artistName` | string[] | no | Display names, in order. `null`/absent when unknown. | +| `artistId` | string[] | no | YouTube channel ids, **positionally aligned with `artistName`**. See the length rule below. | +| `albumName` | string | no | Album title. Do not emit the literal string `"Album"` — older app builds used it as a placeholder and the app treats it as "no album". | +| `albumId` | string | no | YouTube browse id of the album (`MPREb_…`). | +| `duration` | string | no, defaults `""` | Human-readable duration, `m:ss` or `h:mm:ss`. Shown as-is in the UI. | +| `durationSeconds` | int | no, defaults `0` | Duration in seconds. Used for sorting and for scrobble thresholds. | +| `isExplicit` | bool | no, defaults `false` | Explicit marker. | +| `thumbnails` | string | no | A **single** URL, not a list. Prefer the largest square art available (`w544-h544` for songs). | +| `videoType` | string | no, defaults `""` | YouTube Music video type, e.g. `MUSIC_VIDEO_TYPE_ATV` (audio track) or `MUSIC_VIDEO_TYPE_OMV` (official video). Drives whether the app can play it as video. | + +### The `artistId` length rule (important) + +`artistId` must either be **absent/`null`**, or have **exactly the same length as `artistName`**. + +The app pairs the two lists by index when it converts a stored song back into a track. A non-null +`artistId` that is shorter than `artistName` would read past its end. The app defends itself — it +drops `artistId` entirely whenever the sizes disagree — but that silently loses every channel id +for that track, so the converter should never emit a partial list. If only some artists resolved to +a channel id, emit `artistId: null` and keep the names. + +## `playlists[]` + +```json +{ + "title": "Chill mix", + "thumbnail": "https://...", + "videoIds": ["dQw4w9WgXcQ", "abc123xyz00"] +} +``` + +| Field | Type | Required | Meaning | +|---|---|---|---| +| `title` | string | **yes** | Playlist name. Created as a **local playlist**, not synced to YouTube. | +| `thumbnail` | string | no | Cover art URL. | +| `videoIds` | string[] | no, defaults `[]` | Track order. Each entry should appear in `songs`. | + +Order matters: the app stores the position of each track from the order of this array. + +A `videoId` in `videoIds` that has no matching entry in `songs` is **skipped**, and the app reports +how many were skipped at the end of the import. The remaining tracks are then re-numbered from 0 so +positions stay contiguous. Emitting dangling ids is therefore not fatal, but it is a converter bug. + +## Caps + +The web converter enforces these limits, and the app relies on them by parsing the whole file into +memory in one pass (no streaming decoder): + +- **10,000** entries in `songs` +- **500** entries in `playlists` + +A file beyond these caps is the converter's problem to reject, not the app's. + +## What the app fills in itself + +These are **not** in the file, and a converter must not try to supply them: + +- `likeStatus` — set to `""`; imported songs are not liked. +- `liked` — `false`. +- `isAvailable` — `true`. +- `category`, `resultType` — `null`; these only mean something for search results. +- `totalPlayTime` — `0`. +- `downloadState` — not downloaded. +- `inLibrary`, `favoriteAt`, `downloadedAt` — set to the moment of import. +- `canvasUrl`, `canvasThumbUrl` — left empty; fetched later from Spotify at playback time. +- Playlist `id` — assigned by the database. +- Playlist `youtubePlaylistId` / sync state — imported playlists are local-only and unsynced. +- Track positions inside a playlist — derived from the order of `videoIds`. + +## Write behaviour + +- A song whose `videoId` is already in the database is **not overwritten**; the existing row keeps + its play count, liked state and download state. The import only fills in an album name or artist + list that the stored row is missing or that an older parse got wrong. +- Playlists are always created fresh. Importing the same file twice creates a second copy of every + playlist; it does not merge into the first. From 818af1a0f62d6e76834ddf23f86d984a0beea7ef Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 1 Aug 2026 17:17:10 +0700 Subject: [PATCH 45/47] fix(desktop): stop double-wrapping the backup zip output stream --- .../maxrave/simpmusic/viewModel/SettingsViewModel.jvm.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.jvm.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.jvm.kt index 442f7c4bc..951b1cae5 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.jvm.kt @@ -23,7 +23,6 @@ import java.io.FileOutputStream import java.util.Locale import java.util.zip.ZipEntry import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream import kotlin.system.exitProcess actual suspend fun calculateDataFraction(cacheRepository: CacheRepository): SettingsStorageSectionFraction? = null @@ -79,9 +78,7 @@ actual suspend fun backupNative( uri: Uri, backupDownloaded: Boolean, ) { - ZipOutputStream( - FileOutputStream(File(uri.toString())) - ).use { + FileOutputStream(File(uri.toString())).use { it.buffered().zipOutputStream().use { outputStream -> File(getHomeFolderPath(listOf(".simpmusic")), "$SETTINGS_FILENAME.preferences_pb") .inputStream() @@ -98,7 +95,6 @@ actual suspend fun backupNative( inputStream.copyTo(outputStream) } } - } } From a7fb742c505784b1e96f179d9ba8567735b36b9f Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 1 Aug 2026 17:17:16 +0700 Subject: [PATCH 46/47] fix(desktop): disable skiko vsync and restore window on dock reopen --- .../com/maxrave/simpmusic/DesktopApp.kt | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt index cbb905449..30d70c445 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt @@ -103,18 +103,40 @@ fun runDesktopApp(args: Array = emptyArray()) { System.setProperty("compose.interop.blending", "true") System.setProperty("compose.layers.type", "COMPONENT") + // Skiko's vsync wait can park the EDT forever after a display change: + // macOS waits on a CVDisplayLink-signalled NSConditionLock with no timeout + // (DisplayLinkThrottler.mm), Linux blocks inside the vsync'd glXSwapBuffers + // when X11 loses its sync source (CMP-9725 / CMP-10019). Both surface as + // "UI frozen, audio keeps playing" when the window moves to another + // monitor. With vsync off, skiko paces frames with its refresh-rate frame + // limiter (skiko.vsync.framelimit.fallback, on by default) instead. + // Windows renders via DirectX and shows no such freeze — leave it alone. + if (!System.getProperty("os.name", "").contains("Windows", ignoreCase = true)) { + System.setProperty("skiko.vsync.enabled", "false") + } + // Handle deep link URIs // macOS: receives URI via Desktop open URI handler (app already running or launched via scheme) // Windows/Linux: receives URI as command-line argument val isMacOS = System.getProperty("os.name", "").contains("Mac", ignoreCase = true) if (isMacOS && java.awt.Desktop.isDesktopSupported()) { + val desktop = java.awt.Desktop.getDesktop() try { - java.awt.Desktop.getDesktop().setOpenURIHandler { event -> + desktop.setOpenURIHandler { event -> DesktopDeepLinkHandler.onNewUri(event.uri.toString()) } } catch (_: UnsupportedOperationException) { // Shouldn't happen on macOS, but handle gracefully } + // Clicking the Dock icon of a running app arrives as a "reopen" Apple + // Event — macOS never spawns a second instance, so the + // SingleInstanceManager restore path can't fire for it. Route it + // through the same restore signal the tray and second instances use. + if (desktop.isSupported(java.awt.Desktop.Action.APP_EVENT_REOPENED)) { + desktop.addAppEventListener( + java.awt.desktop.AppReopenedListener { DesktopRestoreSignal.request() }, + ) + } } // Handle URI passed as command-line argument (Windows/Linux, or explicit invocation) // Note: macOS does NOT pass URI as args — it uses Apple Events via setOpenURIHandler @@ -243,8 +265,7 @@ fun runDesktopApp(args: Array = emptyArray()) { else -> appName }, primaryAction = { - isVisible = true - windowState.isMinimized = false + DesktopRestoreSignal.request() }, ) { // Disabled entries: while the window is hidden the tray is the only place showing what @@ -258,8 +279,7 @@ fun runDesktopApp(args: Array = emptyArray()) { } if (!isVisible) { Item(openAppString) { - isVisible = true - windowState.isMinimized = false + DesktopRestoreSignal.request() } } if (MiniPlayerManager.isOpen) { @@ -344,6 +364,14 @@ fun runDesktopApp(args: Array = emptyArray()) { state = windowState, visible = isVisible, ) { + // Restore requests (Dock reopen, tray, second instance) also need a + // z-order raise; visibility/minimized are reset by the + // application-level collector, but toFront needs the AWT window. + LaunchedEffect(Unit) { + DesktopRestoreSignal.requests.collect { + window.toFront() + } + } Column( modifier = Modifier From f303cdb97fb841226f50a9f8f1953b48bda5e007 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 1 Aug 2026 21:49:38 +0700 Subject: [PATCH 47/47] fix(desktop): switch macOS audio output to avfoundation --- CLAUDE.md | 6 ++++++ core | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5471d616f..c0f038dc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -517,6 +517,12 @@ if (getPlatform() == Platform.Android) { - **Crossfade skips video tracks (2026-08-01)**: both `CrossfadeExoPlayerAdapter` (Android) and `MpvPlayerAdapter` (Desktop) skip the crossfade path when the NEXT track will play as video (`isVideo()` + watch-video setting on — the same condition that builds a merged audio+video source). The merged two-URL source is error-prone to prepare mid-fade and used to cut the outgoing song short or jump straight to the video at 0:00; such transitions now take the normal (non-crossfade) path. The Android check for the CURRENT track being video stays removed (commit `9da155d7`). - **Desktop video renders through Compose, SwingPanel removed (2026-08-01)**: `MpvVideoSurfacePanel` (JPanel + `SwingPanel` embedding) became `MpvVideoFrameSource` — the mpv SW render loop is unchanged, but finished frames are published as immutable `BufferedImage` snapshots on a `StateFlow` and drawn by a plain Compose `Image` (`MpvVideoFrames` in `media-jvm-ui`, converted with `toComposeImageBitmap()` off the UI thread; the UI reports its size via `setTargetSize()`). This kills the whole SwingPanel bug class: always-on-top z-order, one-frame-late repositioning while scrolling (the flicker that exposed the transparent window), and AWT's single-parent rule that made NowPlaying/Fullscreen/Artist screens fight over the one panel (video "randomly missing until next/prev"). `MpvPlayerAdapter.currentVideoSurface: StateFlow` is now `currentVideoFrames: StateFlow` and is set unconditionally during crossfade — the old null-guard kept a dead panel from a released player on screen (the "black video" bug). +- **macOS desktop audio moved to `ao=avfoundation` (2026-08-01)**: `MpvPlayer` now pins `ao` to `"avfoundation,"` when `Platform.isMac()`, because **`ao_coreaudio` leaks a process-wide CoreAudio listener onto a freed `struct ao`** and takes the whole JVM down the next time an audio device appears or disappears — Sentry-visible as `EXC_BAD_ACCESS` on the `HALC_ProxyNotification Call Listener Queue`, reproduced by simply plugging in headphones. Windows (wasapi) and Linux (pulse/pipewire) are untouched. + - The chain, all upstream and **still present in mpv master as of 0.41.0**: `ao_coreaudio.c` `init()` registers `AudioObjectAddPropertyListener(kAudioObjectSystemObject, …, hotplug_cb, (void *)ao)` on the *system* object, but its failure label is bare (`coreaudio_error: return CONTROL_ERROR;`). An init that fails any later step (`ca_init_chmap`, `init_audiounit`) therefore leaves the listener registered. `ao.c` then does `goto fail` → `ao_uninit()`, and `buffer.c`'s `ao_uninit()` calls `driver->uninit()` **only when `driver_initialized` is set** — a flag `ao.c` sets only *after* a successful init. So `unregister_hotplug_cb()` never runs while `talloc_free(ao)` does, and the orphaned listener outlives the handle for the rest of the process. + - **Why SimpMusic hits this and plain mpv does not**: mpv initialises one ao per session; SimpMusic creates one handle per media item and runs two at once during a crossfade, so a single failed audio init anywhere in a session arms the crash. The crash then waits for an unrelated hotplug event, which is why the process can look healthy for an hour first. + - Diagnosing it: the faulting address decodes as ASCII (`0x65636e6174736e49` = "Instance"), the signature of a freed allocation already handed to another object. No thread was tearing an ao down at crash time, which is what ruled out a teardown race and pointed at a listener leaked much earlier. + - Accepted trade-offs, neither reproducible in testing on macOS 27: delayed mute (mpv#15014) and audio desync on playback-speed changes (mpv#14483). The trailing comma in `"avfoundation,"` keeps mpv's auto-probe as a fallback, so a failure degrades audio instead of silencing it. Remove the whole workaround once upstream frees the listener on the error path. + - Related blind spot, still open: nothing calls `mpv_request_log_messages()`, so libmpv's own warnings (including failed audio init) never surface anywhere. ## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY) diff --git a/core b/core index 11d12b1f9..09c43e176 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 11d12b1f9643384a04ab0f817277109fdb70310b +Subproject commit 09c43e176a69b33ab2708755097c524ba05cee5f