Skip to content

Commit 406650f

Browse files
authored
Merge pull request #16 from thomasroodnl/feat/media-event
feat(media): Event-based updating
2 parents 07ebead + 9b4d3e5 commit 406650f

File tree

4 files changed

+250
-108
lines changed

4 files changed

+250
-108
lines changed

src/core/bar_manager.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from core.config import get_stylesheet, get_config
1313
from copy import deepcopy
1414

15+
from core.utils.win32.media import WindowsMedia
16+
1517

1618
class BarManager(QObject):
1719
styles_modified = pyqtSignal()
@@ -92,6 +94,8 @@ def close_bars(self):
9294
for t in tasks:
9395
t.cancel()
9496

97+
WindowsMedia().stop()
98+
9599
for bar in self.bars:
96100
bar.close()
97101

src/core/utils/utilities.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,12 @@ def is_valid_percentage_str(s: str) -> bool:
1515

1616
def get_screen_by_name(screen_name: str) -> QScreen:
1717
return next(filter(lambda scr: screen_name in scr.name(), QApplication.screens()), None)
18+
19+
20+
class Singleton(type):
21+
_instances = {}
22+
23+
def __call__(cls, *args, **kwargs):
24+
if cls not in cls._instances:
25+
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
26+
return cls._instances[cls]

src/core/utils/win32/media.py

Lines changed: 176 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
1-
# media.py
21
import ctypes
32
import logging
4-
from typing import Dict, Union, Optional
3+
from typing import Any, Callable
54

6-
import winsdk.windows.media.control
5+
import asyncio
6+
7+
import threading
78
from winsdk.windows.storage.streams import Buffer, InputStreamOptions, IRandomAccessStreamReference
89
from PIL import Image, ImageFile
910
import io
1011

12+
from core.utils.utilities import Singleton
1113
from core.utils.win32.system_function import KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP
1214

15+
from winsdk.windows.media.control import (GlobalSystemMediaTransportControlsSessionManager as SessionManager,
16+
GlobalSystemMediaTransportControlsSession as Session,
17+
SessionsChangedEventArgs, MediaPropertiesChangedEventArgs,
18+
TimelinePropertiesChangedEventArgs, PlaybackInfoChangedEventArgs)
19+
1320
VK_MEDIA_PLAY_PAUSE = 0xB3
1421
VK_MEDIA_PREV_TRACK = 0xB1
1522
VK_MEDIA_NEXT_TRACK = 0xB0
@@ -19,7 +26,171 @@
1926
pil_logger.setLevel(logging.INFO)
2027

2128

22-
class MediaOperations:
29+
class WindowsMedia(metaclass=Singleton):
30+
"""
31+
Use double thread for media info because I expect subscribers to take some time, and I don't know if holding up the callback from windsdk is a problem.
32+
To also not create and manage too many threads, I made the others direct callbacks
33+
"""
34+
35+
def __init__(self):
36+
self._log = logging.getLogger(__name__)
37+
self._session_manager: SessionManager = None
38+
self._current_session: Session = None
39+
self._current_session_lock = threading.RLock()
40+
41+
self._event_loop = asyncio.new_event_loop()
42+
43+
self._media_info_lock = threading.RLock()
44+
self._media_info = None
45+
46+
self._playback_info_lock = threading.RLock()
47+
self._playback_info = None
48+
49+
self._timeline_info_lock = threading.RLock()
50+
self._timeline_info = None
51+
52+
self._subscription_channels = {channel: [] for channel in ['media_info', 'playback_info', 'timeline_info',
53+
'session_status']}
54+
self._subscription_channels_lock = threading.RLock()
55+
self._registration_tokens = {}
56+
57+
self._run_setup()
58+
59+
def force_update(self):
60+
self._on_current_session_changed(self._session_manager, None)
61+
62+
def subscribe(self, callback: Callable, channel: str):
63+
with self._subscription_channels_lock:
64+
try:
65+
self._subscription_channels[channel].append(callback)
66+
except KeyError:
67+
raise ValueError(f'Incorrect channel subscription type provided ({channel}). '
68+
f'Valid options are {list(self._subscription_channels.keys())}')
69+
70+
def stop(self):
71+
# Clear subscriptions
72+
with self._subscription_channels_lock:
73+
self._subscription_channels = {k: [] for k in self._subscription_channels.keys()}
74+
75+
with self._current_session_lock:
76+
session = self._current_session
77+
78+
# Remove all our subscriptions
79+
if session is not None:
80+
session.remove_media_properties_changed(self._registration_tokens['media_info'])
81+
session.remove_timeline_properties_changed(self._registration_tokens['timeline_info'])
82+
session.remove_playback_info_changed(self._registration_tokens['playback_info'])
83+
84+
def _register_session_callbacks(self):
85+
with self._current_session_lock:
86+
self._registration_tokens['playback_info'] = self._current_session.add_playback_info_changed(self._on_playback_info_changed)
87+
self._registration_tokens['timeline_info'] = self._current_session.add_timeline_properties_changed(self._on_timeline_properties_changed)
88+
self._registration_tokens['media_info'] = self._current_session.add_media_properties_changed(self._on_media_properties_changed)
89+
90+
async def _get_session_manager(self):
91+
return await SessionManager.request_async()
92+
93+
def _run_setup(self):
94+
self._session_manager = asyncio.run(self._get_session_manager())
95+
self._session_manager.add_current_session_changed(self._on_current_session_changed)
96+
97+
# Manually trigger the callback on startup
98+
self._on_current_session_changed(self._session_manager, None, is_setup=True)
99+
100+
def _on_current_session_changed(self, manager: SessionManager, args: SessionsChangedEventArgs, is_setup=False):
101+
self._log.debug('MediaCallback: _on_current_session_changed')
102+
103+
with self._current_session_lock:
104+
self._current_session = manager.get_current_session()
105+
106+
if self._current_session is not None:
107+
108+
# If the current session is not None, register callbacks
109+
self._register_session_callbacks()
110+
111+
if not is_setup:
112+
self._on_playback_info_changed(self._current_session, None)
113+
self._on_timeline_properties_changed(self._current_session, None)
114+
self._on_media_properties_changed(self._current_session, None)
115+
116+
# Get subscribers
117+
with self._subscription_channels_lock:
118+
callbacks = self._subscription_channels['session_status']
119+
120+
for callback in callbacks:
121+
callback(self._current_session is not None)
122+
123+
def _on_playback_info_changed(self, session: Session, args: PlaybackInfoChangedEventArgs):
124+
self._log.info('MediaCallback: _on_playback_info_changed')
125+
with self._playback_info_lock:
126+
self._playback_info = session.get_playback_info()
127+
128+
# Get subscribers
129+
with self._subscription_channels_lock:
130+
callbacks = self._subscription_channels['playback_info']
131+
132+
# Perform callbacks
133+
for callback in callbacks:
134+
callback(self._playback_info)
135+
136+
def _on_timeline_properties_changed(self, session: Session, args: TimelinePropertiesChangedEventArgs):
137+
self._log.info('MediaCallback: _on_timeline_properties_changed')
138+
with self._timeline_info_lock:
139+
self._timeline_info = session.get_timeline_properties()
140+
141+
# Get subscribers
142+
with self._subscription_channels_lock:
143+
callbacks = self._subscription_channels['timeline_info']
144+
145+
# Perform callbacks
146+
for callback in callbacks:
147+
callback(self._timeline_info)
148+
149+
def _on_media_properties_changed(self, session: Session, args: MediaPropertiesChangedEventArgs):
150+
self._log.debug('MediaCallback: _on_media_properties_changed')
151+
try:
152+
asyncio.get_event_loop()
153+
except RuntimeError:
154+
with self._media_info_lock:
155+
self._event_loop.run_until_complete(self._update_media_properties(session))
156+
else:
157+
# Only for the initial timer based update, because it is called from an event loop
158+
asyncio.create_task(self._update_media_properties(session))
159+
160+
async def _update_media_properties(self, session: Session):
161+
self._log.debug('MediaCallback: Attempting media info update')
162+
163+
try:
164+
media_info = await session.try_get_media_properties_async()
165+
166+
media_info = self._properties_2_dict(media_info)
167+
168+
# Skip initial change calls where the thumbnail is None. This prevents processing multiple updates.
169+
# Might prevent showing info for no-thumbnail media
170+
if media_info['thumbnail'] is None:
171+
self._log.debug('MediaCallback: Skipping media info update: no thumbnail')
172+
return
173+
174+
media_info['thumbnail'] = await self.get_thumbnail(media_info['thumbnail'])
175+
except Exception as e:
176+
self._log.error(f'MediaCallback: Error occurred whilst fetching media properties and thumbnail: {e}')
177+
return
178+
179+
self._media_info = media_info
180+
181+
# Get subscribers
182+
with self._subscription_channels_lock:
183+
callbacks = self._subscription_channels['media_info']
184+
185+
# Perform callbacks
186+
for callback in callbacks:
187+
callback(self._media_info)
188+
189+
self._log.debug('MediaCallback: Media info update finished')
190+
191+
@staticmethod
192+
def _properties_2_dict(obj) -> dict[str, Any]:
193+
return {name: getattr(obj, name) for name in dir(obj) if not name.startswith('_')}
23194

24195
@staticmethod
25196
async def get_thumbnail(thumbnail_stream_reference: IRandomAccessStreamReference) -> ImageFile:
@@ -45,51 +216,12 @@ async def get_thumbnail(thumbnail_stream_reference: IRandomAccessStreamReference
45216

46217
return pillow_image
47218
except Exception as e:
48-
logging.error(f'Error occurred when loading the thumbnail: {e}')
219+
logging.error(f'get_thumbnail(): Error occurred when loading the thumbnail: {e}')
49220
return None
50221
finally:
51222
# Close the stream
52223
readable_stream.close()
53224

54-
@staticmethod
55-
async def get_media_properties() -> Optional[Dict[str, Union[str, int, IRandomAccessStreamReference]]]:
56-
"""
57-
Get media properties and the currently running media file
58-
"""
59-
try:
60-
session_manager = await winsdk.windows.media.control.GlobalSystemMediaTransportControlsSessionManager.request_async()
61-
current_session = session_manager.get_current_session()
62-
63-
# If no music is playing, return None
64-
if current_session is None:
65-
return None
66-
67-
media_properties = await current_session.try_get_media_properties_async()
68-
playback_info = current_session.get_playback_info()
69-
timeline_properties = current_session.get_timeline_properties()
70-
media_info = {
71-
"album_artist": media_properties.album_artist,
72-
"album_title": media_properties.album_title,
73-
"album_track_count": media_properties.album_track_count,
74-
"artist": media_properties.artist,
75-
"title": media_properties.title,
76-
"playback_type": str(media_properties.playback_type),
77-
"subtitle": media_properties.subtitle,
78-
"album": media_properties.album_title,
79-
"track_number": media_properties.track_number,
80-
"thumbnail": media_properties.thumbnail,
81-
"playing": playback_info.playback_status == 4,
82-
"prev_available": playback_info.controls.is_previous_enabled,
83-
"next_available": playback_info.controls.is_next_enabled,
84-
"total_time": timeline_properties.end_time.total_seconds(),
85-
"current_time": timeline_properties.position.total_seconds()
86-
# genres
87-
}
88-
return media_info
89-
except Exception as e:
90-
logging.error(f'Error occurred when getting media properties: {e}')
91-
return None
92-
93225
@staticmethod
94226
def play_pause():
95227
user32 = ctypes.windll.user32

0 commit comments

Comments
 (0)