Updated Icon generation and Settings
@ -1,7 +1,6 @@
|
||||
# Python Imports
|
||||
import os, subprocess, hashlib, threading
|
||||
from os.path import isdir, isfile, join
|
||||
|
||||
import os, subprocess, threading, hashlib
|
||||
from os.path import isfile, join
|
||||
|
||||
# Gtk imports
|
||||
import gi
|
||||
@ -24,118 +23,111 @@ def threaded(fn):
|
||||
return wrapper
|
||||
|
||||
class Icon:
|
||||
def __init__(self):
|
||||
self.SCRIPT_PTH = os.path.dirname(os.path.realpath(__file__)) + "/"
|
||||
self.INTERNAL_ICON_PTH = self.SCRIPT_PTH + "./utils/icons/text.png"
|
||||
def create_icon(self, dir, file):
|
||||
full_path = dir + "/" + file
|
||||
return self.get_icon_image(file, full_path)
|
||||
|
||||
|
||||
def createIcon(self, dir, file):
|
||||
fullPath = dir + "/" + file
|
||||
return self.getIconImage(file, fullPath)
|
||||
|
||||
def createThumbnail(self, dir, file):
|
||||
fullPath = dir + "/" + file
|
||||
def create_thumbnail(self, dir, file):
|
||||
full_path = dir + "/" + file
|
||||
try:
|
||||
fileHash = hashlib.sha256(str.encode(fullPath)).hexdigest()
|
||||
hashImgPth = self.get_home() + "/.thumbnails/normal/" + fileHash + ".png"
|
||||
file_hash = hashlib.sha256(str.encode(full_path)).hexdigest()
|
||||
hash_img_pth = ABS_THUMBS_PTH + "/" + file_hash + ".jpg"
|
||||
if isfile(hash_img_pth) == False:
|
||||
self.generate_video_thumbnail(full_path, hash_img_pth)
|
||||
|
||||
thumbnl = self.createScaledImage(hashImgPth, self.viIconWH)
|
||||
thumbnl = self.create_scaled_image(hash_img_pth, self.VIDEO_ICON_WH)
|
||||
if thumbnl == None: # If no icon whatsoever, return internal default
|
||||
thumbnl = Gtk.Image.new_from_file(self.SCRIPT_PTH + "./utils/icons/video.png")
|
||||
thumbnl = Gtk.Image.new_from_file(self.DEFAULT_ICONS + "/video.png")
|
||||
|
||||
return thumbnl
|
||||
except Exception as e:
|
||||
print("Thumbnail generation issue:")
|
||||
print( repr(e) )
|
||||
return Gtk.Image.new_from_file(self.SCRIPT_PTH + "./utils/icons/video.png")
|
||||
return Gtk.Image.new_from_file(self.DEFAULT_ICONS + "/video.png")
|
||||
|
||||
|
||||
def getIconImage(self, file, fullPath):
|
||||
def get_icon_image(self, file, full_path):
|
||||
try:
|
||||
thumbnl = None
|
||||
|
||||
# Video icon
|
||||
if file.lower().endswith(self.fvideos):
|
||||
thumbnl = Gtk.Image.new_from_file(self.SCRIPT_PTH + "./utils/icons/video.png")
|
||||
thumbnl = Gtk.Image.new_from_file(self.DEFAULT_ICONS + "/video.png")
|
||||
# Image Icon
|
||||
elif file.lower().endswith(self.fimages):
|
||||
thumbnl = self.createScaledImage(fullPath, self.viIconWH)
|
||||
thumbnl = self.create_scaled_image(full_path, self.VIDEO_ICON_WH)
|
||||
# .desktop file parsing
|
||||
elif fullPath.lower().endswith( ('.desktop',) ):
|
||||
thumbnl = self.parseDesktopFiles(fullPath)
|
||||
elif full_path.lower().endswith( ('.desktop',) ):
|
||||
thumbnl = self.parse_desktop_files(full_path)
|
||||
# System icons
|
||||
else:
|
||||
thumbnl = self.getSystemThumbnail(fullPath, self.systemIconImageWH[0])
|
||||
thumbnl = self.get_system_thumbnail(full_path, self.SYS_ICON_WH[0])
|
||||
|
||||
if thumbnl == None: # If no icon whatsoever, return internal default
|
||||
thumbnl = Gtk.Image.new_from_file(self.INTERNAL_ICON_PTH)
|
||||
thumbnl = Gtk.Image.new_from_file(self.DEFAULT_ICON)
|
||||
|
||||
return thumbnl
|
||||
except Exception as e:
|
||||
print("Icon generation issue:")
|
||||
print( repr(e) )
|
||||
return Gtk.Image.new_from_file(self.INTERNAL_ICON_PTH)
|
||||
return Gtk.Image.new_from_file(self.DEFAULT_ICON)
|
||||
|
||||
def parseDesktopFiles(self, fullPath):
|
||||
def parse_desktop_files(self, full_path):
|
||||
try:
|
||||
xdgObj = DesktopEntry(fullPath)
|
||||
xdgObj = DesktopEntry(full_path)
|
||||
icon = xdgObj.getIcon()
|
||||
altIconPath = ""
|
||||
alt_icon_path = ""
|
||||
|
||||
if "steam" in icon:
|
||||
steamIconsDir = self.get_home() + "/.thumbnails/steam_icons/"
|
||||
name = xdgObj.getName()
|
||||
fileHash = hashlib.sha256(str.encode(name)).hexdigest()
|
||||
name = xdgObj.getName()
|
||||
file_hash = hashlib.sha256(str.encode(name)).hexdigest()
|
||||
hash_img_pth = self.STEAM_ICONS_PTH + "/" + file_hash + ".jpg"
|
||||
|
||||
if isdir(steamIconsDir) == False:
|
||||
os.mkdir(steamIconsDir)
|
||||
|
||||
hashImgPth = steamIconsDir + fileHash + ".jpg"
|
||||
if isfile(hashImgPth) == True:
|
||||
if isfile(hash_img_pth) == True:
|
||||
# Use video sizes since headers are bigger
|
||||
return self.createScaledImage(hashImgPth, self.viIconWH)
|
||||
return self.create_scaled_image(hash_img_pth, self.VIDEO_ICON_WH)
|
||||
|
||||
execStr = xdgObj.getExec()
|
||||
parts = execStr.split("steam://rungameid/")
|
||||
exec_str = xdgObj.getExec()
|
||||
parts = exec_str.split("steam://rungameid/")
|
||||
id = parts[len(parts) - 1]
|
||||
imageLink = "https://steamcdn-a.akamaihd.net/steam/apps/" + id + "/header.jpg"
|
||||
proc = subprocess.Popen(["wget", "-O", hashImgPth, imageLink])
|
||||
imageLink = self.STEAM_BASE_URL + id + "/header.jpg"
|
||||
proc = subprocess.Popen(["wget", "-O", hash_img_pth, imageLink])
|
||||
proc.wait()
|
||||
|
||||
# Use video thumbnail sizes since headers are bigger
|
||||
return self.createScaledImage(hashImgPth, self.viIconWH)
|
||||
return self.create_scaled_image(hash_img_pth, self.VIDEO_ICON_WH)
|
||||
elif os.path.exists(icon):
|
||||
return self.createScaledImage(icon, self.systemIconImageWH)
|
||||
return self.create_scaled_image(icon, self.SYS_ICON_WH)
|
||||
else:
|
||||
iconsDirs = ["/usr/share/pixmaps", "/usr/share/icons", self.get_home() + "/.icons" ,]
|
||||
altIconPath = ""
|
||||
alt_icon_path = ""
|
||||
|
||||
for iconsDir in iconsDirs:
|
||||
altIconPath = self.traverseIconsFolder(iconsDir, icon)
|
||||
if altIconPath is not "":
|
||||
for dir in self.ICON_DIRS:
|
||||
alt_icon_path = self.traverse_icons_folder(dir, icon)
|
||||
if alt_icon_path is not "":
|
||||
break
|
||||
|
||||
return self.createScaledImage(altIconPath, self.systemIconImageWH)
|
||||
return self.create_scaled_image(alt_icon_path, self.SYS_ICON_WH)
|
||||
except Exception as e:
|
||||
print(self.DEFAULT_ICON)
|
||||
print(".desktop icon generation issue:")
|
||||
print( repr(e) )
|
||||
return None
|
||||
|
||||
|
||||
def traverseIconsFolder(self, path, icon):
|
||||
altIconPath = ""
|
||||
def traverse_icons_folder(self, path, icon):
|
||||
alt_icon_path = ""
|
||||
|
||||
for (dirpath, dirnames, filenames) in os.walk(path):
|
||||
for file in filenames:
|
||||
appNM = "application-x-" + icon
|
||||
if icon in file or appNM in file:
|
||||
altIconPath = dirpath + "/" + file
|
||||
alt_icon_path = dirpath + "/" + file
|
||||
break
|
||||
|
||||
return altIconPath
|
||||
return alt_icon_path
|
||||
|
||||
|
||||
def getSystemThumbnail(self, filename, size):
|
||||
def get_system_thumbnail(self, filename, size):
|
||||
try:
|
||||
if os.path.exists(filename):
|
||||
gioFile = Gio.File.new_for_path(filename)
|
||||
@ -156,17 +148,63 @@ class Icon:
|
||||
return None
|
||||
|
||||
|
||||
def createScaledImage(self, path, wxh):
|
||||
def generate_video_thumbnail(self, full_path, hash_img_pth):
|
||||
try:
|
||||
pixbuf = Gtk.Image.new_from_file(path).get_pixbuf()
|
||||
scaledPixBuf = pixbuf.scale_simple(wxh[0], wxh[1], 2) # 2 = BILINEAR and is best by default
|
||||
return Gtk.Image.new_from_pixbuf(scaledPixBuf)
|
||||
proc = subprocess.Popen([self.FFMPG_THUMBNLR, "-t", "65%", "-s", "300", "-c", "jpg", "-i", full_path, "-o", hash_img_pth])
|
||||
proc.wait()
|
||||
except Exception as e:
|
||||
self.logger.debug(repr(e))
|
||||
self.ffprobe_generate_video_thumbnail(full_path, hash_img_pth)
|
||||
|
||||
|
||||
def ffprobe_generate_video_thumbnail(self, full_path, hash_img_pth):
|
||||
proc = None
|
||||
try:
|
||||
# Stream duration
|
||||
command = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=duration", "-of", "default=noprint_wrappers=1:nokey=1", full_path]
|
||||
data = subprocess.run(command, stdout=subprocess.PIPE)
|
||||
duration = data.stdout.decode('utf-8')
|
||||
|
||||
# Format (container) duration
|
||||
if "N/A" in duration:
|
||||
command = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", full_path]
|
||||
data = subprocess.run(command , stdout=subprocess.PIPE)
|
||||
duration = data.stdout.decode('utf-8')
|
||||
|
||||
# Stream duration type: image2
|
||||
if "N/A" in duration:
|
||||
command = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-f", "image2", "-show_entries", "stream=duration", "-of", "default=noprint_wrappers=1:nokey=1", full_path]
|
||||
data = subprocess.run(command, stdout=subprocess.PIPE)
|
||||
duration = data.stdout.decode('utf-8')
|
||||
|
||||
# Format (container) duration type: image2
|
||||
if "N/A" in duration:
|
||||
command = ["ffprobe", "-v", "error", "-f", "image2", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", full_path]
|
||||
data = subprocess.run(command , stdout=subprocess.PIPE)
|
||||
duration = data.stdout.decode('utf-8')
|
||||
|
||||
# Get frame roughly 35% through video
|
||||
grabTime = str( int( float( duration.split(".")[0] ) * 0.35) )
|
||||
command = ["ffmpeg", "-ss", grabTime, "-an", "-i", full_path, "-s", "320x180", "-vframes", "1", hash_img_pth]
|
||||
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
|
||||
proc.wait()
|
||||
except Exception as e:
|
||||
print("Video thumbnail generation issue in thread:")
|
||||
print( repr(e) )
|
||||
self.logger.debug(repr(e))
|
||||
|
||||
|
||||
def create_scaled_image(self, path, wxh):
|
||||
try:
|
||||
pixbuf = Gtk.Image.new_from_file(path).get_pixbuf()
|
||||
scaled_pixbuf = pixbuf.scale_simple(wxh[0], wxh[1], 2) # 2 = BILINEAR and is best by default
|
||||
return Gtk.Image.new_from_pixbuf(scaled_pixbuf)
|
||||
except Exception as e:
|
||||
print("Image Scaling Issue:")
|
||||
print( repr(e) )
|
||||
return None
|
||||
|
||||
def createFromFile(self, path):
|
||||
def create_from_file(self, path):
|
||||
try:
|
||||
return Gtk.Image.new_from_file(path)
|
||||
except Exception as e:
|
||||
@ -174,5 +212,5 @@ class Icon:
|
||||
print( repr(e) )
|
||||
return None
|
||||
|
||||
def returnGenericIcon(self):
|
||||
return Gtk.Image.new_from_file(self.INTERNAL_ICON_PTH)
|
||||
def return_generic_icon(self):
|
||||
return Gtk.Image.new_from_file(self.DEFAULT_ICON)
|
||||
|
@ -17,6 +17,7 @@ from . import Path, Icon
|
||||
class View(Settings, Launcher, Icon, Path):
|
||||
def __init__(self):
|
||||
self.id = ""
|
||||
self. logger = None
|
||||
self.files = []
|
||||
self.dirs = []
|
||||
self.vids = []
|
||||
@ -157,16 +158,7 @@ class View(Settings, Launcher, Icon, Path):
|
||||
return self.hashSet(self.dirs)
|
||||
|
||||
def get_videos(self):
|
||||
videos_set = self.hashSet(self.vids)
|
||||
current_directory = self.get_current_directory()
|
||||
for video in videos_set:
|
||||
hashImgPth = join(self.ABS_THUMBS_PTH, video[1]) + ".jpg"
|
||||
if not os.path.exists(hashImgPth) :
|
||||
fullPath = join(current_directory, video[0])
|
||||
self.logger.debug(f"Hash Path: {hashImgPth}\nFile Path: {fullPath}")
|
||||
self.generate_video_thumbnail(fullPath, hashImgPth)
|
||||
|
||||
return videos_set
|
||||
return self.hashSet(self.vids)
|
||||
|
||||
def get_images(self):
|
||||
return self.hashSet(self.images)
|
||||
|
@ -9,7 +9,7 @@ import os, subprocess, threading
|
||||
|
||||
|
||||
class Launcher:
|
||||
def openFilelocally(self, file):
|
||||
def open_file_locally(self, file):
|
||||
lowerName = file.lower()
|
||||
command = []
|
||||
|
||||
@ -38,7 +38,7 @@ class Launcher:
|
||||
subprocess.Popen(command, start_new_session=True, stdout=DEVNULL, stderr=DEVNULL, close_fds=True)
|
||||
|
||||
|
||||
def remuxVideo(self, hash, file):
|
||||
def remux_video(self, hash, file):
|
||||
remux_vid_pth = self.REMUX_FOLDER + "/" + hash + ".mp4"
|
||||
self.logger.debug(remux_vid_pth)
|
||||
|
||||
@ -66,53 +66,6 @@ class Launcher:
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def generate_video_thumbnail(self, fullPath, hashImgPth):
|
||||
try:
|
||||
proc = subprocess.Popen([self.FFMPG_THUMBNLR, "-t", "65%", "-s", "300", "-c", "jpg", "-i", fullPath, "-o", hashImgPth])
|
||||
proc.wait()
|
||||
except Exception as e:
|
||||
self.logger.debug(repr(e))
|
||||
self.ffprobe_generate_video_thumbnail(fullPath, hashImgPth)
|
||||
|
||||
|
||||
def generate_video_thumbnail(self, fullPath, hashImgPth):
|
||||
proc = None
|
||||
try:
|
||||
# Stream duration
|
||||
command = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=duration", "-of", "default=noprint_wrappers=1:nokey=1", fullPath]
|
||||
data = subprocess.run(command, stdout=subprocess.PIPE)
|
||||
duration = data.stdout.decode('utf-8')
|
||||
|
||||
# Format (container) duration
|
||||
if "N/A" in duration:
|
||||
command = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", fullPath]
|
||||
data = subprocess.run(command , stdout=subprocess.PIPE)
|
||||
duration = data.stdout.decode('utf-8')
|
||||
|
||||
# Stream duration type: image2
|
||||
if "N/A" in duration:
|
||||
command = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-f", "image2", "-show_entries", "stream=duration", "-of", "default=noprint_wrappers=1:nokey=1", fullPath]
|
||||
data = subprocess.run(command, stdout=subprocess.PIPE)
|
||||
duration = data.stdout.decode('utf-8')
|
||||
|
||||
# Format (container) duration type: image2
|
||||
if "N/A" in duration:
|
||||
command = ["ffprobe", "-v", "error", "-f", "image2", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", fullPath]
|
||||
data = subprocess.run(command , stdout=subprocess.PIPE)
|
||||
duration = data.stdout.decode('utf-8')
|
||||
|
||||
# Get frame roughly 35% through video
|
||||
grabTime = str( int( float( duration.split(".")[0] ) * 0.35) )
|
||||
command = ["ffmpeg", "-ss", grabTime, "-an", "-i", fullPath, "-s", "320x180", "-vframes", "1", hashImgPth]
|
||||
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
|
||||
proc.wait()
|
||||
except Exception as e:
|
||||
print("Video thumbnail generation issue in thread:")
|
||||
print( repr(e) )
|
||||
self.logger.debug(repr(e))
|
||||
|
||||
|
||||
def check_remux_space(self):
|
||||
limit = self.remux_folder_max_disk_usage
|
||||
try:
|
||||
@ -121,7 +74,7 @@ class Launcher:
|
||||
self.logger.debug(e)
|
||||
return
|
||||
|
||||
usage = self.getRemuxFolderUsage(self.REMUX_FOLDER)
|
||||
usage = self.get_remux_folder_usage(self.REMUX_FOLDER)
|
||||
if usage > limit:
|
||||
files = os.listdir(self.REMUX_FOLDER)
|
||||
for file in files:
|
||||
@ -129,7 +82,7 @@ class Launcher:
|
||||
os.unlink(fp)
|
||||
|
||||
|
||||
def getRemuxFolderUsage(self, start_path = "."):
|
||||
def get_remux_folder_usage(self, start_path = "."):
|
||||
total_size = 0
|
||||
for dirpath, dirnames, filenames in os.walk(start_path):
|
||||
for f in filenames:
|
||||
|
@ -1,7 +1,7 @@
|
||||
# System import
|
||||
import os
|
||||
from os import path
|
||||
|
||||
|
||||
# Lib imports
|
||||
|
||||
|
||||
@ -11,19 +11,33 @@ from os import path
|
||||
|
||||
class Settings:
|
||||
logger = None
|
||||
GTK_ORIENTATION = 1 # HORIZONTAL (0) VERTICAL (1)
|
||||
lock_folder = False
|
||||
go_past_home = True
|
||||
|
||||
GTK_ORIENTATION = 1 # HORIZONTAL (0) VERTICAL (1)
|
||||
ABS_THUMBS_PTH = None # Used for thumbnail generation and is set by passing in
|
||||
REMUX_FOLDER = None # Used for Remuxed files and is set by passing in
|
||||
FFMPG_THUMBNLR = None # Used for thumbnail generator binary and is set by passing in
|
||||
HIDE_HIDDEN_FILES = True
|
||||
lock_folder = False
|
||||
go_past_home = True
|
||||
|
||||
iconContainerWH = [128, 128]
|
||||
systemIconImageWH = [56, 56]
|
||||
viIconWH = [256, 128]
|
||||
USER_HOME = path.expanduser('~')
|
||||
CONFIG_PATH = USER_HOME + "/.config/pyfm"
|
||||
DEFAULT_ICONS = CONFIG_PATH + "/icons"
|
||||
DEFAULT_ICON = DEFAULT_ICONS + "/text.png"
|
||||
FFMPG_THUMBNLR = CONFIG_PATH + "/ffmpegthumbnailer" # Thumbnail generator binary
|
||||
REMUX_FOLDER = USER_HOME + "/.remuxs" # Remuxed files folder
|
||||
|
||||
subpath = "" # modify 'home' folder path
|
||||
STEAM_BASE_URL = "https://steamcdn-a.akamaihd.net/steam/apps/"
|
||||
ICON_DIRS = ["/usr/share/pixmaps", "/usr/share/icons", USER_HOME + "/.icons" ,]
|
||||
BASE_THUMBS_PTH = USER_HOME + "/.thumbnails" # Used for thumbnail generation
|
||||
ABS_THUMBS_PTH = BASE_THUMBS_PTH + "/normal" # Used for thumbnail generation
|
||||
STEAM_ICONS_PTH = BASE_THUMBS_PTH + "/steam_icons"
|
||||
CONTAINER_ICON_WH = [128, 128]
|
||||
VIDEO_ICON_WH = [256, 128]
|
||||
SYS_ICON_WH = [56, 56]
|
||||
|
||||
subpath = "/LazyShare/Movies-TV-Music/TV/Anime/Barakamon" # modify 'home' folder path
|
||||
# subpath = "/Desktop" # modify 'home' folder path
|
||||
locked_folders = "venv::::flasks".split("::::")
|
||||
mplayer_options = "-quiet -really-quiet -xy 1600 -geometry 50%:50%".split()
|
||||
music_app = "/opt/deadbeef/bin/deadbeef"
|
||||
@ -35,10 +49,24 @@ class Settings:
|
||||
file_manager_app = "spacefm"
|
||||
remux_folder_max_disk_usage = "8589934592"
|
||||
|
||||
|
||||
# Filters
|
||||
fvideos = ('.mkv', '.avi', '.flv', '.mov', '.m4v', '.mpg', '.wmv', '.mpeg', '.mp4', '.webm')
|
||||
foffice = ('.doc', '.docx', '.xls', '.xlsx', '.xlt', '.xltx', '.xlm', '.ppt', 'pptx', '.pps', '.ppsx', '.odt', '.rtf')
|
||||
fimages = ('.png', '.jpg', '.jpeg', '.gif', '.ico', '.tga')
|
||||
ftext = ('.txt', '.text', '.sh', '.cfg', '.conf')
|
||||
fmusic = ('.psf', '.mp3', '.ogg', '.flac', '.m4a')
|
||||
fpdf = ('.pdf')
|
||||
|
||||
|
||||
# Dire structure check
|
||||
if path.isdir(REMUX_FOLDER) == False:
|
||||
os.mkdir(REMUX_FOLDER)
|
||||
|
||||
if path.isdir(BASE_THUMBS_PTH) == False:
|
||||
os.mkdir(BASE_THUMBS_PTH)
|
||||
|
||||
if path.isdir(ABS_THUMBS_PTH) == False:
|
||||
os.mkdir(ABS_THUMBS_PTH)
|
||||
|
||||
if path.isdir(STEAM_ICONS_PTH) == False:
|
||||
os.mkdir(STEAM_ICONS_PTH)
|
||||
|
BIN
src/user_config/pyfm/ffmpegthumbnailer
Executable file
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 858 B After Width: | Height: | Size: 858 B |
Before Width: | Height: | Size: 850 B After Width: | Height: | Size: 850 B |
Before Width: | Height: | Size: 702 B After Width: | Height: | Size: 702 B |
Before Width: | Height: | Size: 925 B After Width: | Height: | Size: 925 B |
Before Width: | Height: | Size: 882 B After Width: | Height: | Size: 882 B |
Before Width: | Height: | Size: 707 B After Width: | Height: | Size: 707 B |
Before Width: | Height: | Size: 798 B After Width: | Height: | Size: 798 B |
Before Width: | Height: | Size: 989 B After Width: | Height: | Size: 989 B |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |