Skip to content
64 changes: 63 additions & 1 deletion GUI/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ def _build_ui(self):
)
self.settingsPage.closed.connect(self.hideSettings)
self.settingsPage.theme_changed.connect(self._on_theme_changed)
self.settingsPage.artwork_appearance_changed.connect(
self._on_artwork_appearance_changed
)
self.centralStack.addWidget(self.settingsPage) # Index 2

# Backup browser page
Expand Down Expand Up @@ -421,6 +424,11 @@ def _on_theme_changed(self):
if settings_scope == "device" and hasattr(self.settingsPage, "set_settings_scope"):
self.settingsPage.set_settings_scope("device")

def _on_artwork_appearance_changed(self):
"""Refresh visible artwork after UI-only artwork settings change."""
self.musicBrowser.refresh_artwork_appearance()
self.selectiveSyncBrowser.refresh_artwork_appearance()

def selectDevice(self):
"""Open device picker dialog to scan and select an iPod."""
from GUI.widgets.devicePicker import DevicePickerDialog
Expand All @@ -443,6 +451,7 @@ def selectDevice(self):
return
folder = selected_ipod.path or folder

self._ensure_ipod_drive_tag(selected_ipod, folder)
device_manager.discovered_ipod = selected_ipod
device_manager.device_path = folder
# Persist selection
Expand Down Expand Up @@ -472,7 +481,15 @@ def onDeviceChanged(self, path: str):
thread_pool.clear()

from .imgMaker import clear_artwork_api
from .widgets.MBGridViewItem import clear_pixmap_cache
clear_artwork_api()
clear_pixmap_cache()

if path and self.device_manager.discovered_ipod is not None:
self._ensure_ipod_drive_tag(
self.device_manager.discovered_ipod,
path,
)

if self._apply_effective_theme():
self._schedule_themed_rebuild(restore_page=0)
Expand Down Expand Up @@ -517,6 +534,39 @@ def onDeviceSettingsFailed(self, path: str, error: str):
if getattr(self.settingsPage, "_settings_scope", "global") == "device":
self.settingsPage.load_from_settings()

def _get_ipod_hdd_tag(self) -> bool:
from infrastructure.device_tags import get_ipod_hdd_tag
device = self.device_manager.discovered_ipod
ipod_root = self.device_manager.device_path or ""
value = get_ipod_hdd_tag(device, ipod_root)
return bool(value) if value is not None else False

def _set_ipod_hdd_tag(self, ipod_hdd: bool) -> None:
from infrastructure.device_tags import set_ipod_hdd_tag
device = self.device_manager.discovered_ipod
if device is None:
return
ipod_root = self.device_manager.device_path or ""
set_ipod_hdd_tag(device, ipod_root, ipod_hdd)

def _ensure_ipod_drive_tag(self, device_info, ipod_root: str) -> None:
from infrastructure.device_tags import get_ipod_hdd_tag, set_ipod_hdd_tag
if get_ipod_hdd_tag(device_info, ipod_root) is not None:
return
msg = QMessageBox(self)
msg.setWindowTitle("iPod Storage Type")
msg.setText(
"Does this iPod have solid state storage (SSD / microSD / flash) "
"or the original hard drive (HDD)?\n\n"
"If you are not sure, select HDD."
)
btn_ssd = msg.addButton("SSD / Flash", QMessageBox.ButtonRole.YesRole)
btn_hdd = msg.addButton("HDD (Original)", QMessageBox.ButtonRole.NoRole)
msg.setDefaultButton(btn_hdd)
msg.exec()
is_ssd = msg.clickedButton() is btn_ssd
set_ipod_hdd_tag(device_info, ipod_root, not is_ssd)

def resyncDevice(self):
"""Rebuild the cache from the current device."""
device = self.device_manager
Expand Down Expand Up @@ -755,7 +805,9 @@ def _settle_background_device_reads_for_eject(self) -> bool:

try:
from .imgMaker import clear_artwork_api
from .widgets.MBGridViewItem import clear_pixmap_cache
clear_artwork_api()
clear_pixmap_cache()
except Exception:
logger.debug("Failed to clear artwork cache before eject", exc_info=True)

Expand Down Expand Up @@ -982,6 +1034,7 @@ def startPCSync(self):
pc_folder=self._last_pc_folder,
ipod_tracks=ipod_tracks,
ipod_path=device_manager.device_path or "",
ipod_hdd=self._get_ipod_hdd_tag(),
),
artwork_provider=self._create_back_sync_artwork_provider(
device_manager.device_path or "",
Expand Down Expand Up @@ -1041,6 +1094,7 @@ def startPCSync(self):
"fit_photo_thumbnails": settings.fit_photo_thumbnails,
},
transcode_options=build_transcode_options(settings),
ipod_hdd=self._get_ipod_hdd_tag(),
)
)
self._sync_worker.progress.connect(self.syncReview.update_progress)
Expand Down Expand Up @@ -1213,9 +1267,13 @@ def _onSyncError(self, error_msg: str):
self.syncReview.show_error(error_msg)

def _onBackSyncComplete(self, result: dict):
"""Called when Back Sync export completes."""
"""Called when Back Sync export completes or is cancelled."""
self._back_sync_worker = None

if result.get("cancelled"):
self.hideSyncReview()
return

exported = int(result.get("exported", 0) or 0)
missing = int(result.get("missing_on_pc", 0) or 0)
self.syncReview.show_back_sync_result(result)
Expand Down Expand Up @@ -1278,6 +1336,7 @@ def _onSelectiveSyncDone(self, folder: str, selected_paths):
},
transcode_options=build_transcode_options(settings),
allowed_paths=frozenset(selected_track_paths),
ipod_hdd=self._get_ipod_hdd_tag(),
)
)
self._sync_worker.progress.connect(self.syncReview.update_progress)
Expand Down Expand Up @@ -1430,6 +1489,7 @@ def _on_sync_complete():
user_playlists=user_playlists,
device_info=device_session.identity,
on_sync_complete=_on_sync_complete,
ipod_hdd=self._get_ipod_hdd_tag(),
)
self._sync_execute_worker.progress.connect(self.syncReview.update_execute_progress)
self._sync_execute_worker.finished.connect(self._onSyncExecuteComplete)
Expand Down Expand Up @@ -1487,7 +1547,9 @@ def _rescanAfterSync(self):

# Clear artwork cache — sync may have added/changed album art
from .imgMaker import clear_artwork_api
from .widgets.MBGridViewItem import clear_pixmap_cache
clear_artwork_api()
clear_pixmap_cache()

# Clear UI so the reload starts from a clean slate
self.musicBrowser.reloadData()
Expand Down
100 changes: 100 additions & 0 deletions GUI/artwork_rendering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Helpers for UI-only artwork presentation effects."""

from __future__ import annotations

from PIL import Image, ImageEnhance, ImageFilter
from PyQt6.QtCore import QRectF, Qt
from PyQt6.QtGui import QPainter, QPainterPath, QPixmap


def nested_artwork_radius(
surface_radius: int,
inset: int,
*,
min_radius: int = 4,
) -> int:
"""Return an artwork radius that visually echoes its containing surface."""

radius = int(round(float(surface_radius) - (max(0, inset) * 0.4)))
return max(min_radius, min(surface_radius, radius))


def rounded_artwork_pixmap(pixmap: QPixmap, radius: int) -> QPixmap:
"""Return a copy of *pixmap* clipped to a rounded rectangle."""

if pixmap.isNull() or radius <= 0:
return pixmap

target = QPixmap(pixmap.size())
target.setDevicePixelRatio(pixmap.devicePixelRatio())
target.fill(Qt.GlobalColor.transparent)

dpr = target.devicePixelRatio()
rect = QRectF(
0.0,
0.0,
target.width() / dpr,
target.height() / dpr,
)

path = QPainterPath()
path.addRoundedRect(rect, float(radius), float(radius))

painter = QPainter(target)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
painter.setClipPath(path)
painter.drawPixmap(0, 0, pixmap)
painter.end()
return target


def enhance_artwork_image(
image: Image.Image,
*,
enabled: bool = True,
) -> Image.Image:
"""Apply UI-only post-processing to decoded artwork."""

if not enabled:
return image

width, height = image.size
min_dim = min(width, height)

if min_dim <= 0:
return image

sharpen_percent = 105
contrast_factor = 1.03
color_factor = 1.02

if min_dim <= 80:
sharpen_percent = 120
contrast_factor = 1.05
color_factor = 1.03
elif min_dim <= 140:
sharpen_percent = 112
contrast_factor = 1.04
color_factor = 1.025

enhanced = image.filter(
ImageFilter.UnsharpMask(radius=0.8, percent=sharpen_percent, threshold=3)
)
enhanced = ImageEnhance.Contrast(enhanced).enhance(contrast_factor)
enhanced = ImageEnhance.Color(enhanced).enhance(color_factor)
return enhanced


def virtual_artwork_payload(
image: Image.Image,
*,
sharpen: bool = True,
) -> tuple[Image.Image, tuple[int, int, int], dict[str, tuple[int, int, int]]]:
"""Return the UI-only enhanced image plus derived display colors."""

from .imgMaker import get_artwork_colors

enhanced = enhance_artwork_image(image, enabled=sharpen)
dominant_color, album_colors = get_artwork_colors(enhanced)
return enhanced, dominant_color, album_colors
35 changes: 3 additions & 32 deletions GUI/imgMaker.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from typing import Any, Literal, overload

import numpy as np
from PIL import Image, ImageEnhance, ImageFilter
from PIL import Image

from ArtworkDB_Writer.ithmb_codecs import decode_pixels_for_format

Expand Down Expand Up @@ -251,35 +251,6 @@ def read_rgb565_pixels(img_data, fmt):
return pixels


def _enhance_decoded_artwork(img_pil):
"""Apply mild post-processing to small decoded ithmb artwork."""
width, height = img_pil.size
min_dim = min(width, height)

if min_dim <= 0:
return img_pil

sharpen_percent = 105
contrast_factor = 1.03
color_factor = 1.02

if min_dim <= 80:
sharpen_percent = 120
contrast_factor = 1.05
color_factor = 1.03
elif min_dim <= 140:
sharpen_percent = 112
contrast_factor = 1.04
color_factor = 1.025

enhanced = img_pil.filter(
ImageFilter.UnsharpMask(radius=0.8, percent=sharpen_percent, threshold=3)
)
enhanced = ImageEnhance.Contrast(enhanced).enhance(contrast_factor)
enhanced = ImageEnhance.Color(enhanced).enhance(color_factor)
return enhanced


def generate_image(ithmb_filename, image_info):
"""Generate image from the ithmb file based on image_info."""
try:
Expand Down Expand Up @@ -413,7 +384,7 @@ def generate_image(ithmb_filename, image_info):
if img_pil.size != (target_width, target_height):
img_pil = img_pil.resize(
(target_width, target_height), Image.Resampling.LANCZOS)
return _enhance_decoded_artwork(img_pil)
return img_pil

# Non-RGB565 formats (UYVY, I420, RGB555 variants, JPEG) go through
# the shared format-aware decoder.
Expand All @@ -431,7 +402,7 @@ def generate_image(ithmb_filename, image_info):
return None
if decoded.size != (target_width, target_height):
decoded = decoded.resize((target_width, target_height), Image.Resampling.LANCZOS)
return _enhance_decoded_artwork(decoded)
return decoded

logger.warning("Unsupported image format: %s", fmt)
return None
Expand Down
Loading