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 │ │
│ └────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
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
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)
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 ✓
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()
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)
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)
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
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)
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)
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))
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
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()
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%
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
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
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
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)
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)
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
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:
-
Physical/Logical Pixel Separation: Android
densityDpiconversion ensures accurate layout classification -
Runtime Layout Restructuring: Dynamic
size_hint/opacity/parent-child adjustments, zero destroy-recreate - Interaction Paradigm Adaptation: Desktop fixed split ↔ Phone Tab+Modal, matching platform conventions
-
Child Component Coordination: Unified
set_phone_modeprotocol, auto-scaling fonts/buttons/list items - 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)