From 84abb53befe085c9e98c2347e9f8977e7ab78e0f Mon Sep 17 00:00:00 2001 From: Pavel Rojtberg Date: Mon, 19 Oct 2026 11:27:10 +0200 Subject: [PATCH 1/3] Draw the app edge to edge with explicit system bar insets The app targets SDK 36, so from API 35 on the platform lays the window out edge to edge: the theme's android:statusBarColor is ignored, the window is transparent and the status bar icons are drawn on top of whatever the app paints there. The drawer layout still carried android:fitsSystemWindows, so the window background (colorPrimaryDark) showed through behind the status bar while the action bar and the now playing bar stopped short of the screen edges, and the navigation bar area was left black. Drop fitsSystemWindows from the drawer layout and handle the insets per view in SubsonicActivity.applyEdgeToEdgeInsets(), which listens on the decor for systemBars()|displayCutout(): - both toolbars grow by the top inset and take the same inset as padding, so their background paints the status bar while the title and the icons stay below it; the content of the main activity follows with its top margin - the now playing bar keeps the navigation bar area coloured: the swipe target is padded at the bottom and the panel height is raised by the same amount, so the collapsed bar stays the same visual height - the now playing fragment container and the drawer are padded at the bottom, the drawer header is padded and grown by the top inset (its height is fixed, padding alone would squeeze its content) - screens without a now playing bar (the settings activity) pad their content frame at the bottom instead - nothing is applied in full screen mode, where the bars are hidden anyway Since the status bar icons now sit on the action bar colour, take the icon contrast in applyEdgeToEdge() from actionbarBackgroundColor (or the custom action bar colour) instead of colorPrimaryDark. --- .../dsub2000/activity/SubsonicActivity.java | 96 ++++++++++++++++++- app/src/main/res/layout/abstract_activity.xml | 3 +- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/github/paroj/dsub2000/activity/SubsonicActivity.java b/app/src/main/java/github/paroj/dsub2000/activity/SubsonicActivity.java index 2d37bbef3..4cf31e675 100644 --- a/app/src/main/java/github/paroj/dsub2000/activity/SubsonicActivity.java +++ b/app/src/main/java/github/paroj/dsub2000/activity/SubsonicActivity.java @@ -39,10 +39,14 @@ import com.google.android.material.navigation.NavigationView; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.appcompat.app.ActionBarDrawerToggle; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.drawerlayout.widget.DrawerLayout; +import com.sothree.slidinguppanel.SlidingUpPanelLayout; import androidx.appcompat.app.AlertDialog; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import androidx.appcompat.app.AppCompatActivity; @@ -530,6 +534,92 @@ public boolean onTouch(View v, MotionEvent event) { if(secondaryContainer != null) { primaryContainer = findViewById(R.id.fragment_container); } + + applyEdgeToEdgeInsets(); + } + + /** + * The window is drawn edge to edge, so the system bar insets have to be applied to the views which + * should not end up below the bars. The toolbars keep the status bar area painted with the action + * bar colour, while the now playing bar keeps the navigation bar area coloured. + */ + private void applyEdgeToEdgeInsets() { + if(fullScreen) { + // The bars are hidden in full screen mode, so there is nothing to keep clear of them. + return; + } + + final View mainToolbar = findViewById(R.id.main_toolbar); + final View nowPlayingToolbar = findViewById(R.id.now_playing_toolbar); + final View swipeTarget = findViewById(R.id.slide_up_swipe_target); + final View nowPlayingContainer = findViewById(R.id.now_playing_fragment_container); + final View fragmentContainer = findViewById(R.id.fragment_container); + final View drawer = findViewById(R.id.left_drawer); + final View panel = findViewById(R.id.slide_up_panel); + final View header = drawerHeader; + + final int panelHeight = panel instanceof SlidingUpPanelLayout ? ((SlidingUpPanelLayout) panel).getPanelHeight() : 0; + final int mainToolbarHeight = mainToolbar == null ? 0 : mainToolbar.getLayoutParams().height; + final int nowPlayingToolbarHeight = nowPlayingToolbar == null ? 0 : nowPlayingToolbar.getLayoutParams().height; + final int mainToolbarPaddingTop = mainToolbar == null ? 0 : mainToolbar.getPaddingTop(); + final int nowPlayingToolbarPaddingTop = nowPlayingToolbar == null ? 0 : nowPlayingToolbar.getPaddingTop(); + final int swipeTargetPaddingBottom = swipeTarget == null ? 0 : swipeTarget.getPaddingBottom(); + final int nowPlayingContainerPaddingBottom = nowPlayingContainer == null ? 0 : nowPlayingContainer.getPaddingBottom(); + final int fragmentContainerMarginTop = fragmentContainer == null ? 0 : ((ViewGroup.MarginLayoutParams) fragmentContainer.getLayoutParams()).topMargin; + final int drawerPaddingBottom = drawer == null ? 0 : drawer.getPaddingBottom(); + final int headerHeight = header == null ? 0 : header.getLayoutParams().height; + final int headerPaddingTop = header == null ? 0 : header.getPaddingTop(); + final int rootPaddingBottom = rootView == null ? 0 : rootView.getPaddingBottom(); + + ViewCompat.setOnApplyWindowInsetsListener(getWindow().getDecorView(), (view, windowInsets) -> { + Insets bars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); + + // The toolbars grow by the status bar height so that their background paints it, while the + // padding keeps the title and the icons below it. + if(mainToolbar != null) { + ViewGroup.LayoutParams params = mainToolbar.getLayoutParams(); + params.height = mainToolbarHeight + bars.top; + mainToolbar.setPadding(mainToolbar.getPaddingLeft(), mainToolbarPaddingTop + bars.top, mainToolbar.getPaddingRight(), mainToolbar.getPaddingBottom()); + mainToolbar.setLayoutParams(params); + } + if(nowPlayingToolbar != null) { + ViewGroup.LayoutParams params = nowPlayingToolbar.getLayoutParams(); + params.height = nowPlayingToolbarHeight + bars.top; + nowPlayingToolbar.setPadding(nowPlayingToolbar.getPaddingLeft(), nowPlayingToolbarPaddingTop + bars.top, nowPlayingToolbar.getPaddingRight(), nowPlayingToolbar.getPaddingBottom()); + nowPlayingToolbar.setLayoutParams(params); + } + if(fragmentContainerMarginTop > 0) { + // The content is placed below the toolbar with a margin, which has to follow it. + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) fragmentContainer.getLayoutParams(); + params.topMargin = fragmentContainerMarginTop + bars.top; + fragmentContainer.setLayoutParams(params); + } + if(swipeTarget != null) { + // The bar itself stays coloured below the buttons, so only its content is lifted. + swipeTarget.setPadding(swipeTarget.getPaddingLeft(), swipeTarget.getPaddingTop(), swipeTarget.getPaddingRight(), swipeTargetPaddingBottom + bars.bottom); + if(panel instanceof SlidingUpPanelLayout) { + ((SlidingUpPanelLayout) panel).setPanelHeight(panelHeight + bars.bottom); + } + } else if(rootView != null) { + // Screens without a now playing bar keep all of their content clear of the navigation bar. + rootView.setPadding(rootView.getPaddingLeft(), rootView.getPaddingTop(), rootView.getPaddingRight(), rootPaddingBottom + bars.bottom); + } + if(nowPlayingContainer != null) { + nowPlayingContainer.setPadding(nowPlayingContainer.getPaddingLeft(), nowPlayingContainer.getPaddingTop(), nowPlayingContainer.getPaddingRight(), nowPlayingContainerPaddingBottom + bars.bottom); + } + if(header != null) { + // The drawer header is painted behind the status bar, so it grows and its content moves below it. + ViewGroup.LayoutParams params = header.getLayoutParams(); + params.height = headerHeight + bars.top; + header.setPadding(header.getPaddingLeft(), headerPaddingTop + bars.top, header.getPaddingRight(), header.getPaddingBottom()); + header.setLayoutParams(params); + } + if(drawer != null) { + drawer.setPadding(drawer.getPaddingLeft(), drawer.getPaddingTop(), drawer.getPaddingRight(), drawerPaddingBottom + bars.bottom); + } + + return windowInsets; + }); } @Override @@ -1011,10 +1101,12 @@ private void applyTheme() { actionbarCustomColor = prefs.getInt(Constants.PREFERENCES_KEY_ACTION_BAR_COLOR, -1); actionbarNowPlayingCustomColor = prefs.getInt(Constants.PREFERENCES_KEY_ACTION_BAR_NOW_PLAYING_COLOR, -1); } - // The drawer layout paints colorPrimaryDark behind the status bar; fall back to the surface when it is transparent. + // The action bar is drawn behind the status bar, so its colour decides the contrast of the status bar icons. private void applyEdgeToEdge() { int surface = MaterialColors.getColor(this, R.attr.colorSurface, Color.WHITE); - int statusColor = MaterialColors.getColor(this, R.attr.colorPrimaryDark, Color.TRANSPARENT); + int statusColor = actionbarColored && actionbarCustomColor != -1 + ? actionbarCustomColor + : MaterialColors.getColor(this, R.attr.actionbarBackgroundColor, Color.TRANSPARENT); if(Color.alpha(statusColor) == 0) { statusColor = surface; } diff --git a/app/src/main/res/layout/abstract_activity.xml b/app/src/main/res/layout/abstract_activity.xml index 0ed7cc27b..090d8b079 100644 --- a/app/src/main/res/layout/abstract_activity.xml +++ b/app/src/main/res/layout/abstract_activity.xml @@ -4,8 +4,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/drawer_layout" android:layout_width="match_parent" - android:layout_height="match_parent" - android:fitsSystemWindows="true"> + android:layout_height="match_parent"> Date: Sun, 18 Oct 2026 20:23:12 +0200 Subject: [PATCH 2/3] Style the platform preferences for Material 3 The settings screens run on the deprecated android.preference framework, so their widgets are still platform styled. Two things were visibly non-M3: - Category headers: the platform layout hardcodes TextAppearance.Material.Body2 and ?android:attr/colorAccent. android:preferenceCategoryStyle now points at our own layout using the M3 type scale and colorPrimary. - Preference dialogs: the platform DialogPreference creates framework AlertDialogs, which read android:alertDialogTheme. MaterialComponents sets it to its Material 2 overlay, so these dialogs looked M2 next to the app's MaterialAlertDialogs. Point it at the M3 framework overlay instead. The overlay is applied on top of the activity theme, so the Light/Dark/Black/ Holo colour roles carry over without re-declaring them. Preference rows themselves needed nothing: the platform row layout already takes its typography from ?attr/textAppearanceListItem, which the M3 theme defines. --- .../main/res/layout/preference_category_m3.xml | 14 ++++++++++++++ app/src/main/res/values/styles.xml | 15 +++++++++++++++ app/src/main/res/values/themes.xml | 7 +++++++ 3 files changed, 36 insertions(+) create mode 100644 app/src/main/res/layout/preference_category_m3.xml diff --git a/app/src/main/res/layout/preference_category_m3.xml b/app/src/main/res/layout/preference_category_m3.xml new file mode 100644 index 000000000..862790a70 --- /dev/null +++ b/app/src/main/res/layout/preference_category_m3.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 300eeeb49..0f25e4223 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -5,6 +5,21 @@ false + + + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 29a18ac74..b159b3f36 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -97,6 +97,11 @@ true ?attr/actionbarBackgroundColor @drawable/drawer_header_light + + @style/ThemeOverlay.Material3.Dialog.Alert.Framework + @style/Preference.DSub2000.Category From a9ec45ee7639fc056b0f4972979901986c4be681 Mon Sep 17 00:00:00 2001 From: Pavel Rojtberg Date: Sun, 18 Oct 2026 20:21:33 +0200 Subject: [PATCH 3/3] Use MaterialAlertDialogBuilder for the lyrics version dialog The last place that built a classic AppCompat AlertDialog. The preference framework still creates framework AlertDialogs; those are handled separately. --- .../java/github/paroj/dsub2000/fragments/LyricsFragment.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/github/paroj/dsub2000/fragments/LyricsFragment.java b/app/src/main/java/github/paroj/dsub2000/fragments/LyricsFragment.java index 93c2c6107..59482760f 100644 --- a/app/src/main/java/github/paroj/dsub2000/fragments/LyricsFragment.java +++ b/app/src/main/java/github/paroj/dsub2000/fragments/LyricsFragment.java @@ -34,12 +34,13 @@ import android.widget.ScrollView; import android.widget.TextView; -import androidx.appcompat.app.AlertDialog; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearSmoothScroller; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -502,7 +503,7 @@ private void showVersionDialog() { labels[i] = versionLabel(lyricsVersions.get(i)); } - new AlertDialog.Builder(context) + new MaterialAlertDialogBuilder(context) .setTitle(R.string.lyrics_select_version) .setSingleChoiceItems(labels, selectedVersion, new DialogInterface.OnClickListener() { @Override