diff --git a/src/python/app.py b/src/python/app.py index d94b773d..0541333e 100644 --- a/src/python/app.py +++ b/src/python/app.py @@ -7,10 +7,11 @@ from concurrent.futures import Future from pathlib import Path from threading import Thread from typing import Any, Coroutine, Dict, List, Optional, Sequence -from uuid import uuid4 from appdirs import AppDirs +import pyotherside + from . import __about__ from .events.app import CoroutineDone, ExitRequested @@ -22,6 +23,10 @@ class App: from .backend import Backend self.backend = Backend(app=self) + from .image_provider import ImageProvider + self.image_provider = ImageProvider(self) + pyotherside.set_image_provider(self.image_provider.get) + self.loop = asyncio.get_event_loop() self.loop_thread = Thread(target=self._loop_starter) self.loop_thread.start() diff --git a/src/python/image_provider.py b/src/python/image_provider.py new file mode 100644 index 00000000..c3394f1f --- /dev/null +++ b/src/python/image_provider.py @@ -0,0 +1,123 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under LGPLv3. + +import asyncio +import random +import re +from io import BytesIO +from pathlib import Path +from typing import Tuple +from urllib.parse import urlparse + +from atomicfile import AtomicFile +from dataclasses import dataclass, field +from PIL import Image as PILImage + +import nio +import pyotherside +from nio.api import ResizingMethod + +from .app import App + +Size = Tuple[int, int] +ImageData = Tuple[bytearray, Size, int] # last int: pyotherside format enum + + +@dataclass +class Thumbnail: + provider: "ImageProvider" = field() + id: str = field() + width: int = field() + height: int = field() + + def __post_init__(self) -> None: + self.id = re.sub(r"#auto$", "", self.id) + + if not re.match(r"^(crop|scale)/mxc://.+/.+", self.id): + raise ValueError(f"Invalid image ID: {self.id}") + + + @property + def resize_method(self) -> ResizingMethod: + return ResizingMethod.crop \ + if self.id.startswith("crop/") else ResizingMethod.scale + + + @property + def mxc(self) -> str: + return re.sub(r"^(crop|scale)/", "", self.id) + + + @property + def http(self) -> str: + return nio.Api.mxc_to_http(self.mxc) + + + @property + def local_path(self) -> Path: + parsed = urlparse(self.mxc) + name = "%s.%d.%d.%s" % ( + parsed.path.lstrip("/"), + self.width, + self.height, + self.resize_method.value, + ) + return self.provider.cache / parsed.netloc / name + + + async def download(self) -> bytes: + client = random.choice( + tuple(self.provider.app.backend.clients.values()) + ) + parsed = urlparse(self.mxc) + + response = await client.thumbnail( + server_name = parsed.netloc, + media_id = parsed.path.lstrip("/"), + width = self.width, + height = self.height, + method = self.resize_method, + ) + body = response.body + + if response.content_type not in ("image/jpeg", "image/png"): + with BytesIO(body) as in_, BytesIO() as out: + PILImage.open(in_).save(out, "PNG") + body = out.getvalue() + + self.local_path.parent.mkdir(parents=True, exist_ok=True) + + with AtomicFile(str(self.local_path), "wb") as file: + file.write(body) + + return body + + + async def get_data(self) -> ImageData: + try: + body = self.local_path.read_bytes() + except FileNotFoundError: + body = await self.download() + + size = (self.width, self.height) + return (bytearray(body), size , pyotherside.format_data) + + +class ImageProvider: + def __init__(self, app) -> None: + self.app = app + + self.cache = Path(self.app.appdirs.user_cache_dir) / "thumbnails" + self.cache.mkdir(parents=True, exist_ok=True) + + + def get(self, image_id: str, requested_size: Size) -> ImageData: + print("Get image:", image_id, "with size", requested_size) + + width = 128 if requested_size[0] < 1 else requested_size[0] + height = width if requested_size[1] < 1 else requested_size[1] + thumb = Thumbnail(self, image_id, width, height) + + return asyncio.run_coroutine_threadsafe( + thumb.get_data(), self.app.loop + ).result() diff --git a/src/qml/Base/HAvatar.qml b/src/qml/Base/HAvatar.qml index c8938909..a29cfb7e 100644 --- a/src/qml/Base/HAvatar.qml +++ b/src/qml/Base/HAvatar.qml @@ -11,6 +11,8 @@ Rectangle { property int dimension: theme.avatar.size property bool hidden: false + onImageUrlChanged: if (imageUrl) { avatarImage.source = imageUrl } + width: dimension height: hidden ? 1 : dimension implicitWidth: dimension @@ -23,7 +25,7 @@ Rectangle { HLabel { z: 1 anchors.centerIn: parent - visible: ! hidden + visible: ! hidden && ! imageUrl text: name ? name.charAt(0) : "?" color: theme.avatar.letter @@ -32,12 +34,12 @@ Rectangle { HImage { z: 2 + id: avatarImage anchors.fill: parent - //visible: ! hidden && imageUrl - visible: false - - //Component.onCompleted: if (imageUrl) { source = imageUrl } + visible: ! hidden && imageUrl fillMode: Image.PreserveAspectCrop + sourceSize.width: dimension + sourceSize.height: dimension } } diff --git a/src/qml/Base/HUserAvatar.qml b/src/qml/Base/HUserAvatar.qml index 18fa3cd8..08fc0ea8 100644 --- a/src/qml/Base/HUserAvatar.qml +++ b/src/qml/Base/HUserAvatar.qml @@ -7,8 +7,14 @@ HAvatar { property string userId: "" readonly property var userInfo: userId ? users.find(userId) : ({}) - name: userInfo.displayName || userId.substring(1) // no leading @ - imageUrl: userInfo.avatarUrl + + name: + userInfo.displayName || userId.substring(1) // no leading @ + + imageUrl: + userInfo.avatarUrl ? + ("image://python/crop/" + userInfo.avatarUrl) : + null //HImage { //id: status diff --git a/src/qml/Chat/Banners/LeftBanner.qml b/src/qml/Chat/Banners/LeftBanner.qml index cb4743b3..a50f9979 100644 --- a/src/qml/Chat/Banners/LeftBanner.qml +++ b/src/qml/Chat/Banners/LeftBanner.qml @@ -12,7 +12,6 @@ Banner { // TODO: avatar func auto avatar.userId: userId - avatar.imageUrl: userInfo ? userInfo.avatarUrl : null labelText: qsTr("You are not part of this room anymore.") buttonModel: [