Sideband/sbapp/ui/voice.py

752 lines
No EOL
32 KiB
Python

import time
import RNS
from typing import Union
from kivy.metrics import dp,sp
from kivy.lang.builder import Builder
from kivy.core.clipboard import Clipboard
from kivy.utils import escape_markup
from kivymd.uix.recycleview import MDRecycleView
from kivymd.uix.list import OneLineIconListItem
from kivymd.uix.pickers import MDColorPicker
from kivymd.uix.button import MDRectangleFlatButton
from kivymd.uix.button import MDRectangleFlatIconButton
from kivymd.uix.dialog import MDDialog
from kivy.properties import StringProperty, BooleanProperty, OptionProperty, ColorProperty, Property
from kivymd.uix.list import MDList, IconLeftWidget, IconRightWidget, OneLineAvatarIconListItem
from kivymd.icon_definitions import md_icons
from kivymd.toast import toast
from kivy.properties import StringProperty, BooleanProperty
from kivy.effects.scroll import ScrollEffect
from kivy.clock import Clock
from sideband.sense import Telemeter
import threading
from datetime import datetime
if RNS.vendor.platformutils.get_platform() == "android":
from ui.helpers import ts_format_date
from android.permissions import request_permissions, check_permission
else:
from .helpers import ts_format_date
from kivy.utils import escape_markup
if RNS.vendor.platformutils.get_platform() == "android":
from ui.helpers import multilingual_markup
else:
from .helpers import multilingual_markup
from LXST.Primitives.Telephony import Telephone, Profiles
class Voice():
def __init__(self, app):
self.app = app
self.screen = None
self.settings_screen = None
self.dial_target = None
self.ui_updater = None
self.path_requesting = None
self.output_devices = []
self.input_devices = []
self.log_list = None
self.last_log_update = 0
self.log_name_cache = {}
self.listed_output_devices = []
self.listed_input_devices = []
self.listed_ringer_devices = []
self.call_profile = Profiles.DEFAULT_PROFILE
if not self.app.root.ids.screen_manager.has_screen("voice_screen"):
self.screen = Builder.load_string(layout_voice_screen)
self.screen.app = self.app
self.screen.delegate = self
self.app.root.ids.screen_manager.add_widget(self.screen)
self.update_call_log()
def update_call_status(self, dt=None):
if self.app.root.ids.screen_manager.current == "voice_screen":
if self.ui_updater == None: self.ui_updater = Clock.schedule_interval(self.update_call_status, 0.5)
else:
if self.ui_updater:
self.ui_updater.cancel()
self.ui_updater = None
db = self.screen.ids.dial_button
rb = self.screen.ids.reject_button
ih = self.screen.ids.identity_hash
pb = self.screen.ids.call_profile_button
if self.app.sideband.voice_running:
telephone = self.app.sideband.telephone
if self.path_requesting:
db.disabled = True
rb.disabled = True
ih.disabled = True
else:
if telephone.is_available:
ih.disabled = False
rb.disabled = True
pb.disabled = False
self.target_input_action(ih)
else:
ih.disabled = True
rb.disabled = True
pb.disabled = True
if telephone.is_in_call or telephone.call_is_connecting:
ih.disabled = True
rb.disabled = True
db.disabled = False
if telephone.call_is_connecting: pb.disabled = True
if telephone.is_in_call: pb.disabled = False
db.text = "Hang up"
db.icon = "phone-hangup"
if telephone.active_profile: self.call_profile = telephone.active_profile
elif telephone.is_ringing:
ih.disabled = True
rb.disabled = False
db.disabled = False
pb.disabled = True
db.text = "Answer"
db.icon = "phone-ring"
if telephone.caller: ih.text = RNS.hexrep(telephone.caller.hash, delimit=False)
if telephone.active_profile: self.call_profile = telephone.active_profile
if self.app.sideband.getstate("voice.connection_failure"):
self.app.sideband.setstate("voice.connection_failure", False)
toast("Could not connect call", duration=5)
else:
db.disabled = True; db.text = "Voice calls disabled"
ih.disabled = True
rb.disabled = True
pb.disabled = True
pb.text = Profiles.profile_abbrevation(self.call_profile)
if time.time() > self.last_log_update+3: self.update_call_log()
def target_valid(self):
if self.app.sideband.voice_running:
db = self.screen.ids.dial_button
db.disabled = False; db.text = "Call"
db.icon = "phone-outgoing"
def target_invalid(self):
if self.app.sideband.voice_running:
db = self.screen.ids.dial_button
db.disabled = True; db.text = "Call"
db.icon = "phone-outgoing"
def target_input_action(self, sender):
if sender:
target_hash = sender.text
if len(target_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2:
try:
identity_hash = bytes.fromhex(target_hash)
self.dial_target = identity_hash
self.target_valid()
except Exception as e: self.target_invalid()
else: self.target_invalid()
def request_path(self, destination_hash):
if not self.path_requesting:
self.app.sideband.telephone.set_busy(True)
toast("Requesting path...")
self.screen.ids.dial_button.disabled = True
self.path_requesting = destination_hash
RNS.Transport.request_path(destination_hash)
threading.Thread(target=self._path_wait_job, daemon=True).start()
else:
toast("Waiting for path request answer...")
def _path_wait_job(self):
timeout = time.time()+self.app.sideband.telephone.PATH_TIME
while not RNS.Transport.has_path(self.path_requesting) and time.time() < timeout:
time.sleep(0.25)
self.app.sideband.telephone.set_busy(False)
if RNS.Transport.has_path(self.path_requesting):
RNS.log(f"Calling {RNS.prettyhexrep(self.dial_target)}...", RNS.LOG_DEBUG)
self.app.sideband.telephone.dial(self.dial_target, profile=self.call_profile)
Clock.schedule_once(self.update_call_status, 0.1)
else:
Clock.schedule_once(self._path_request_failed, 0.05)
Clock.schedule_once(self.update_call_status, 0.1)
self.path_requesting = None
self.update_call_status()
def _path_request_failed(self, dt):
toast("Path request timed out")
def call_profile_action(self, sender=None):
if self.app.sideband.telephone.is_in_call: self.switch_profile_action()
else:
pb = self.screen.ids.call_profile_button
self.call_profile = Profiles.next_profile(self.call_profile)
pb.text = Profiles.profile_abbrevation(self.call_profile)
toast(f"Call Profile: {Profiles.profile_name(self.call_profile)}")
def switch_profile_action(self, sender=None):
if self.initial_call_profile == None: self.initial_call_profile = self.call_profile
if self.initial_call_profile < Profiles.QUALITY_MEDIUM: alt_profile = Profiles.QUALITY_MEDIUM
else: alt_profile = Profiles.BANDWIDTH_LOW
switch_profiles = [alt_profile, self.initial_call_profile]
if self.call_profile == switch_profiles[0]:
RNS.log(f"Switching to {Profiles.profile_name(switch_profiles[1])}", RNS.LOG_DEBUG)
self.app.sideband.telephone.switch_profile(switch_profiles[1])
else:
RNS.log(f"Switching to {Profiles.profile_name(switch_profiles[0])}", RNS.LOG_DEBUG)
self.app.sideband.telephone.switch_profile(switch_profiles[0])
def clear_log_action(self, sender=None):
self.app.sideband.telephone.clear_call_log()
self.update_call_log()
def log_dial_action(self, sender=None):
def job(dt=None):
if not self.app.root.ids.screen_manager.current == "voice_screen": return
if sender and self.app.sideband.voice_running and self.app.sideband.telephone != None:
telephone = self.app.sideband.telephone
if not telephone.is_ringing and not telephone.is_in_call and not telephone.call_is_connecting:
call_dialog_text = f"[b]Initiate Voice Call?[/b]\n\nDestination Identity:\n{RNS.prettyhexrep(sender.identity)}"
if hasattr(self.app, "confirm_call_dialog"): self.app.confirm_call_dialog.text = call_dialog_text
else: self.app.init_confirm_call_dialog(call_dialog_text)
self.app.confirm_call_dialog.dest_identity_hash = sender.identity
self.app.confirm_call_dialog.open()
Clock.schedule_once(job, 0.1)
def reject_action(self, sender=None):
if self.app.sideband.voice_running:
if self.app.sideband.telephone.is_ringing:
self.app.sideband.telephone.hangup()
self.initial_call_profile = None
def dial_action(self, sender=None):
if self.app.sideband.voice_running:
if self.app.sideband.telephone.is_available:
destination_hash = RNS.Destination.hash_from_name_and_identity("lxst.telephony", self.dial_target)
if not RNS.Transport.has_path(destination_hash): self.request_path(destination_hash)
else:
RNS.log(f"Calling {RNS.prettyhexrep(self.dial_target)}...", RNS.LOG_DEBUG)
if self.app.sideband.telephone.dial(self.dial_target, profile=self.call_profile) == "no_path":
self.request_path(destination_hash)
self.update_call_status()
elif self.app.sideband.telephone.is_in_call or self.app.sideband.telephone.call_is_connecting:
RNS.log(f"Hanging up", RNS.LOG_DEBUG)
self.app.sideband.telephone.hangup()
self.update_call_status()
elif self.app.sideband.telephone.is_ringing:
RNS.log(f"Answering", RNS.LOG_DEBUG)
self.app.sideband.telephone.answer()
self.update_call_status()
self.initial_call_profile = None
### Settings screen
######################################
def settings_action(self, sender=None):
if not self.app.root.ids.screen_manager.has_screen("voice_settings_screen"):
self.voice_settings_screen = Builder.load_string(layout_voice_settings_screen)
self.voice_settings_screen.app = self.app
self.voice_settings_screen.delegate = self
self.app.root.ids.screen_manager.add_widget(self.voice_settings_screen)
self.app.root.ids.screen_manager.transition.direction = "left"
self.app.root.ids.screen_manager.current = "voice_settings_screen"
self.voice_settings_screen.ids.voice_settings_scrollview.effect_cls = ScrollEffect
self.app.sideband.setstate("app.displaying", self.app.root.ids.screen_manager.current)
self.update_settings_screen()
def update_devices(self):
import LXST
self.output_devices = []; self.input_devices = []
for device in LXST.Sources.Backend().soundcard.all_speakers(): self.output_devices.append(device.name)
for device in LXST.Sinks.Backend().soundcard.all_microphones(): self.input_devices.append(device.name)
if self.app.sideband.config["voice_output"] != None:
if not self.app.sideband.config["voice_output"] in self.output_devices: self.output_devices.append(self.app.sideband.config["voice_output"])
if self.app.sideband.config["voice_input"] != None:
if not self.app.sideband.config["voice_input"] in self.input_devices: self.input_devices.append(self.app.sideband.config["voice_input"])
if self.app.sideband.config["voice_ringer"] != None:
if not self.app.sideband.config["voice_ringer"] in self.output_devices: self.output_devices.append(self.app.sideband.config["voice_ringer"])
def update_settings_screen(self, sender=None):
self.voice_settings_screen.ids.voice_trusted_only.active = self.app.sideband.config["voice_trusted_only"]
self.voice_settings_screen.ids.voice_trusted_only.bind(active=self.settings_save_action)
self.voice_settings_screen.ids.voice_low_latency.active = self.app.sideband.config["voice_low_latency"]
self.voice_settings_screen.ids.voice_low_latency.bind(active=self.settings_save_action)
if not RNS.vendor.platformutils.is_android(): self.voice_settings_screen.ids.voice_low_latency.disabled = True
bp = 6; ml = 38; fs = 16; ics = 14
self.update_devices()
# Output devices
if not "system_default" in self.listed_output_devices:
default_output_button = MDRectangleFlatIconButton(text="System Default", font_size=dp(fs), icon_size=dp(ics), on_release=self.output_device_action)
default_output_button.device = None; default_output_button.size_hint = [1.0, None]
if self.app.sideband.config["voice_output"] == None: default_output_button.icon = "check"
self.voice_settings_screen.ids.output_devices.add_widget(default_output_button)
self.listed_output_devices.append("system_default")
for device in self.output_devices:
if not device in self.listed_output_devices:
device_str = device.replace("[", "").replace("]", "")
label = device_str if len(device_str) < ml else device_str[:ml-3]+"..."
device_button = MDRectangleFlatIconButton(text=label, font_size=dp(fs), icon_size=dp(ics), on_release=self.output_device_action)
device_button.padding = [dp(bp), dp(bp), dp(bp), dp(bp)]; device_button.size_hint = [1.0, None]
if self.app.sideband.config["voice_output"] == device: device_button.icon = "check"
device_button.device = device
self.voice_settings_screen.ids.output_devices.add_widget(device_button)
self.listed_output_devices.append(device)
# Input devices
if not "system_default" in self.listed_input_devices:
default_input_button = MDRectangleFlatIconButton(text="System Default", font_size=dp(fs), icon_size=dp(ics), on_release=self.input_device_action)
default_input_button.device = None; default_input_button.size_hint = [1.0, None]
if self.app.sideband.config["voice_output"] == None: default_input_button.icon = "check"
self.voice_settings_screen.ids.input_devices.add_widget(default_input_button)
self.listed_input_devices.append("system_default")
for device in self.input_devices:
if not device in self.listed_input_devices:
device_str = device.replace("[", "").replace("]", "")
label = device_str if len(device_str) < ml else device_str[:ml-3]+"..."
device_button = MDRectangleFlatIconButton(text=label, font_size=dp(fs), icon_size=dp(ics), on_release=self.input_device_action)
device_button.padding = [dp(bp), dp(bp), dp(bp), dp(bp)]; device_button.size_hint = [1.0, None]
if self.app.sideband.config["voice_input"] == device: device_button.icon = "check"
device_button.device = device
self.voice_settings_screen.ids.input_devices.add_widget(device_button)
self.listed_input_devices.append(device)
# Ringer devices
if not "system_default" in self.listed_ringer_devices:
default_ringer_button = MDRectangleFlatIconButton(text="System Default", font_size=dp(fs), icon_size=dp(ics), on_release=self.ringer_device_action)
default_ringer_button.device = None; default_ringer_button.size_hint = [1.0, None]
if self.app.sideband.config["voice_ringer"] == None: default_ringer_button.icon = "check"
self.voice_settings_screen.ids.ringer_devices.add_widget(default_ringer_button)
self.listed_ringer_devices.append("system_default")
for device in self.output_devices:
if not device in self.listed_ringer_devices:
device_str = device.replace("[", "").replace("]", "")
label = device_str if len(device_str) < ml else device_str[:ml-3]+"..."
device_button = MDRectangleFlatIconButton(text=label, font_size=dp(fs), icon_size=dp(ics), on_release=self.ringer_device_action)
device_button.padding = [dp(bp), dp(bp), dp(bp), dp(bp)]; device_button.size_hint = [1.0, None]
if self.app.sideband.config["voice_ringer"] == device: device_button.icon = "check"
device_button.device = device
self.voice_settings_screen.ids.ringer_devices.add_widget(device_button)
self.listed_ringer_devices.append(device)
def settings_save_action(self, sender=None, event=None):
self.app.sideband.config["voice_trusted_only"] = self.voice_settings_screen.ids.voice_trusted_only.active
self.app.sideband.config["voice_low_latency"] = self.voice_settings_screen.ids.voice_low_latency.active
self.app.sideband.save_configuration()
if self.app.sideband.telephone:
self.app.sideband.telephone.set_low_latency_output(self.app.sideband.config["voice_low_latency"])
def output_device_action(self, sender=None):
self.app.sideband.config["voice_output"] = sender.device
self.app.sideband.save_configuration()
for w in self.voice_settings_screen.ids.output_devices.children: w.icon = ""
sender.icon = "check"
if self.app.sideband.telephone:
self.app.sideband.telephone.set_speaker(self.app.sideband.config["voice_output"])
def input_device_action(self, sender=None):
self.app.sideband.config["voice_input"] = sender.device
self.app.sideband.save_configuration()
for w in self.voice_settings_screen.ids.input_devices.children: w.icon = ""
sender.icon = "check"
if self.app.sideband.telephone:
self.app.sideband.telephone.set_microphone(self.app.sideband.config["voice_input"])
def ringer_device_action(self, sender=None):
self.app.sideband.config["voice_ringer"] = sender.device
self.app.sideband.save_configuration()
for w in self.voice_settings_screen.ids.ringer_devices.children: w.icon = ""
sender.icon = "check"
if self.app.sideband.telephone:
self.app.sideband.telephone.set_ringer(self.app.sideband.config["voice_ringer"])
### Call log
######################################
def update_call_log(self):
if self.log_list == None:
self.log_list = CallList()
self.screen.ids.log_list_container.add_widget(self.log_list)
self.update_log_list()
self.last_log_update = time.time()
def update_log_list(self):
if not self.app.sideband.telephone: self.log_list.data = []
else:
LogEntry.owner = self
call_log = self.app.sideband.telephone.get_call_log()
call_log.sort(key=lambda e: e["time"], reverse=True)
data = []
for entry in call_log:
try:
at = entry["time"]
td = int(time.time())-int(at)
evt = entry["event"]
idnt = entry["identity"]
if not idnt in self.log_name_cache: self.log_name_cache[idnt] = self.app.sideband.voice_display_name(idnt)
name = multilingual_markup(escape_markup(str(self.log_name_cache[idnt])).encode("utf-8")).decode("utf-8")
icon = None
if evt == "incoming-missed": icon = "phone-missed"
elif evt == "outgoing-failure": icon = "phone-remove"
elif evt == "incoming-success": icon = "phone-incoming"
elif evt == "outgoing-success": icon = "phone-outgoing"
time_str = None
if td < 60: time_str = "Just now"
elif td < 60*60: td = int((td//60)*60)
elif td < 60*60*24: td = int((td//60)*60)
elif td < 60*60*24*7: td = int((td//(60*60*24))*(60*60*24))
else: time_str = time.strftime(ts_format_date, time.localtime(at))
if time_str == None: time_str = f"{RNS.prettytime(td)} ago"
if icon:
info = f"{name} • [i]{time_str}[/i]"
entry = {"icon": icon, "text": f"{info}", "identity": idnt}
data.append(entry)
except Exception as e:
RNS.log(f"An error occurred while updating the call log list: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
self.log_list.data = data
class LogEntry(OneLineAvatarIconListItem):
owner = None
icon = StringProperty()
# ti_color = OptionProperty(None, options=theme_text_color_options)
# icon_fg = Property(None, allownone=True)
# icon_bg = Property(None, allownone=True)
def __init__(self):
super().__init__()
self.bind(on_release=self.dial_action)
# self.ids.left_icon.bind(on_release=self.left_icon_action)
# self.ids.right_icon.bind(on_release=self.right_icon_action)
def dial_action(self, sender=None):
self.owner.log_dial_action(self)
def left_icon_action(self, sender):
pass
def right_icon_action(self, sender):
pass
class CallList(MDRecycleView):
def __init__(self):
super().__init__()
self.data = []
Builder.load_string("""
<LogEntry>
IconLeftWidget:
id: left_icon
# theme_icon_color: root.ti_color
# icon_color: root.icon_fg
# md_bg_color: root.icon_bg
icon: root.icon
_default_icon_pad: dp(14)
icon_size: dp(24)
# IconRightWidget:
# id: right_icon
# icon: "dots-vertical"
<CallList>:
id: calls_scrollview
viewclass: "LogEntry"
effect_cls: "ScrollEffect"
RecycleBoxLayout:
default_size: None, dp(57)
default_size_hint: 1, None
size_hint_y: None
height: self.minimum_height
orientation: "vertical"
""")
layout_voice_screen = """
MDScreen:
name: "voice_screen"
BoxLayout:
orientation: "vertical"
MDTopAppBar:
title: "Voice"
anchor_title: "left"
elevation: 0
left_action_items:
[['menu', lambda x: root.app.nav_drawer.set_state("open")]]
right_action_items:
[
['wrench-cog', lambda x: root.delegate.settings_action(self)],
['close', lambda x: root.app.close_any_action(self)],
]
MDBoxLayout:
orientation: "vertical"
size_hint_y: None
height: self.minimum_height
padding: [dp(28), dp(12), dp(28), dp(16)]
MDBoxLayout:
orientation: "vertical"
# spacing: "24dp"
size_hint_y: None
height: self.minimum_height
padding: [dp(0), dp(12), dp(0), dp(0)]
MDTextField:
id: identity_hash
hint_text: "Identity hash"
mode: "rectangle"
# size_hint: [1.0, None]
pos_hint: {"center_x": .5}
max_text_length: 32
on_text: root.delegate.target_input_action(self)
MDBoxLayout:
orientation: "vertical"
spacing: "24dp"
size_hint_y: None
height: self.minimum_height
padding: [dp(0), dp(35), dp(0), dp(14)]
MDBoxLayout:
orientation: "horizontal"
spacing: "24dp"
size_hint_y: None
height: self.minimum_height
padding: [dp(0), dp(0), dp(0), dp(0)]
MDRectangleFlatIconButton:
id: call_profile_button
icon: "account-voice"
text: "HQ"
padding: [dp(0), dp(14), dp(0), dp(14)]
icon_size: dp(24)
font_size: dp(16)
size_hint: [1.0, None]
on_release: root.delegate.call_profile_action(self)
disabled: False
MDRectangleFlatIconButton:
id: dial_button
icon: "phone-outgoing"
text: "Call"
padding: [dp(0), dp(14), dp(0), dp(14)]
icon_size: dp(24)
font_size: dp(16)
size_hint: [2.0, None]
on_release: root.delegate.dial_action(self)
disabled: True
MDRectangleFlatIconButton:
id: reject_button
icon: "phone-cancel"
text: "Reject"
padding: [dp(0), dp(14), dp(0), dp(14)]
icon_size: dp(24)
font_size: dp(16)
size_hint: [1.0, None]
on_release: root.delegate.reject_action(self)
disabled: True
MDSeparator:
orientation: "horizontal"
height: dp(1)
MDBoxLayout:
orientation: "vertical"
id: log_list_container
MDSeparator:
orientation: "horizontal"
height: dp(1)
MDBoxLayout:
orientation: "vertical"
size_hint_y: None
height: self.minimum_height
padding: [dp(28), dp(24), dp(28), dp(24)]
MDRectangleFlatIconButton:
id: clear_log_button
icon: "playlist-remove"
text: "Clear Log"
padding: [dp(0), dp(14), dp(0), dp(14)]
icon_size: dp(24)
font_size: dp(16)
size_hint: [1.0, None]
on_release: root.delegate.clear_log_action(self)
"""
layout_voice_settings_screen = """
MDScreen:
name: "voice_settings_screen"
BoxLayout:
orientation: "vertical"
MDTopAppBar:
id: top_bar
title: "Voice Configuration"
anchor_title: "left"
elevation: 0
left_action_items:
[['menu', lambda x: root.app.nav_drawer.set_state("open")]]
right_action_items:
[
['close', lambda x: root.app.close_sub_voice_action(self)],
]
MDScrollView:
id: voice_settings_scrollview
size_hint_x: 1
size_hint_y: None
size: [root.width, root.height-root.ids.top_bar.height]
do_scroll_x: False
do_scroll_y: True
MDBoxLayout:
orientation: "vertical"
size_hint_y: None
height: self.minimum_height
padding: [dp(28), dp(48), dp(28), dp(16)]
MDLabel:
text: "Call Handling"
font_style: "H6"
height: self.texture_size[1]
padding: [dp(0), dp(0), dp(0), dp(12)]
MDLabel:
id: voice_settings_info
markup: True
text: "You can block calls from all other callers than contacts marked as trusted, by enabling the following option."
size_hint_y: None
text_size: self.width, None
height: self.texture_size[1]
padding: [dp(0), dp(16), dp(0), dp(16)]
MDBoxLayout:
orientation: "horizontal"
padding: [0,0,dp(24),0]
size_hint_y: None
height: dp(48)
MDLabel:
text: "Block non-trusted callers"
font_style: "H6"
MDSwitch:
id: voice_trusted_only
pos_hint: {"center_y": 0.3}
active: False
MDBoxLayout:
orientation: "horizontal"
padding: [0,0,dp(24),0]
size_hint_y: None
height: dp(48)
MDLabel:
text: "Low-latency output"
font_style: "H6"
MDSwitch:
id: voice_low_latency
pos_hint: {"center_y": 0.3}
active: False
MDLabel:
text: "Audio Devices"
font_style: "H6"
padding: [dp(0), dp(96), dp(0), dp(12)]
MDLabel:
id: voice_settings_info
markup: True
text: "You can configure which audio devices Sideband will use for voice calls, by selecting either the system default device, or specific audio devices available."
size_hint_y: None
text_size: self.width, None
height: self.texture_size[1]
padding: [dp(0), dp(64), dp(0), dp(32)]
MDLabel:
text: "[b]Output[/b]"
font_size: dp(18)
markup: True
MDBoxLayout:
id: output_devices
orientation: "vertical"
spacing: "12dp"
size_hint_y: None
height: self.minimum_height
padding: [dp(0), dp(24), dp(0), dp(48)]
# MDRectangleFlatIconButton:
# id: output_default_button
# text: "System Default"
# padding: [dp(0), dp(14), dp(0), dp(14)]
# icon_size: dp(24)
# font_size: dp(16)
# size_hint: [1.0, None]
# on_release: root.delegate.output_device_action(self)
# disabled: False
MDLabel:
text: "[b]Input[/b]"
font_size: dp(18)
markup: True
MDBoxLayout:
id: input_devices
orientation: "vertical"
spacing: "12dp"
size_hint_y: None
height: self.minimum_height
padding: [dp(0), dp(24), dp(0), dp(48)]
MDLabel:
text: "[b]Ringer[/b]"
font_size: dp(18)
markup: True
MDBoxLayout:
id: ringer_devices
orientation: "vertical"
spacing: "12dp"
size_hint_y: None
height: self.minimum_height
padding: [dp(0), dp(24), dp(0), dp(48)]
"""