Files
ts3java/ts3-client/tools/material-icon-pack.py
ericek111 cdf81f3258 Share icon packs between desktop and Android
The Android app's Material icons become a TeamSpeak-format pack shipped in
core, which the desktop offers next to the TeamSpeak packs. Android now
draws its icons from packs too and imports TeamSpeak's own zips, falling
back to the Material pack for what they lack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 09:39:42 +00:00

166 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Builds the Material icon pack the client ships (core/src/main/resources/gfx/material).
The pack is in TeamSpeak's own format (settings.ini + SVGs), so both frontends draw it
through the same code as any TeamSpeak pack. The art is Google's Material Icons
(Apache 2.0), the set the Android frontend was designed with.
Monochrome icons are filled with `currentColor`, which the frontends replace with the
interface's text colour; status icons carry the fixed colours TeamSpeak gives them.
"""
import pathlib
import urllib.request
SOURCE = "https://cdn.jsdelivr.net/npm/@material-design-icons/svg@0.14.15/{style}/{name}.svg"
OUT = pathlib.Path(__file__).resolve().parent.parent / "core/src/main/resources/gfx/material"
TALKING = "#2E9BF0"
MUTED = "#D9443A"
AWAY = "#9E8A2E"
IDLE = "#888888"
UNSUBSCRIBED = 0.5
# key: (icon, style, colour, opacity); style "o" is Material's outlined variant.
ICONS = {
"SERVER_GREEN": ("dns",),
"CHANNEL_GREEN": ("chat", "o", None, UNSUBSCRIBED),
"CHANNEL_GREEN_SUBSCRIBED": ("chat", "o"),
"CHANNEL_PRIVATE": ("lock", "", None, UNSUBSCRIBED),
"CHANNEL_PRIVATE_SUBSCRIBED": ("lock",),
"CHANNEL_RED": ("block", "", MUTED, UNSUBSCRIBED),
"CHANNEL_RED_SUBSCRIBED": ("block", "", MUTED),
"CHANNEL_CHAT": ("chat", "o"),
"CHANNEL_COMMANDER": ("campaign", "o"),
"PLAYER_OFF": ("circle", "o", IDLE),
"PLAYER_ON": ("circle", "", TALKING),
"PLAYER_COMMANDER_OFF": ("campaign", "o"),
"PLAYER_COMMANDER_ON": ("campaign", "", TALKING),
"AWAY": ("bedtime", "", AWAY),
"SERVER_QUERY": ("terminal",),
"INPUT_MUTED": ("mic_off", "", MUTED),
"INPUT_MUTED_LOCAL": ("mic_off", "", AWAY),
"OUTPUT_MUTED": ("volume_off", "", MUTED),
"HARDWARE_INPUT_MUTED": ("mic_off", "", IDLE),
"HARDWARE_OUTPUT_MUTED": ("headset_off", "", IDLE),
"CAPTURE": ("mic",),
"PLAYBACK": ("volume_up",),
"VOLUME": ("volume_up",),
"ACTIVATE_MICROPHONE": ("record_voice_over", "", TALKING),
"CONNECT": ("login",),
"DISCONNECT": ("logout",),
"QUIT": ("power_settings_new",),
"SETTINGS": ("settings",),
"REFRESH": ("refresh",),
"FILE_REFRESH": ("refresh",),
"INFO": ("info",),
"ABOUT": ("info", "o"),
"WARNING": ("warning",),
"ERROR": ("error",),
"CHANNEL_SWITCH": ("login",),
"CHANNEL_CREATE": ("add",),
"CHANNEL_CREATE_SUB": ("subdirectory_arrow_right",),
"CHANNEL_CREATE_SPACER": ("horizontal_rule",),
"CHANNEL_EDIT": ("edit",),
"CHANNEL_DELETE": ("delete",),
"SUBSCRIBE_TO_CHANNEL": ("visibility",),
"UNSUBSCRIBE_FROM_CHANNEL": ("visibility_off",),
"SUBSCRIBE_TO_CHANNEL_FAMILY": ("visibility", "o"),
"UNSUBSCRIBE_FROM_CHANNEL_FAMILY": ("visibility_off", "o"),
"SUBSCRIBE_TO_ALL_CHANNELS": ("visibility",),
"UNSUBSCRIBE_FROM_ALL_CHANNELS": ("visibility_off",),
"PLAYER_CHAT": ("chat", "o"),
"NEW_CHAT": ("add_comment", "o"),
"POKE": ("notifications_active",),
"CHANGE_NICKNAME": ("drive_file_rename_outline",),
"MOVE_CLIENT_TO_OWN_CHANNEL": ("move_down",),
"KICK_FROM_CHANNEL": ("remove_circle_outline",),
"KICK_FROM_SERVER": ("remove_circle",),
"BAN_CLIENT": ("gavel",),
"BAN_LIST": ("list",),
"PERMISSIONS_SERVER_GROUPS": ("groups",),
"PERMISSIONS_CHANNEL_GROUPS": ("group_work", "o"),
"ADD_FRIEND": ("person_add",),
"REMOVE_FRIEND": ("person_remove",),
"ADD_FOE": ("person_off",),
"REMOVE_FOE": ("person_remove", "o"),
"EDIT_FRIEND_FOE_STATUS": ("contacts",),
"CONTACT": ("contacts",),
"IDENTITY_MANAGER": ("badge",),
"BOOKMARK_MANAGER": ("bookmark",),
"BOOKMARK_ADD": ("bookmark_add",),
"BOOKMARK_REMOVE": ("bookmark_remove",),
"UPLOAD_AVATAR": ("add_a_photo",),
"DELETE_AVATAR": ("no_photography",),
"ADD": ("add",),
"EDIT": ("edit",),
"DELETE": ("delete",),
"COPY": ("content_copy",),
"DEFAULT": ("star",),
"CLOSE_BUTTON": ("close",),
"TAB_CLOSE_BUTTON": ("close",),
"FOLDER": ("folder",),
"ADD_FOLDER": ("create_new_folder",),
"FILE_UP": ("drive_folder_upload",),
"FILETRANSFER": ("swap_vert",),
"UPLOAD": ("upload",),
"DOWNLOAD": ("download",),
}
INI_HEAD = """\
[info]
name = Material
[options]
# Complete for what the client draws; the rest stays with the client's own icons.
FALLBACK=false
[gfxfiles]
"""
def file_name(icon, style, colour, opacity):
parts = [icon] + (["outlined"] if style == "o" else [])
if colour:
parts.append(colour.lstrip("#").lower())
if opacity is not None:
parts.append(f"a{int(opacity * 100)}")
return "-".join(parts) + ".svg"
def fetch(icon, style):
url = SOURCE.format(style="outlined" if style == "o" else "filled", name=icon)
with urllib.request.urlopen(url) as response:
return response.read().decode()
def paint(svg, colour, opacity):
attributes = f' fill="{colour or "currentColor"}"'
if opacity is not None:
attributes += f' fill-opacity="{opacity}"'
return svg.replace("<svg ", "<svg" + attributes + " ", 1)
def main():
OUT.mkdir(parents=True, exist_ok=True)
for old in OUT.glob("*.svg"):
old.unlink()
lines = []
width = max(map(len, ICONS))
for key, spec in ICONS.items():
icon, style, colour, opacity = spec + ("", None, None)[len(spec) - 1:]
name = file_name(icon, style, colour, opacity)
path = OUT / name
if not path.exists():
path.write_text(paint(fetch(icon, style), colour, opacity) + "\n")
lines.append(f"{key.ljust(width)} = {name}")
(OUT / "settings.ini").write_text(INI_HEAD + "\n".join(lines) + "\n")
if __name__ == "__main__":
main()