Add initial support for user avatar thumbnails
This commit is contained in:
parent
62ec4a9ae8
commit
2ced310ce1
|
@ -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()
|
||||||
|
|
123
src/python/image_provider.py
Normal file
123
src/python/image_provider.py
Normal 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()
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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: [
|
||||||
|
|
Loading…
Reference in New Issue
Block a user