1
- # media.py
2
1
import ctypes
3
2
import logging
4
- from typing import Dict , Union , Optional
3
+ from typing import Any , Callable
5
4
6
- import winsdk .windows .media .control
5
+ import asyncio
6
+
7
+ import threading
7
8
from winsdk .windows .storage .streams import Buffer , InputStreamOptions , IRandomAccessStreamReference
8
9
from PIL import Image , ImageFile
9
10
import io
10
11
12
+ from core .utils .utilities import Singleton
11
13
from core .utils .win32 .system_function import KEYEVENTF_EXTENDEDKEY , KEYEVENTF_KEYUP
12
14
15
+ from winsdk .windows .media .control import (GlobalSystemMediaTransportControlsSessionManager as SessionManager ,
16
+ GlobalSystemMediaTransportControlsSession as Session ,
17
+ SessionsChangedEventArgs , MediaPropertiesChangedEventArgs ,
18
+ TimelinePropertiesChangedEventArgs , PlaybackInfoChangedEventArgs )
19
+
13
20
VK_MEDIA_PLAY_PAUSE = 0xB3
14
21
VK_MEDIA_PREV_TRACK = 0xB1
15
22
VK_MEDIA_NEXT_TRACK = 0xB0
19
26
pil_logger .setLevel (logging .INFO )
20
27
21
28
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 ('_' )}
23
194
24
195
@staticmethod
25
196
async def get_thumbnail (thumbnail_stream_reference : IRandomAccessStreamReference ) -> ImageFile :
@@ -45,51 +216,12 @@ async def get_thumbnail(thumbnail_stream_reference: IRandomAccessStreamReference
45
216
46
217
return pillow_image
47
218
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 } ' )
49
220
return None
50
221
finally :
51
222
# Close the stream
52
223
readable_stream .close ()
53
224
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
-
93
225
@staticmethod
94
226
def play_pause ():
95
227
user32 = ctypes .windll .user32
0 commit comments