This module allows changing the fallback sink and moving active sink inputs with itmaster
parent
abe6990abf
commit
a16146ef43
@ -0,0 +1,161 @@ |
|||||||
|
from collections import OrderedDict |
||||||
|
|
||||||
|
import pulsectl |
||||||
|
import logging |
||||||
|
from ..cycle import OrderedDictCycleAction |
||||||
|
from .naming_map import description |
||||||
|
from .sink_filter import all as sink_filter_all |
||||||
|
from .sink_input_filter import all as sink_input_filter_all |
||||||
|
from functools import partial |
||||||
|
|
||||||
|
logger = logging.getLogger(__name__) |
||||||
|
|
||||||
|
__all__ = ['PulseCtlDefaultSinkCycleAction'] |
||||||
|
|
||||||
|
|
||||||
|
class PulseProxy: |
||||||
|
class PulseServerInfo: |
||||||
|
def __init__(self, realobj, proxy): |
||||||
|
self.__realobj = realobj |
||||||
|
self.__proxy = proxy |
||||||
|
|
||||||
|
@property |
||||||
|
def default_sink_name(self): |
||||||
|
if self.__proxy._fake_default_sink_name is None: |
||||||
|
return self.__realobj.default_sink_name |
||||||
|
elif self.__realobj.default_sink_name == self.__proxy._real_default_sink_name: |
||||||
|
return self.__proxy._fake_default_sink_name |
||||||
|
else: |
||||||
|
return self.__realobj.default_sink_name |
||||||
|
|
||||||
|
def __init__(self, name, sink_filter: callable, sink_input_filter: callable): |
||||||
|
self.__pulse = pulsectl.Pulse(name) |
||||||
|
self.__sink_filter = partial(sink_filter, pulse=self) |
||||||
|
self.__sink_input_filter = partial(sink_input_filter, pulse=self) |
||||||
|
self._fake_default_sink_name = None |
||||||
|
self._real_default_sink_name = None |
||||||
|
|
||||||
|
def server_info(self): |
||||||
|
return self.PulseServerInfo(self.__pulse.server_info(), self) |
||||||
|
|
||||||
|
@property |
||||||
|
def __real_default_sink(self): |
||||||
|
default_sink_name = self.__pulse.server_info().default_sink_name |
||||||
|
return self.__find_sink_by(lambda sink: sink.name == default_sink_name) |
||||||
|
|
||||||
|
def sink_default_set(self, value: pulsectl.PulseSinkInfo): |
||||||
|
real_default_sink = self.__real_default_sink |
||||||
|
if not self.__sink_filter(real_default_sink): # Current default sink is filtered out |
||||||
|
self._fake_default_sink_name = value.name # Fake setting of default sink |
||||||
|
self._real_default_sink_name = real_default_sink.name # Save real default sink |
||||||
|
else: # Current default sink is not filtered out |
||||||
|
self.__pulse.sink_default_set(value) |
||||||
|
self._fake_default_sink_name = None |
||||||
|
self._real_default_sink_name = None |
||||||
|
|
||||||
|
def sink_list(self): |
||||||
|
return list(filter(self.__sink_filter, self.__pulse.sink_list())) |
||||||
|
|
||||||
|
def sink_info(self, *a, **k): |
||||||
|
return self.__pulse.sink_info(*a, **k) |
||||||
|
|
||||||
|
def sink_input_list(self): |
||||||
|
return list(filter(self.__sink_input_filter, self.__pulse.sink_input_list())) |
||||||
|
|
||||||
|
def sink_input_move(self, *a, **k): |
||||||
|
return self.__pulse.sink_input_move(*a, **k) |
||||||
|
|
||||||
|
def __find_sink_by(self, filter_: callable): |
||||||
|
return next(filter(filter_, self.__pulse.sink_list())) |
||||||
|
|
||||||
|
|
||||||
|
class PulseCtlDefaultSinkCycleAction(OrderedDictCycleAction): |
||||||
|
""" |
||||||
|
A cycle action that allows to select the pulseaudio fallback sink, |
||||||
|
and also moves active sink inputs to that sink. |
||||||
|
""" |
||||||
|
def __init__(self, naming_map: callable = description, sink_filter: callable = sink_filter_all, |
||||||
|
sink_input_filter: callable = sink_input_filter_all): |
||||||
|
""" |
||||||
|
:param naming_map: A function that maps a sink object to a visual representation. Must accept an arbitrary number of keyword arguments |
||||||
|
:param sink_filter: A filter that is applied on all sinks to select the ones that should be shown. |
||||||
|
Must accept an arbitrary number of keyword arguments |
||||||
|
The fallback sink will only be changed when the current fallback sink is not filtered out. |
||||||
|
:param sink_input_filter: A filter that is applied on all sink inputs to select the ones that should be moved to the new fallback sink. |
||||||
|
Must accept an arbitrary number of keyword arguments |
||||||
|
Sink inputs that do not match the filter are never moved |
||||||
|
""" |
||||||
|
self.__od = OrderedDict() |
||||||
|
super().__init__(self.__od) |
||||||
|
self.__pulse = PulseProxy(self.__class__.__name__, sink_filter=sink_filter, |
||||||
|
sink_input_filter=partial(sink_input_filter, sink_filter=sink_filter)) |
||||||
|
self.__naming_func = partial(naming_map, pulse=self.__pulse) |
||||||
|
self.__update_items() |
||||||
|
self.current = self.__pulse.server_info().default_sink_name |
||||||
|
|
||||||
|
@OrderedDictCycleAction.current.setter |
||||||
|
def current(self, value): |
||||||
|
# if self.current == value: |
||||||
|
# return |
||||||
|
if value not in self.__od: |
||||||
|
return |
||||||
|
|
||||||
|
while self.current != value: |
||||||
|
super().next() |
||||||
|
|
||||||
|
self.__update_default_sink() |
||||||
|
|
||||||
|
def __update_default_sink(self): |
||||||
|
default_sink = self.__od[self.current] |
||||||
|
|
||||||
|
self.__pulse.sink_default_set(default_sink) |
||||||
|
logger.debug('%s.__update_default_sink: Set default sink to %r', self.__class__.__name__, default_sink) |
||||||
|
for sink_input in self.__pulse.sink_input_list(): |
||||||
|
try: |
||||||
|
self.__pulse.sink_input_move(sink_input.index, default_sink.index) |
||||||
|
logger.debug('%s.__move_sink_inputs: Moved sink input %r to sink %r', self.__class__.__name__, |
||||||
|
sink_input, default_sink) |
||||||
|
except pulsectl.PulseOperationFailed: |
||||||
|
logger.exception('Failed moving sink input %d to sink %d', sink_input.index, default_sink.index) |
||||||
|
|
||||||
|
def __update_items(self): |
||||||
|
changed = False |
||||||
|
sinks = self.__pulse.sink_list() |
||||||
|
for sink in sinks: |
||||||
|
if sink.name not in self.__od: |
||||||
|
logger.debug('%s.__update_items: Added sink %r', self.__class__.__name__, sink) |
||||||
|
changed = True |
||||||
|
self.__od[sink.name] = sink |
||||||
|
to_delete = [] |
||||||
|
for k, sink in self.__od.items(): |
||||||
|
if sink not in sinks: |
||||||
|
logger.debug('%s.__update_items: Removed sink %r', self.__class__.__name__, sink) |
||||||
|
to_delete.append(k) |
||||||
|
changed = True |
||||||
|
for k in to_delete: |
||||||
|
del self.__od[k] |
||||||
|
|
||||||
|
default_name = self.__pulse.server_info().default_sink_name |
||||||
|
if self.current != default_name: |
||||||
|
self.current = default_name |
||||||
|
changed = True |
||||||
|
return changed |
||||||
|
|
||||||
|
def periodic(self): |
||||||
|
return self.__update_items() |
||||||
|
|
||||||
|
@property |
||||||
|
def items(self): |
||||||
|
self.__update_items() |
||||||
|
return map(self.__naming_func, super().items()) |
||||||
|
|
||||||
|
def prev(self): |
||||||
|
super().prev() |
||||||
|
self.__update_default_sink() |
||||||
|
|
||||||
|
def next(self): |
||||||
|
super().next() |
||||||
|
self.__update_default_sink() |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return self.__naming_func(self.__od[self.current]) |
@ -0,0 +1,65 @@ |
|||||||
|
import pulsectl |
||||||
|
from functools import partial, reduce |
||||||
|
|
||||||
|
|
||||||
|
def description(sink: pulsectl.PulseSinkInfo, **k) -> str: |
||||||
|
""" |
||||||
|
Uses the description property as naming |
||||||
|
""" |
||||||
|
return sink.description |
||||||
|
|
||||||
|
|
||||||
|
def n_chars(n: int, s) -> str: |
||||||
|
""" |
||||||
|
Takes the first n characters |
||||||
|
""" |
||||||
|
return s[0:n] |
||||||
|
|
||||||
|
|
||||||
|
first_char = partial(n_chars, 1) |
||||||
|
|
||||||
|
|
||||||
|
def first_word(s: str) -> str: |
||||||
|
""" |
||||||
|
Takes the first word |
||||||
|
""" |
||||||
|
return s.split(' ')[0] |
||||||
|
|
||||||
|
|
||||||
|
def drop_first_n_words(n: int, s: str) -> str: |
||||||
|
""" |
||||||
|
Removes the first word |
||||||
|
""" |
||||||
|
return ' '.join(s.split(' ')[n:]) |
||||||
|
|
||||||
|
|
||||||
|
drop_first_word = partial(drop_first_n_words, 1) |
||||||
|
|
||||||
|
|
||||||
|
def drop_first_word_if_eq(drop_if: str, s: str) -> str: |
||||||
|
return drop_first_word(s) if drop_if == first_word(s) else s |
||||||
|
|
||||||
|
|
||||||
|
def apply(*args: [callable], **k): |
||||||
|
fns = args[:-1] |
||||||
|
data = args[-1] |
||||||
|
for fn in fns: |
||||||
|
data = fn(data, **k) |
||||||
|
k = {} |
||||||
|
return data |
||||||
|
|
||||||
|
|
||||||
|
def drop_first_if_eq(drop_if: str, s: str) -> str: |
||||||
|
return s[len(drop_if):] if s[0:len(drop_if)] == drop_if else s |
||||||
|
|
||||||
|
|
||||||
|
def apply(x, y, **k): |
||||||
|
return y(x, **k) |
||||||
|
|
||||||
|
|
||||||
|
def foldr(fns, data, **kwargs): |
||||||
|
return reduce(partial(apply, **kwargs), fns, data) |
||||||
|
|
||||||
|
|
||||||
|
def drop_kwargs(x, *a, **k): |
||||||
|
return x(*a) |
@ -0,0 +1,24 @@ |
|||||||
|
import pulsectl |
||||||
|
|
||||||
|
__all__ = ['hardware_only', 'virtual_only', 'all'] |
||||||
|
|
||||||
|
|
||||||
|
def all(sink: pulsectl.PulseSinkInfo, **k) -> bool: |
||||||
|
""" |
||||||
|
Selects all output devices |
||||||
|
""" |
||||||
|
return True |
||||||
|
|
||||||
|
|
||||||
|
def hardware_only(sink: pulsectl.PulseSinkInfo, **k) -> bool: |
||||||
|
""" |
||||||
|
Selects hardware output devices |
||||||
|
""" |
||||||
|
return sink.flags & 0x4 == 0x4 |
||||||
|
|
||||||
|
|
||||||
|
def virtual_only(sink: pulsectl.PulseSinkInfo, **k) -> bool: |
||||||
|
""" |
||||||
|
Selects virtual output devices |
||||||
|
""" |
||||||
|
return not hardware_only(sink) |
@ -0,0 +1,20 @@ |
|||||||
|
import pulsectl |
||||||
|
import logging |
||||||
|
|
||||||
|
__all__ = ['all', 'connected_sink'] |
||||||
|
logger = logging.getLogger(__name__) |
||||||
|
|
||||||
|
|
||||||
|
def all(sink_input: pulsectl.PulseSinkInputInfo, **k) -> bool: |
||||||
|
""" |
||||||
|
Selects all output devices |
||||||
|
""" |
||||||
|
return True |
||||||
|
|
||||||
|
|
||||||
|
def connected_sink(sink_input: pulsectl.PulseSinkInputInfo, sink_filter: callable, pulse: pulsectl.Pulse, **k) -> bool: |
||||||
|
""" |
||||||
|
Selects all output devices that are attached to a sink matching a sink filter |
||||||
|
""" |
||||||
|
sink = pulse.sink_info(sink_input.sink) |
||||||
|
return sink_filter(sink) |
@ -0,0 +1,177 @@ |
|||||||
|
import abc |
||||||
|
from collections import OrderedDict |
||||||
|
|
||||||
|
from .core import AbstractControl, WrappingControl, action, Button |
||||||
|
import logging |
||||||
|
|
||||||
|
__all__ = ['AbstractCycleAction', 'OrderedDictCycleAction', 'CycleControl', 'ExpandedCycleControlAction'] |
||||||
|
logger = logging.getLogger(__name__) |
||||||
|
|
||||||
|
|
||||||
|
class AbstractCycleAction(AbstractControl, metaclass=abc.ABCMeta): |
||||||
|
""" |
||||||
|
Base class for all cycle actions |
||||||
|
""" |
||||||
|
|
||||||
|
@abc.abstractmethod |
||||||
|
def next(self): |
||||||
|
""" |
||||||
|
Go to the next item in the cycle |
||||||
|
""" |
||||||
|
pass |
||||||
|
|
||||||
|
@abc.abstractmethod |
||||||
|
def prev(self): |
||||||
|
""" |
||||||
|
Go to the previous item in the cycle |
||||||
|
""" |
||||||
|
pass |
||||||
|
|
||||||
|
@abc.abstractproperty |
||||||
|
def current(self) -> object: |
||||||
|
""" |
||||||
|
:return: An unique identifier for the current item in the cycle |
||||||
|
""" |
||||||
|
return None |
||||||
|
|
||||||
|
@abc.abstractproperty |
||||||
|
def items(self): |
||||||
|
""" |
||||||
|
:return: An iterator over all items in the cycler, as tuples of unique identifier to string representation |
||||||
|
""" |
||||||
|
return iter([]) |
||||||
|
|
||||||
|
def load_state(self, state): |
||||||
|
if 'current' in state: |
||||||
|
prev_current = self.current |
||||||
|
while self.current != state['current']: |
||||||
|
self.next() |
||||||
|
if prev_current == state['current']: |
||||||
|
logger.warning('%s.load_state: Saved item is not present in cycle.', self.__class__.__name__) |
||||||
|
break |
||||||
|
|
||||||
|
def dump_state(self): |
||||||
|
return {'current': self.current} |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
""" |
||||||
|
:return: The visual representation of the current item in the cycle |
||||||
|
""" |
||||||
|
return self.current |
||||||
|
|
||||||
|
|
||||||
|
class OrderedDictCycleAction(AbstractCycleAction): |
||||||
|
""" |
||||||
|
A cycle action that cycles through an ordered dictionary. |
||||||
|
Dictionary keys are used as unique identifiers, dictionary values are their visual represenation |
||||||
|
""" |
||||||
|
def __init__(self, items: OrderedDict = None): |
||||||
|
super().__init__() |
||||||
|
self.__items = items if items is not None else OrderedDict() |
||||||
|
|
||||||
|
def prev(self): |
||||||
|
# prev: a b c -> c a b |
||||||
|
# ^ ^ |
||||||
|
# Move last item to front |
||||||
|
for k in reversed(self.__items.keys()): |
||||||
|
self.__items.move_to_end(k, last=False) |
||||||
|
break |
||||||
|
|
||||||
|
def next(self): |
||||||
|
# next: a b c -> b c a |
||||||
|
# ^ ^ |
||||||
|
# Move first item to back |
||||||
|
for k in self.__items.keys(): |
||||||
|
self.__items.move_to_end(k) |
||||||
|
break |
||||||
|
|
||||||
|
@property |
||||||
|
def items(self): |
||||||
|
return iter(self.__items.items()) |
||||||
|
|
||||||
|
@property |
||||||
|
def current(self): |
||||||
|
for k in self.__items.keys(): |
||||||
|
return k |
||||||
|
|
||||||
|
@property |
||||||
|
def visible(self): |
||||||
|
return len(self.__items) > 1 |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return self.__items[self.current] |
||||||
|
|
||||||
|
|
||||||
|
class CycleControl(WrappingControl): |
||||||
|
""" |
||||||
|
Implements a simple cycle control. |
||||||
|
|
||||||
|
When clicked or scrolled over it changes to the previous/next value in a circular fashion. |
||||||
|
""" |
||||||
|
|
||||||
|
def __init__(self, cycle_action: AbstractCycleAction): |
||||||
|
super().__init__(cycle_action) |
||||||
|
|
||||||
|
def respond_to(self, command: str): |
||||||
|
if command == ':next': |
||||||
|
self.child.next() |
||||||
|
return True |
||||||
|
elif command == ':prev': |
||||||
|
self.child.prev() |
||||||
|
return True |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return action( |
||||||
|
command=self.create_pipe_command(':next'), |
||||||
|
button=Button.LEFT | Button.SCROLL_UP, |
||||||
|
text=action( |
||||||
|
command=self.create_pipe_command(':prev'), |
||||||
|
button=Button.RIGHT | Button.SCROLL_DOWN, |
||||||
|
text=str(self.child) |
||||||
|
) |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
class ExpandedCycleControlAction(WrappingControl, AbstractCycleAction): |
||||||
|
""" |
||||||
|
An expanded cycler that always shows all items in its child cycler, separated by a separator. |
||||||
|
|
||||||
|
Every item is clickable separately and will force a jump to that state. |
||||||
|
""" |
||||||
|
def __init__(self, child_action: AbstractCycleAction, separator: str = ''): |
||||||
|
super().__init__(child_action) |
||||||
|
self.__separator = separator |
||||||
|
|
||||||
|
def next(self): |
||||||
|
self.child.next() |
||||||
|
|
||||||
|
def prev(self): |
||||||
|
self.child.prev() |
||||||
|
|
||||||
|
@property |
||||||
|
def items(self): |
||||||
|
return self.child.items |
||||||
|
|
||||||
|
@property |
||||||
|
def current(self): |
||||||
|
return self.child.current |
||||||
|
|
||||||
|
def __get_control_command(self, item_key): |
||||||
|
return ':set:%s' % item_key |
||||||
|
|
||||||
|
def respond_to(self, command: str): |
||||||
|
for item_key, _ in self.items: |
||||||
|
if command == self.__get_control_command(item_key): |
||||||
|
prev_current = self.current |
||||||
|
while self.current != item_key: |
||||||
|
self.next() |
||||||
|
if self.current == prev_current: |
||||||
|
logger.error('%s.respond_to: Item %s not found in cycle.', self.__class__.__name__, item_key) |
||||||
|
return True |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return self.__separator.join([action( |
||||||
|
command=self.create_pipe_command(self.__get_control_command(item_key)), |
||||||
|
button=Button.LEFT, |
||||||
|
text=item_value |
||||||
|
) for item_key, item_value in self.items]) |
Loading…
Reference in new issue