Files
ts3java/ts3-client/tools/material-icon-pack.py
ericek111 e7741e8b34 Give the Material pack every icon TeamSpeak's packs have
The rest of default_colored_2014's keys get Material counterparts:
talk power, whisper, recording, text formatting, identities, add-ons,
permissions and the like. Ping levels are drawn as signal bars, since
Material's own tell their levels apart by a hairline, and the default
group icons as lettered badges.

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

337 lines
12 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"
YELLOW = "#C9A227"
GREEN = "#3A9D4A"
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",),
# Everything else TeamSpeak's own packs draw, so the pack is as complete as theirs.
"CHANNEL_YELLOW": ("chat", "o", YELLOW, UNSUBSCRIBED),
"CHANNEL_YELLOW_SUBSCRIBED": ("chat", "o", YELLOW),
"CHANNEL_COLLAPSE_ALL": ("unfold_less",),
"CHANNEL_COLLAPSE_SUB": ("unfold_less",),
"CHANNEL_EXPAND_ALL": ("unfold_more",),
"CHANNEL_EXPAND_SUB": ("unfold_more",),
"MAKE_CURRENT_CHANNEL_DEFAULT": ("push_pin",),
"MODERATED": ("shield", "o"),
"SUBSCRIBE_MODE": ("visibility", "o"),
"UNSUBSCRIBE_MODE": ("visibility_off", "o"),
"CLIENT_SHOW": ("preview",),
"CLIENT_HIDE": ("hide_source",),
"IS_TALKER": ("record_voice_over", "o"),
"TALK_POWER_REQUEST": ("back_hand",),
"TALK_POWER_CANCEL_REQUEST": ("do_not_touch",),
"TALK_POWER_GRANT": ("record_voice_over", "", GREEN),
"TALK_POWER_GRANT_NEXT": ("record_voice_over", "o", GREEN),
"TALK_POWER_REVOKE": ("voice_over_off", "", MUTED),
"TALK_POWER_REVOKE_ALL_GRANT_NEXT": ("voice_over_off", "o", MUTED),
"WHISPER": ("hearing",),
"ON_WHISPERLIST": ("hearing", "", TALKING),
"PLAYER_WHISPER": ("hearing", "", MUTED),
"WHISPERHISTORY": ("history",),
"WHISPERLISTS": ("list_alt",),
"TOGGLE_BLOCK_INCOMING_WHISPERS": ("hearing_disabled",),
"TOGGLE_SERVER_QUERY_CLIENTS": ("terminal", "o"),
"3D_SOUND": ("surround_sound",),
"3D_SOUND_ME": ("person_pin_circle",),
"3D_SOUND_OTHER": ("person_pin",),
"MUSIC": ("music_note",),
"PLAY": ("play_arrow",),
"STOP": ("stop",),
"RECORDING_START": ("fiber_manual_record", "", MUTED),
"RECORDING_STOP": ("stop_circle",),
"PING_CALCULATING": ("network_check",),
"PING_DISCONNECTED": ("wifi_off", "", IDLE),
"MESSAGES": ("forum",),
"MESSAGE_INCOMING": ("call_received",),
"MESSAGE_OUTGOING": ("call_made",),
"MESSAGE_INFO": ("info", "", TALKING),
"OFFLINE_MESSAGES": ("mail", "o"),
"NOTIFICATIONS": ("notifications",),
"EMOTICON": ("emoji_emotions", "o"),
"TEXTFORMAT": ("text_format",),
"TEXTFORMAT_BOLD": ("format_bold",),
"TEXTFORMAT_ITALIC": ("format_italic",),
"TEXTFORMAT_UNDERLINE": ("format_underlined",),
"TEXTFORMAT_FOREGROUND": ("format_color_text",),
"COPY_URL": ("link",),
"URLCATCHER": ("add_link",),
"WEBLIST": ("public",),
"WARNING_EXTERNAL_LINK": ("open_in_new",),
"SERVER_LOG": ("receipt_long",),
"COMPLAINT_LIST": ("report", "o"),
"SEND_COMPLAINT": ("flag",),
"INVITE_BUDDY": ("group_add",),
"PHONETICSNICKNAME": ("spellcheck",),
"RENAME": ("edit", "o"),
"CONNECT_NEW_TAB": ("tab",),
"DISCONNECT_ALL": ("link_off",),
"HOSTER_BUTTON": ("storage",),
"VIRTUALSERVER_EDIT": ("settings_applications",),
"TEMP_SERVER_PASSWORD": ("password",),
"TEMP_SERVER_PASSWORD_ADD": ("enhanced_encryption",),
"TOKEN": ("vpn_key",),
"TOKEN_USE": ("key",),
"SECURITY": ("security",),
"PERMISSION_OVERVIEW": ("policy",),
"PERMISSIONS_CLIENTS": ("manage_accounts",),
"PERMISSIONS_CHANNELS": ("rule",),
"PERMISSIONS_CHANNEL_CLIENT": ("switch_account",),
"IDENTITY_ADD": ("person_add_alt",),
"IDENTITY_REMOVE": ("person_remove_alt_1",),
"IDENTITY_DEFAULT": ("how_to_reg",),
"IDENTITY_IMPORT": ("file_download",),
"IDENTITY_EXPORT": ("file_upload",),
"DELETE_AVATAR_OTHER": ("no_photography",),
"BOOKMARK_ADD_FOLDER": ("create_new_folder", "o"),
"BOOKMARK_DUPLICATE": ("bookmarks",),
"BOOKMARK_SET_FOR_ALL": ("bookmark_added",),
"MYTS_ACCOUNT": ("account_circle",),
"MYTS_ACCOUNT_ONLINE": ("cloud_done",),
"MYTS_ACCOUNT_OFFLINE": ("cloud_off", "", IDLE),
"MYTS_ACCOUNT_ERROR": ("sync_problem", "", MUTED),
"SYNC": ("sync",),
"SYNC_ENABLE": ("cloud_sync",),
"SYNC_DISABLE": ("cloud_off",),
"REGISTER": ("app_registration",),
"ADDON": ("extension",),
"ADDON_BROWSER": ("travel_explore",),
"ADDON_COLLECTION": ("library_add",),
"ADDON_ICONPACK": ("category",),
"ADDON_SOUNDPACK": ("library_music",),
"ADDON_THEME": ("palette",),
"ADDON_TRANSLATION": ("translate",),
"PLUGINS": ("electrical_services",),
"SOUNDPACK": ("library_music",),
"HOTKEYS": ("keyboard",),
"GUISETUP": ("tune",),
"SWITCH_ADVANCED": ("build",),
"SWITCH_STANDARD": ("build", "o"),
"CHANGELOG": ("article",),
"CHECK_UPDATE": ("update",),
"PRESENT": ("redeem",),
"APPLY": ("check",),
"RELOAD": ("replay",),
"FIND": ("search",),
"SEARCH": ("search",),
"SEARCH_CLEAR": ("close",),
"SEARCH_HIDE": ("close",),
"FILTER_CLEAR": ("filter_alt_off",),
"SORT_BY_NAME": ("sort_by_alpha",),
"UP": ("arrow_drop_up",),
"UP_ARROW": ("arrow_drop_up",),
"DOWN": ("arrow_drop_down",),
"DOWN_ARROW": ("arrow_drop_down",),
"FILE_HOME": ("home",),
"SELECTFOLDER": ("folder_open",),
"LISTVIEW": ("view_list",),
"ICONSVIEW": ("grid_view",),
"ICONVIEWER": ("photo_library",),
"BROKEN_IMAGE": ("broken_image",),
"LOADING_IMAGE": ("hourglass_empty",),
}
# TeamSpeak's default server and channel groups, lettered as its own packs letter them.
GROUP_BADGES = {
"GROUP_100": ("C", "#D9443A"),
"GROUP_200": ("O", "#E0782E"),
"GROUP_300": ("S", "#B02E6E"),
"GROUP_500": ("Q", "#6E4CB0"),
"GROUP_600": ("V", "#2E7DB0"),
}
# Material's own signal icons tell their levels apart by an outline too thin for 16 px.
PING_LEVELS = {"PING_1": 1, "PING_2": 2, "PING_3": 3, "PING_4": 4}
def ping_bars(level):
bars = "".join(
f'<rect x="{3 + i * 5}" y="{17 - i * 4}" width="3.5" height="{4 + i * 4}" rx="1"'
+ ("" if i < level else ' fill-opacity="0.3"') + "/>"
for i in range(4))
return ('<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" '
f'viewBox="0 0 24 24">{bars}</svg>')
BADGE = (
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
'<rect x="2" y="2" width="20" height="20" rx="5" fill="{colour}"/>'
'<text x="12" y="17.5" text-anchor="middle" font-family="sans-serif" font-weight="bold" '
'font-size="15" fill="#FFFFFF">{letter}</text></svg>'
)
INI_HEAD = """\
[info]
name = Material
[options]
# Covers every icon TeamSpeak's own packs do.
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}")
for key, level in PING_LEVELS.items():
name = f"ping-{level}.svg"
(OUT / name).write_text(ping_bars(level) + "\n")
lines.append(f"{key.ljust(width)} = {name}")
for key, (letter, colour) in GROUP_BADGES.items():
name = f"group-{letter.lower()}.svg"
(OUT / name).write_text(BADGE.format(letter=letter, colour=colour) + "\n")
lines.append(f"{key.ljust(width)} = {name}")
(OUT / "settings.ini").write_text(INI_HEAD + "\n".join(lines) + "\n")
if __name__ == "__main__":
main()