Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/widgets/(Widget)-Media.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ media:
- `toggle_label`: Toggles the visibility of the label.
- `toggle_play_pause`: Toggles between play and pause states.
- `toggle_media_menu`: Toggles the visibility of the media menu popup.
- `open_media_source`: Opens the the source that is playing the media.
- `do_nothing`: A placeholder callback that does nothing when triggered.

## Description of Options
Expand Down
94 changes: 89 additions & 5 deletions src/core/utils/win32/aumid.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,25 @@ class PROPVARIANT(ctypes.Structure):

class IPropertyStoreVtbl(ctypes.Structure):
_fields_ = [
("QueryInterface", WINFUNCTYPE(ctypes.c_long, c_void_p, POINTER(GUID), POINTER(c_void_p))),
(
"QueryInterface",
WINFUNCTYPE(ctypes.c_long, c_void_p, POINTER(GUID), POINTER(c_void_p)),
),
("AddRef", WINFUNCTYPE(ctypes.c_ulong, c_void_p)),
("Release", WINFUNCTYPE(ctypes.c_ulong, c_void_p)),
("GetCount", WINFUNCTYPE(ctypes.c_long, c_void_p, POINTER(ctypes.c_uint))),
("GetAt", WINFUNCTYPE(ctypes.c_long, c_void_p, ctypes.c_uint, POINTER(PROPERTYKEY))),
("GetValue", WINFUNCTYPE(ctypes.c_long, c_void_p, POINTER(PROPERTYKEY), POINTER(PROPVARIANT))),
("SetValue", WINFUNCTYPE(ctypes.c_long, c_void_p, POINTER(PROPERTYKEY), POINTER(PROPVARIANT))),
(
"GetAt",
WINFUNCTYPE(ctypes.c_long, c_void_p, ctypes.c_uint, POINTER(PROPERTYKEY)),
),
(
"GetValue",
WINFUNCTYPE(ctypes.c_long, c_void_p, POINTER(PROPERTYKEY), POINTER(PROPVARIANT)),
),
(
"SetValue",
WINFUNCTYPE(ctypes.c_long, c_void_p, POINTER(PROPERTYKEY), POINTER(PROPVARIANT)),
),
("Commit", WINFUNCTYPE(ctypes.c_long, c_void_p)),
]

Expand All @@ -92,7 +104,13 @@ class IPropertyStore(ctypes.Structure):

# SHGetPropertyStoreFromParsingName - to read properties from files (shortcuts)
SHGetPropertyStoreFromParsingName = shell32.SHGetPropertyStoreFromParsingName
SHGetPropertyStoreFromParsingName.argtypes = [wt.LPCWSTR, c_void_p, ctypes.c_uint32, POINTER(GUID), POINTER(c_void_p)]
SHGetPropertyStoreFromParsingName.argtypes = [
wt.LPCWSTR,
c_void_p,
ctypes.c_uint32,
POINTER(GUID),
POINTER(c_void_p),
]
SHGetPropertyStoreFromParsingName.restype = ctypes.c_long

# PropVariantClear is exported by Ole32.dll
Expand Down Expand Up @@ -234,5 +252,71 @@ def get_aumid_from_shortcut(shortcut_path: str) -> str | None:
store.contents.lpVtbl.contents.Release(store)
except Exception:
pass
return aumid

return aumid


def activate_app_by_aumid(aumid: str, fallback_process_name: str | None = None) -> bool:
"""
Find and activate validity window for the given AUMID.

Args:
aumid: The App User Model ID to find.
fallback_process_name: Optional process name (e.g. "firefox.exe") to match if AUMID fails.

Returns:
True if a window was found and activation was attempted, False otherwise.
"""
from core.utils.win32.window_actions import force_foreground_focus, restore_window

found_hwnd = None

WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)

def enum_window_callback(hwnd, _):
nonlocal found_hwnd
if user32.IsWindowVisible(hwnd):
# 1. Try exact AUMID match
curr_aumid = get_aumid_for_window(hwnd)
if curr_aumid == aumid:
found_hwnd = hwnd
return False # Stop enumeration

# 2. Fallback: Try process name match if provided
if fallback_process_name:
pid = wt.DWORD(0)
GetWindowThreadProcessId(wt.HWND(hwnd), byref(pid))
if pid.value:
# We need to open the process to get its image name
# PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
hProcess = OpenProcess(0x1000, False, pid.value)
if hProcess:
try:
buf = ctypes.create_unicode_buffer(1024)
size = wt.DWORD(1024)
# QueryFullProcessImageNameW is in kernel32
if hasattr(kernel32, "QueryFullProcessImageNameW"):
if kernel32.QueryFullProcessImageNameW(hProcess, 0, buf, byref(size)):
full_path = buf.value
if full_path.lower().endswith(fallback_process_name.lower()):
found_hwnd = hwnd
return False
finally:
CloseHandle(hProcess)

return True

user32.EnumWindows(WNDENUMPROC(enum_window_callback), 0)

if found_hwnd:
# 1. Force Focus (helps switch workspace)
force_foreground_focus(found_hwnd)

# 2. Restore if minimized (now that it's potentially active/on current workspace)
if user32.IsIconic(found_hwnd):
restore_window(found_hwnd)

return True

return False
44 changes: 38 additions & 6 deletions src/core/widgets/yasb/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@
)
from qasync import asyncSlot # type: ignore

from core.utils.utilities import PopupWidget, ScrollingLabel, add_shadow, refresh_widget_style
from core.utils.utilities import (
PopupWidget,
ScrollingLabel,
add_shadow,
refresh_widget_style,
)
from core.utils.widgets.animation_manager import AnimationManager
from core.utils.widgets.media.aumid_process import get_process_name_for_aumid
from core.utils.widgets.media.media import MediaSession, SessionState, WindowsMedia
Expand All @@ -38,6 +43,7 @@
CloseHandle,
GetApplicationUserModelId,
OpenProcess,
activate_app_by_aumid,
)
from core.validation.widgets.yasb.media import MediaWidgetConfig
from core.widgets.base import BaseWidget
Expand Down Expand Up @@ -188,6 +194,8 @@ def __init__(self, config: MediaWidgetConfig):
self.register_callback("toggle_label", self._toggle_label)
self._label.show()

self.register_callback("open_media_source", self._open_media_source)

self._label_alt.hide()
self._show_alt_label = False

Expand Down Expand Up @@ -245,7 +253,8 @@ def show_menu(self):
self._popup_thumbnail_label.setProperty("class", "thumbnail")
self._popup_thumbnail_label.setContentsMargins(0, 0, 0, 0)
self._popup_thumbnail_label.setFixedSize(
self.config.media_menu.thumbnail_size, self.config.media_menu.thumbnail_size
self.config.media_menu.thumbnail_size,
self.config.media_menu.thumbnail_size,
)
try:
# Use thumbnail if available, otherwise create a default one
Expand Down Expand Up @@ -435,7 +444,10 @@ def show_menu(self):

# Initialize slider position
if self.current_session is not None and self.current_session.duration > 0:
percent = min(1000, int((self.current_session.current_pos / self.current_session.duration) * 1000))
percent = min(
1000,
int((self.current_session.current_pos / self.current_session.duration) * 1000),
)
self._progress_slider.setValue(percent)
else:
self._progress_slider.setValue(0)
Expand Down Expand Up @@ -558,6 +570,18 @@ def _toggle_play_pause(self):
AnimationManager.animate(self, self.config.animation.type, self.config.animation.duration)
_ = self.media.play_pause()

def _open_media_source(self):
if self.config.animation.enabled:
AnimationManager.animate(self, self.config.animation.type, self.config.animation.duration)
if self.current_session and self.current_session.app_id:
# Try to get process name from mapping for fallback
fallback_process = None
mapping = get_source_app_mapping(self.current_session.app_id)
if mapping and isinstance(mapping, dict):
fallback_process = mapping.get("process")

activate_app_by_aumid(self.current_session.app_id, fallback_process_name=fallback_process)

def _on_timeline_properties_changed(self):
"""Handle timeline property updates."""
if not self.current_session:
Expand Down Expand Up @@ -588,7 +612,10 @@ def _update_interpolated_position(self):
# Update widget progress bar first (hide progress bar if duration is too long)
if self.current_session.timeline_enabled and (0 < self.current_session.duration < MAX_TIMLINE_DURATION):
self._progress_bar.setHidden(False)
new_pos = min(1000, int((self.current_session.current_pos / self.current_session.duration) * 1000))
new_pos = min(
1000,
int((self.current_session.current_pos / self.current_session.duration) * 1000),
)
self._progress_bar.setValue(new_pos)
else:
self._progress_bar.setHidden(True)
Expand Down Expand Up @@ -880,7 +907,10 @@ def _create_empty_thumbnail(self):

# Draw the note head (circle)
draw.ellipse(
[(head_x - head_radius, head_y - head_radius), (head_x + head_radius, head_y + head_radius)],
[
(head_x - head_radius, head_y - head_radius),
(head_x + head_radius, head_y + head_radius),
],
fill=note_color,
)

Expand Down Expand Up @@ -1369,7 +1399,9 @@ def mousePressEvent(self, ev: QMouseEvent | None):
return
if self.parent_widget.config.animation.enabled:
AnimationManager.animate(
self, self.parent_widget.config.animation.type, self.parent_widget.config.animation.duration
self,
self.parent_widget.config.animation.type,
self.parent_widget.config.animation.duration,
)
self.parent_widget.execute_code(self.data)

Expand Down