Add initial support for user avatar thumbnails

This commit is contained in:
miruka 2019-07-09 21:46:21 -04:00
parent 62ec4a9ae8
commit 2ced310ce1
5 changed files with 144 additions and 9 deletions

View File

@ -7,10 +7,11 @@ from concurrent.futures import Future
from pathlib import Path from pathlib import Path
from threading import Thread from threading import Thread
from typing import Any, Coroutine, Dict, List, Optional, Sequence from typing import Any, Coroutine, Dict, List, Optional, Sequence
from uuid import uuid4
from appdirs import AppDirs from appdirs import AppDirs
import pyotherside
from . import __about__ from . import __about__
from .events.app import CoroutineDone, ExitRequested from .events.app import CoroutineDone, ExitRequested
@ -22,6 +23,10 @@ class App:
from .backend import Backend from .backend import Backend
self.backend = Backend(app=self) 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 = asyncio.get_event_loop()
self.loop_thread = Thread(target=self._loop_starter) self.loop_thread = Thread(target=self._loop_starter)
self.loop_thread.start() self.loop_thread.start()

View File

@ -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()

View File

@ -11,6 +11,8 @@ Rectangle {
property int dimension: theme.avatar.size property int dimension: theme.avatar.size
property bool hidden: false property bool hidden: false
onImageUrlChanged: if (imageUrl) { avatarImage.source = imageUrl }
width: dimension width: dimension
height: hidden ? 1 : dimension height: hidden ? 1 : dimension
implicitWidth: dimension implicitWidth: dimension
@ -23,7 +25,7 @@ Rectangle {
HLabel { HLabel {
z: 1 z: 1
anchors.centerIn: parent anchors.centerIn: parent
visible: ! hidden visible: ! hidden && ! imageUrl
text: name ? name.charAt(0) : "?" text: name ? name.charAt(0) : "?"
color: theme.avatar.letter color: theme.avatar.letter
@ -32,12 +34,12 @@ Rectangle {
HImage { HImage {
z: 2 z: 2
id: avatarImage
anchors.fill: parent anchors.fill: parent
//visible: ! hidden && imageUrl visible: ! hidden && imageUrl
visible: false
//Component.onCompleted: if (imageUrl) { source = imageUrl }
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
sourceSize.width: dimension sourceSize.width: dimension
sourceSize.height: dimension
} }
} }

View File

@ -7,8 +7,14 @@ HAvatar {
property string userId: "" property string userId: ""
readonly property var userInfo: userId ? users.find(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 { //HImage {
//id: status //id: status

View File

@ -12,7 +12,6 @@ Banner {
// TODO: avatar func auto // TODO: avatar func auto
avatar.userId: userId avatar.userId: userId
avatar.imageUrl: userInfo ? userInfo.avatarUrl : null
labelText: qsTr("You are not part of this room anymore.") labelText: qsTr("You are not part of this room anymore.")
buttonModel: [ buttonModel: [