DEV Community

nan nan
nan nan

Posted on

Ebook Voice Player Responsive Cross-Platform UI: Desktop/Phone Dual-Form, Android Logical Pixel Conversion & Dynamic Layout Restructuring

Product Link: Microsoft Store - Ebook Voice Player

Introduction

Ebook Voice Player is built on the Kivy framework with a single codebase supporting both Windows Desktop and Android Phone platforms. This article deep-dives into the responsive layout system in main_screen.py: automatic layout mode detection, runtime layout restructuring, Android logical pixel conversion, and phone-mode bottom tab switching with modal voice panel design.


1. Responsive Architecture Overview

1.1 Dual-Form Layout Comparison

┌────────────────────────────────────────────────────────────────────┐
│                        DESKTOP MODE (Landscape/Wide)                │
├────────────────────────────────────────────────────────────────────┤
│  ┌────────────────────────────────────────────────────────────┐   │
│  │ MenuBar (File/Play/Nav/View/Tools/About/Language)           │   │
│  ├──────────────────────────────┬──────────────────────────────┤   │
│  │                              │                              │   │
│  │      ReaderPanel (3/4)       │      VoicePanel (1/4)        │   │
│  │  - Chapter Select            │  - Voice List/Search/Preview │   │
│  │  - Paragraphs (click jump)   │  - Voice Detail Info         │   │
│  │  - Font/Line/Margin Controls │                              │   │
│  │                              │                              │   │
│  ├──────────────────────────────┴──────────────────────────────┤   │
│  │ ControlPanel (Play/Pause/Stop/Speed/Prev/Next/Progress)     │   │
│  ├────────────────────────────────────────────────────────────┤   │
│  │ StatusBar (Status Text/Progress %/Stats)                    │   │
│  └────────────────────────────────────────────────────────────┘   │
└────────────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────────────┐
│                        PHONE MODE (Portrait/Narrow)                │
├────────────────────────────────────────────────────────────────────┤
│  ┌────────────────────────────────────────────────────────────┐   │
│  │ MenuBar (Horizontal Scroll)                                 │   │
│  ├────────────────────────────────────────────────────────────┤   │
│  │                                                              │   │
│  │      ReaderPanel (Full Width)  ←→  VoicePanel (Full, Hidden) │   │
│  │  - Chapter Select                - Voice List/Search/Preview │   │
│  │  - Paragraphs                      - Voice Detail Info       │   │
│  │                                                              │   │
│  ├────────────────────────────────────────────────────────────┤   │
│  │ ControlPanel (Taller, Touch-Friendly)                       │   │
│  ├────────────────────────────────────────────────────────────┤   │
│  │ TabBar (📖 Read / 🎤 Voice) ← Core Switch Entry             │   │
│  └────────────────────────────────────────────────────────────┘   │
└────────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

1.2 Component Responsive Behavior Matrix

Component Desktop Mode Phone Mode Switch Mechanism
Body Layout horizontal (split) vertical (stack) orientation dynamic
ReaderPanel size_hint_x=3 size_hint_x=1, size_hint_y=1 Width/height weight realloc
VoicePanel size_hint_x=1, visible size_hint_x=1, size_hint_y=0, hidden opacity=0 hide, use modal
ControlPanel height=118dp height=160dp (touch-friendly) Dynamic height
MenuBar height=38dp height=52dp + ScrollView horizontal Height + scroll container
StatusBar Show progress+stats Hide progress+stats (save space) width=0, opacity=0
TabBar Hidden (height=0, opacity=0) Visible (height=60dp) Fixed bottom tab switch

2. Automatic Layout Mode Detection

2.1 Detection Logic: Narrow Portrait = Phone

def is_phone_layout(w, h):
    """Phone layout criteria:
    - Width > 0
    - Width < Height (portrait)
    - Width <= 820dp (narrow threshold, covers mainstream phones 360-430dp)

    Note: Kivy Window coords are already in dp (logical pixels), thresholds
    are raw dp values. Desktop windows usually landscape or > 820dp wide;
    phones in portrait ~360-430dp wide and always taller than wide.
    """
    return w > 0 and w < h and w <= 820
Enter fullscreen mode Exit fullscreen mode

2.2 Android Logical Pixel Conversion: Core Challenge

Problem: On Android, Window.size returns physical pixels (e.g., 1080×1737), not logical dp. Direct usage causes misclassification as desktop.

def _logical_window_size():
    """Return (width_dp, height_dp) for layout decisions.

    On Android, Window.size is physical surface in pixels, so convert
    using real display metrics. Elsewhere fall back to Window.size
    (already in dp / CSS px).
    """
    try:
        from jnius import autoclass  # Only present in p4a app

        DisplayMetrics = autoclass('android.util.DisplayMetrics')
        PythonActivity = autoclass('org.kivy.android.PythonActivity')
        metrics = DisplayMetrics()
        PythonActivity.mActivity.getWindowManager().getDefaultDisplay().getRealMetrics(metrics)
        factor = 160.0 / metrics.densityDpi  # 160 dpi = 1 dp baseline
        return int(metrics.widthPixels * factor), int(metrics.heightPixels * factor)
    except Exception:
        return int(Window.width), int(Window.height)
Enter fullscreen mode Exit fullscreen mode

Conversion Principle:

dp = px × (160 / densityDpi)

Example: Pixel 6 (1080×2400, 420 dpi)
width_dp  = 1080 × (160/420) ≈ 411 dp
height_dp = 2400 × (160/420) ≈ 914 dp
→ 411 < 914 and 411 <= 820 → Classified as phone ✓
Enter fullscreen mode Exit fullscreen mode

2.3 Layout Mode Update Entry Point

def _update_layout_mode(self, *a):
    w, h = _logical_window_size()
    phone = is_phone_layout(w, h)
    self._phone = phone

    # Must run on first call (desktop startup or Android launch);
    # skip repeat calls for already-applied state.
    if self._layout_initialized and phone == self._phone_mode:
        return
    self._layout_initialized = True
    self._phone_mode = phone

    print(f"[TTSLAYOUT] phone={phone} logical={w}x{h} win={int(Window.width)}x{int(Window.height)}")

    if phone:
        self._apply_phone_layout()
    else:
        self._apply_desktop_layout()
    self._sync_menu_width()
Enter fullscreen mode Exit fullscreen mode

3. Runtime Layout Restructuring Implementation

3.1 Applying Phone Layout

def _apply_phone_layout(self):
    # Body → vertical stack
    self._body.orientation = "vertical"
    self.reader.size_hint_x = 1
    self.voice.size_hint_x = 1

    # VoicePanel removed from Body, use popup instead (see _open_voice_picker)
    self.voice.size_hint_y = 0
    self.voice.opacity = 0

    # Reader full-screen
    self.reader.size_hint_y = 1
    self.reader.opacity = 1

    # StatusBar simplified
    self._apply_status_phone()

    # MenuBar taller + horizontal scroll
    self._menu_scroll.height = dp(52)
    self._menubar.height = dp(52)

    # Bottom TabBar visible
    self._tabbar.height = dp(60)
    self._tabbar.padding = (dp(12), dp(8))
    self._tabbar.spacing = dp(12)
    self._tabbar.opacity = 1

    # Touch-friendly scaling
    self._read_tab.font_size = int(18 * PHONE_FONT_SCALE)
    self._voice_tab.font_size = int(18 * PHONE_FONT_SCALE)
    self.control.height = dp(160)

    # Notify child components
    self.reader.set_phone_mode(True)
    self.control.set_phone_mode(True)
Enter fullscreen mode Exit fullscreen mode

3.2 Applying Desktop Layout

def _apply_desktop_layout(self):
    # Body → horizontal split
    self._body.orientation = "horizontal"
    self.reader.size_hint_x = 3
    self.reader.size_hint_y = 1
    self.reader.opacity = 1
    self.voice.size_hint_x = 1
    self.voice.size_hint_y = 1
    self.voice.opacity = 1

    # StatusBar full
    self._apply_status_desktop()

    # MenuBar standard height
    self._menu_scroll.height = dp(38)
    self._menubar.height = dp(38)

    # TabBar hidden
    self._tabbar.height = 0
    self._tabbar.opacity = 0

    # ControlPanel standard height
    self.control.height = dp(118)

    # Notify child components
    self.reader.set_phone_mode(False)
    self.control.set_phone_mode(False)
Enter fullscreen mode Exit fullscreen mode

3.3 StatusBar Simplified/Full Toggle

def _apply_status_phone(self):
    for w in (self._progress_pct, self._stats_label):
        w.size_hint_x = None
        w.width = 0
        w.opacity = 0

def _apply_status_desktop(self):
    for w, wd in ((self._progress_pct, 220), (self._stats_label, 120)):
        w.size_hint_x = None
        w.width = dp(wd)
        w.opacity = 1
Enter fullscreen mode Exit fullscreen mode

4. Phone Core Interactions: Tab Switch & Voice Modal

4.1 Bottom TabBar

# TabBar construction (in _build_ui)
tab = BoxLayout(size_hint_y=None, height=dp(44), spacing=dp(8), padding=(dp(8), dp(6)))
self._tabbar = tab
self._read_tab = TButton(L("tab_read"), theme=self._theme, radius=6,
                         bg_key="accent", text_key="accent_text", font_size=14)
self._read_tab.bind(on_release=lambda b: self._switch_phone_tab("read"))
tab.add_widget(self._read_tab)

self._voice_tab = TButton(L("tab_voice"), theme=self._theme, radius=6,
                          bg_key="btn_bg", font_size=14)
self._voice_tab.bind(on_release=lambda b: self._switch_phone_tab("voice"))
tab.add_widget(self._voice_tab)
self.add_widget(tab)
Enter fullscreen mode Exit fullscreen mode

4.2 Tab Switch Logic

def _switch_phone_tab(self, which):
    if which == "voice":
        self._open_voice_picker()  # Voice tab → modal
    else:
        self._phone_show_reader = True  # Read tab → show reader
    self._apply_phone_view()

def _apply_phone_view(self):
    if self._phone_show_reader:
        self.reader.size_hint_y = 1
        self.reader.opacity = 1
        self.voice.size_hint_y = 0
        self.voice.opacity = 0
        self._read_tab.bg_key = "accent"
        self._read_tab.text_key = "accent_text"
        self._voice_tab.bg_key = "btn_bg"
        self._voice_tab.text_key = "btn_text"
    else:
        self.reader.size_hint_y = 0
        self.reader.opacity = 0
        self.voice.size_hint_y = 1
        self.voice.opacity = 1
        self._read_tab.bg_key = "btn_bg"
        self._read_tab.text_key = "btn_text"
        self._voice_tab.bg_key = "accent"
        self._voice_tab.text_key = "accent_text"
    self._read_tab.apply_theme(self._theme)
    self._voice_tab.apply_theme(self._theme)
Enter fullscreen mode Exit fullscreen mode

4.3 Voice Picker Modal: Detach VoicePanel from Body

def _open_voice_picker(self):
    """Open voice picker popup on phone"""
    from kivy.uix.popup import Popup
    from kivy.uix.boxlayout import BoxLayout as BL

    popup = Popup(title=L("vp_picker_title"),
                  size_hint=(0.95, 0.88),
                  auto_dismiss=True,
                  background="atlas://data/images.atlas/default",
                  background_color=(0, 0, 0, 0.7))

    root = BL(orientation="vertical", spacing=dp(6), padding=dp(12), size_hint=(1, 1))

    # Title row + close button
    title_row = BL(size_hint_y=None, height=dp(52), spacing=dp(8))
    title_row.add_widget(TLabel(L("vp_picker_title"), theme=self._theme,
                                color_key="text", font_size=int(22 * PHONE_FONT_SCALE),
                                bold=True, halign="left", size_hint_x=0.7))
    close_btn = TButton(L("vp_close"), theme=self._theme, radius=8,
                        bg_key="red", text_key="crust",
                        font_size=int(16 * PHONE_FONT_SCALE), size_hint_x=0.3)
    close_btn.bind(on_release=lambda b: popup.dismiss())
    title_row.add_widget(close_btn)
    root.add_widget(title_row)

    # Key: detach VoicePanel from Body, attach to popup
    body_parent = self.voice.parent
    if body_parent:
        body_parent.remove_widget(self.voice)
    self.voice.set_phone_mode(True)
    root.add_widget(self.voice)
    self.voice.size_hint_y = 1
    self.voice.opacity = 1

    popup.content = root
    popup.open()

    # Bind dismiss callback to return VoicePanel
    popup.bind(on_dismiss=lambda *a: self._on_voice_picker_closed(body_parent))
Enter fullscreen mode Exit fullscreen mode

4.4 Return VoicePanel on Modal Dismiss

def _on_voice_picker_closed(self, body_parent=None):
    """After voice picker closes, return VoicePanel to Body on phone."""
    if self._phone_mode and body_parent:
        self.voice.size_hint_y = 0
        self.voice.opacity = 0
        body_parent.add_widget(self.voice)
    else:
        # Ensure proper reset on desktop too
        self.voice.size_hint_y = 1
        self.voice.opacity = 1
Enter fullscreen mode Exit fullscreen mode

5. Child Component Responsive Adaptation Interface

5.1 Unified set_phone_mode(phone) Protocol

# ReaderPanel
def set_phone_mode(self, phone):
    self._phone = phone
    if phone:
        self.font_size = max(self.font_size, 18)  # Minimum readable size
        self._update_label_font()
    self._refresh_layout()

# ControlPanel
def set_phone_mode(self, phone):
    self._phone = phone
    if phone:
        self.height = dp(160)
        self._btn_size = dp(56)  # Touch target ≥ 48dp
    else:
        self.height = dp(118)
        self._btn_size = dp(40)
    self._rebuild_controls()

# VoicePanel
def set_phone_mode(self, phone):
    self._phone = phone
    if phone:
        self._item_height = dp(56)
        self._search_font = int(16 * PHONE_FONT_SCALE)
    else:
        self._item_height = dp(40)
        self._search_font = 14
    self._refresh_list()
Enter fullscreen mode Exit fullscreen mode

5.2 Font/Button Scale Constants

# widgets.py
PHONE_FONT_SCALE = 1.3   # Phone mode font +30%
PHONE_BTN_SCALE = 1.4    # Phone mode button +40%
Enter fullscreen mode Exit fullscreen mode

6. Window Size Auto-Adaptation: Desktop Optimal on First Frame

6.1 Main Entry Auto-Fit Desktop Resolution

# main.py
def _configure_window_size():
    """Auto-fit desktop resolution, avoid first-frame relayout"""
    try:
        from kivy.utils import platform as _kivy_platform
    except Exception:
        _kivy_platform = None

    if _kivy_platform == "android":
        return  # Android managed by system

    res, dpi = _screen_wh()
    if res and dpi:
        sw, sh = res
        scale = 96.0 / float(dpi)  # DPI scale correction
        # 88% width, 86% height of desktop, convert to logical px
        w = max(900, int(sw * 0.88 * scale))
        h = max(620, int(sh * 0.86 * scale))
        # Don't exceed screen (taskbar margin)
        w = min(w, int(sw * scale) - 20)
        h = min(h, int(sh * scale) - 60)
        Config.set("graphics", "width", str(w))
        Config.set("graphics", "height", str(h))
        return

    # Fallback
    Config.set("graphics", "width", "1280")
    Config.set("graphics", "height", "820")

def _screen_wh():
    if sys.platform == "win32":
        import ctypes
        user32 = ctypes.windll.user32
        dpi = user32.GetDpiForWindow(user32.GetDesktopWindow()) or 96
        return (user32.GetSystemMetrics(0), user32.GetSystemMetrics(1)), dpi
    return None, None
Enter fullscreen mode Exit fullscreen mode

7. Fullscreen & Landscape Support

7.1 Desktop Fullscreen Toggle

def toggle_fullscreen(self):
    self._menubar.close_menu()
    if self._is_fullscreen:
        Window.fullscreen = False
        self._is_fullscreen = False
    else:
        Window.fullscreen = "auto"  # Borderless fullscreen
        self._is_fullscreen = True
Enter fullscreen mode Exit fullscreen mode

7.2 Android Auto-Adapt on Rotation

# _update_layout_mode triggers on every resize
# Landscape: w > h → is_phone_layout returns False → auto desktop layout
# Portrait: w < h and w <= 820 → phone layout
# True rotation-responsive behavior
Enter fullscreen mode Exit fullscreen mode

8. Debugging & Diagnostics

8.1 Layout Logging

print(f"[TTSLAYOUT] phone={phone} logical={w}x{h} win={int(Window.width)}x{int(Window.height)}")
# Sample output:
# [TTSLAYOUT] phone=True logical=411x914 win=1080x2400  (Android portrait)
# [TTSLAYOUT] phone=False logical=914x411 win=2400x1080  (Android landscape)
# [TTSLAYOUT] phone=False logical=1536x864 win=1536x864  (Windows desktop)
Enter fullscreen mode Exit fullscreen mode

8.2 Touch Event Debug

def on_touch_down(self, touch):
    import sys
    print(f"[DBGTOUCH] DOWN pos={tuple(touch.pos)} win={int(Window.width)}x{int(Window.height)}", file=sys.stdout)
    return super().on_touch_down(touch)
Enter fullscreen mode Exit fullscreen mode

9. Common Issues & Troubleshooting

Symptom Cause Debug
Android starts in desktop layout _logical_window_size falls back to physical px Verify jnius available, autoclass succeeds
Layout doesn't switch on rotation Window.bind(on_resize) not firing Confirm _update_layout_mode bound correctly
Voice panel gone after modal close _on_voice_picker_closed didn't return widget Check body_parent passed & add_widget called
Bottom TabBar not showing _tabbar.height=0 or opacity=0 Verify _apply_phone_layout executed
Font too small/buttons hard to tap PHONE_FONT_SCALE/PHONE_BTN_SCALE not applied Confirm set_phone_mode(True) reaches children

10. Extensibility: Future Platform Adaptation Guide

10.1 Adding Platforms (iPad/Web/Linux Desktop)

# 1. Extend is_phone_layout or add is_tablet_layout
def is_tablet_layout(w, h):
    return w > 0 and w < h and 820 < w <= 1100  # 7-10 inch tablets

# 2. Add branch in _update_layout_mode
if phone:
    self._apply_phone_layout()
elif tablet:
    self._apply_tablet_layout()  # Dual-pane + sidebar
else:
    self._apply_desktop_layout()

# 3. Implement _apply_tablet_layout
#    - Body horizontal
#    - Reader 2/3, Voice 1/3
#    - Full statusbar
#    - No TabBar
Enter fullscreen mode Exit fullscreen mode

10.2 Responsive Breakpoint Design Recommendations

Breakpoint Range (dp) Layout Form
Phone 0 - 820 Single-column + Tab switch + Modal
Tablet 820 - 1100 Dual-pane fixed + Sidebar drawer
Desktop 1100+ Multi-pane free + Full toolbar

11. Conclusion

Ebook Voice Player's responsive system demonstrates Kivy single-codebase multi-platform engineering practice:

  1. Physical/Logical Pixel Separation: Android densityDpi conversion ensures accurate layout classification
  2. Runtime Layout Restructuring: Dynamic size_hint/opacity/parent-child adjustments, zero destroy-recreate
  3. Interaction Paradigm Adaptation: Desktop fixed split ↔ Phone Tab+Modal, matching platform conventions
  4. Child Component Coordination: Unified set_phone_mode protocol, auto-scaling fonts/buttons/list items
  5. First-Frame Optimal: Desktop startup auto-fits resolution/DPI, no flicker relayout

This system runs stably on Windows 10/11, Android 7-14, powering consistent cross-platform experience.


Try It: Microsoft Store - Ebook Voice Player

Source Code: main_screen.py / tts_app/kivy_ui/main_screen.py

Top comments (0)