"""Tauon Music Box

Preamble

Welcome to the Tauon Music Box source code.
I started this project when I was first learning python, as a result this code can be quite messy.
No doubt I have written some things terribly wrong or inefficiently in places.
I would highly recommend not using this project as an example on how to code cleanly or correctly.
"""

# Copyright © 2015-2026, Taiko2k captain(dot)gxj(at)gmail.com

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

from __future__ import annotations

import base64
import builtins
import colorsys
import copy
import ctypes
import ctypes.util
import datetime
import gc as gbc
import gettext
import glob
import hashlib
import importlib
import io
import json
import locale as py_locale
import logging

# import magic
import math

# import mimetypes
import os
import pickle
import platform
import random
import re
import shlex
import shutil
import signal
import socket
import ssl
import subprocess
import sys
import threading
import time

# import type_enforced
import urllib.parse
import urllib.request
import webbrowser
import xml.etree.ElementTree as ET
import zipfile
from collections import OrderedDict
from ctypes import (
	POINTER,
	Structure,
	byref,
	c_char_p,
	c_double,
	c_float,
	c_int,
	c_ubyte,
	c_uint,
	c_uint32,
	c_void_p,
	pointer,
)
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Literal

import certifi
import musicbrainzngs
import mutagen
import mutagen.apev2
import mutagen.flac
import mutagen.id3
import mutagen.mp4
import mutagen.oggopus
import mutagen.oggvorbis
import requests
import sdl3
from bs4 import BeautifulSoup
from mutagen.easyid3 import EasyID3
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter
from send2trash import send2trash
from unidecode import unidecode

builtins._ = lambda x: x

from tauon.t_modules import t_topchart  # noqa: E402
from tauon.t_modules.t_config import Config  # noqa: E402
from tauon.t_modules.t_db_migrate import (  # noqa: E402
	database_migrate,
	migrate_star_store_71,
)
from tauon.t_modules.t_custom import (  # noqa: E402
	SPECTRO_PRESETS as CL_SPECTRO_PRESETS,
	STACK_COUNTS as CL_STACK_COUNTS,
	TEMPLATES as CL_TEMPLATES,
	WIDGET_SPECS as CL_WIDGET_SPECS,
	CustomLayout,
	GridGalleryWidget,
	draw_layout_glyph,
)
from tauon.t_modules.t_draw import QuickThumbnail, TDraw  # noqa: E402
from tauon.t_modules.t_room import DreamRoom  # noqa: E402
from tauon.t_modules.t_enums import (  # noqa: E402
	Backend,
	GuiMode,
	LoaderCommand,
	MiniModeMode,
	PlayerState,
	PlayingState,
	QueueType,
	StopMode,
)
from tauon.t_modules.t_extra import (  # noqa: E402
	ColourGenCache,
	ColourRGBA,
	FPSCounter,
	FunctionStore,
	RadioPlaylist,
	RadioStation,
	StarRecord,
	TauonPlaylist,
	TauonQueueItem,
	TestTimer,
	Timer,
	alpha_blend,
	alpha_mod,
	archive_file_scan,
	atomic_save,
	check_equal,
	clean_string,
	colour_slide,
	colour_value,
	commonprefix,
	contrast_ratio,
	d_date_display,
	d_date_display2,
	filename_safe,
	filename_to_metadata,
	fit_box,
	folder_file_scan,
	genre_correct,
	get_artist_safe,
	get_artist_strip_feat,
	get_display_time,
	get_filesize_string,
	get_filesize_string_rounded,
	get_first_artist,
	get_folder_size,
	get_hms_time,
	get_modify_date_string,
	get_split_artists,
	get_year_from_string,
	grow_rect,
	hls_to_rgb,
	hms_to_seconds,
	hsl_to_rgb,
	is_grey,
	is_light,
	j_chars,
	mac_styles,
	point_distance,
	point_proximity_test,
	process_odat,
	reduce_paths,
	rgb_add_hls,
	rgb_to_hls,
	search_magic,
	search_magic_any,
	search_magic_beefy,
	seconds_to_day_hms,
	shooter,
	sleep_timeout,
	star_count,
	star_count3,
	subtract_rect,
	test_lumi,
	tmp_cache_dir,
	tryint,
	uri_parse,
	year_search,
)
from tauon.t_modules.t_guitar_chords import GuitarChords  # noqa: E402
from tauon.t_modules.t_jellyfin import Jellyfin  # noqa: E402
from tauon.t_modules.t_lyrics import genius, get_lrclib_challenge, lyric_sources, uses_scraping  # noqa: E402
from tauon.t_modules.t_nowplaying_macos import MacNowPlayingHelper  # noqa: E402
from tauon.t_modules.t_phazor import Cachement, get_phazor_path, phazor_exists, player4  # noqa: E402
from tauon.t_modules.t_prefs import Prefs  # noqa: E402
from tauon.t_modules.t_search import bandcamp_search  # noqa: E402
from tauon.t_modules.t_stream import StreamEnc  # noqa: E402
from tauon.t_modules.t_subsonic import SubsonicService  # noqa: E402
from tauon.t_modules.t_svgout import render_icons  # noqa: E402
from tauon.t_modules.t_tagscan import Ape, Flac, M4a, Opus, TrackFile, Wav, lyrics_are_synced, parse_picture_block  # noqa: E402
from tauon.t_modules.t_themeload import Deco, load_theme, save_theme  # noqa: E402
from tauon.t_modules.t_tidal import Tidal  # noqa: E402
from tauon.t_modules.t_webserve import (  # noqa: E402
	VorbisMonitor,
	authserve,
	controller,
	stream_proxy,
	webserve,
	webserve2,
)

if sys.platform == "linux":
	import gi

	try:
		gi.require_version("Notify", "0.7")
	except Exception:
		logging.exception("Failed importing gi Notify 0.7, will try 0.8")
		gi.require_version("Notify", "0.8")
	from gi.repository import GdkPixbuf, GLib, Notify

if sys.platform == "darwin":
	import gi
	from gi.repository import GLib

try:
	from jxlpy import JXLImagePlugin

	# We've already logged this once to INFO from t_draw, so just log to DEBUG
	logging.debug("Found jxlpy for JPEG XL support")
except ModuleNotFoundError:
	logging.warning("Unable to import jxlpy, JPEG XL support will be disabled.")
except Exception:
	logging.exception("Unknown error trying to import jxlpy, JPEG XL support will be disabled.")

if not sys.platform == "darwin":
	try:
		import setproctitle
	except ModuleNotFoundError:
		logging.warning("Unable to import setproctitle, won't be setting process title.")
	except Exception:
		logging.exception("Unknown error trying to import setproctitle, won't be setting process title.")
	else:
		setproctitle.setproctitle("tauonmb")

# try:
# 	import rpc
# 	discord_allow = True
# except Exception:
# 	logging.exception("Unable to import rpc, Discord Rich Presence will be disabled.")
try:
	from pypresence import ActivityType, StatusDisplayType, Presence
except ModuleNotFoundError:
	logging.warning("Unable to import pypresence, Discord Rich Presence will be disabled.")
except Exception:
	logging.exception("Unknown error trying to import pypresence, Discord Rich Presence will be disabled.")
else:
	import asyncio

try:
	import opencc
except ModuleNotFoundError:
	logging.warning(
		"Unable to import opencc, Traditional and Simplified Chinese searches will not be usable interchangeably."
	)
except Exception:
	logging.exception(
		"Unknown error trying to import opencc, Traditional and Simplified Chinese searches will not be usable interchangeably."
	)

try:
	import natsort
except ModuleNotFoundError:
	logging.warning("Unable to import natsort, playlists may not sort as intended!")
except Exception:
	logging.exception("Unknown error trying to import natsort, playlists may not sort as intended!")

CJK_SEARCH_PATTERN = re.compile(
	r"[\u4e00-\u9fff\u3400-\u4dbf\u20000-\u2a6df\u2a700-\u2b73f\u2b740-\u2b81f\u2b820-\u2ceaf\uf900-\ufaff\u2f800-\u2fa1f]"
)

try:
	from tauon.t_modules.t_chrome import Chrome
	logging.debug("Found Chrome(pychromecast) for chromecast support")
except ModuleNotFoundError as e:
	logging.debug(f"pychromecast import error: {e}")
	logging.warning("Unable to import Chrome(pychromecast), chromecast support will be disabled.")
except Exception:
	logging.exception("Unknown error trying to import Chrome(pychromecast), chromecast support will be disabled.")

try:
	# pyLast needs to be imported AFTER setup_tls() else pyinstaller breaks - we reimport it later
	import pylast
except Exception:
	logging.exception("pyLast module not found, Last.fm support will be disabled.")

if TYPE_CHECKING:
	from ctypes import CDLL
	from io import BufferedReader, BytesIO
	from typing import ClassVar

	from PIL.ImageFile import ImageFile
	from pylast import LibreFMNetwork
	from websocket import WebSocketApp

	from tauon.t_modules.t_bootstrap import Holder
	from tauon.t_modules.t_dbus import MPRIS
	from tauon.t_modules.t_logging import LogHistoryHandler
	from collections.abc import Callable, Sequence
	from subprocess import Popen

	from mutagen.id3 import ID3
	from pylast import LastFMNetwork, SessionKeyGenerator

	from tauon.t_modules.t_webserve import ThreadedHTTPServer

# Detect platform
macos: bool = False
windows: bool = False
arch: str = platform.machine()
platform_release: str = platform.release()
platform_system: str = platform.system()
win_ver: int = 0
if platform_system == "Windows":
	try:
		win_ver = int(platform_release)
	except Exception:
		logging.exception("Failed getting Windows version from platform.release()")

if sys.platform == "win32":
	windows = True
	import gi
	from gi.repository import GLib

if sys.platform == "darwin":
	macos = True

if not macos and not windows:
	from tauon.t_modules.t_dbus import Gnome

CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F]")

@dataclass
class Decorator:
	text_colour: ColourRGBA | None
	bg_colour: ColourRGBA | None
	text: str | None

@dataclass(frozen=True)
class MenuTrackRef:
	track_id: int
	position: int
	playlist_id: int


class LoadImageAsset:
	# TODO(Martin): Global class var!
	assets: ClassVar[list[LoadImageAsset]] = []

	def __init__(
		self, *, bag: Bag, path: str, is_full_path: bool = False, reload: bool = False, scale_name: str = ""
	) -> None:
		if not reload:
			self.assets.append(self)
		self.bag = bag
		self.dirs = bag.dirs
		self.renderer = bag.renderer
		self.path = path
		self.scale_name = scale_name

		raw_image = sdl3.IMG_Load(c_char_p(self.path.encode()))
		self.texture = sdl3.SDL_CreateTextureFromSurface(self.renderer, raw_image)

		p_w = pointer(c_float(0.0))
		p_h = pointer(c_float(0.0))
		sdl3.SDL_GetTextureSize(self.texture, p_w, p_h)

		if is_full_path:
			sdl3.SDL_SetTextureAlphaMod(self.texture, c_ubyte(bag.prefs.custom_bg_opacity))

		self.rect = sdl3.SDL_FRect(0, 0, p_w.contents.value, p_h.contents.value)
		sdl3.SDL_DestroySurface(raw_image)
		self.w = p_w.contents.value
		self.h = p_h.contents.value

		# Lazily created copies of this texture on other renderers (e.g. a
		# SecondaryWindow), keyed by renderer_key(). The home renderer's texture
		# lives in self.texture above.
		self._alt_textures: dict[int, sdl3.LP_SDL_Texture] = {}

	def _texture_for(self, renderer: sdl3.LP_SDL_Renderer | None) -> sdl3.LP_SDL_Texture:
		if renderer is None:
			return self.texture
		from tauon.t_modules.t_window import renderer_key
		key = renderer_key(renderer)
		if key == renderer_key(self.renderer):
			return self.texture
		texture = self._alt_textures.get(key)
		if texture is None:
			raw_image = sdl3.IMG_Load(c_char_p(self.path.encode()))
			texture = sdl3.SDL_CreateTextureFromSurface(renderer, raw_image)
			sdl3.SDL_DestroySurface(raw_image)
			self._alt_textures[key] = texture
		return texture

	def _destroy_alt_textures(self) -> None:
		for texture in self._alt_textures.values():
			sdl3.SDL_DestroyTexture(texture)
		self._alt_textures.clear()

	def reload(self) -> None:
		sdl3.SDL_DestroyTexture(self.texture)
		self._destroy_alt_textures()
		if self.scale_name:
			self.path = str(self.dirs.scaled_asset_directory / self.scale_name)
		self.__init__(bag=self.bag, path=self.path, reload=True, scale_name=self.scale_name)

	def render(self, x: float, y: float, _colour: ColourRGBA | None = None, renderer: sdl3.LP_SDL_Renderer | None = None) -> None:
		self.rect.x = round(x)
		self.rect.y = round(y)
		sdl3.SDL_RenderTexture(renderer or self.renderer, self._texture_for(renderer), None, self.rect)

class WhiteModImageAsset:
	# TODO(Martin): Global class var!
	assets: ClassVar[list[WhiteModImageAsset]] = []

	def __init__(self, *, bag: Bag, path: str, reload: bool = False, scale_name: str = "") -> None:
		self.bag  = bag
		self.dirs = bag.dirs
		if not reload:
			self.assets.append(self)
		self.path = path
		self.scale_name = scale_name

		raw_image = sdl3.IMG_Load(path.encode())
		self.texture = sdl3.SDL_CreateTextureFromSurface(self.bag.renderer, raw_image)
		self.colour = ColourRGBA(255, 255, 255, 255)
		p_w = pointer(c_float(0.0))
		p_h = pointer(c_float(0.0))
		sdl3.SDL_GetTextureSize(self.texture, p_w, p_h)
		self.rect = sdl3.SDL_FRect(0, 0, p_w.contents.value, p_h.contents.value)
		sdl3.SDL_DestroySurface(raw_image)
		self.w = p_w.contents.value
		self.h = p_h.contents.value

		# Lazily created copies on other renderers, with each copy's last applied
		# colour-mod tracked separately: value is [texture, last_colour].
		self._alt_textures: dict[int, list] = {}

	def _entry_for(self, renderer: sdl3.LP_SDL_Renderer | None) -> tuple[sdl3.LP_SDL_Texture, ColourRGBA, int | None]:
		"""Return (texture, last_colour, alt_key) for the given renderer.

		alt_key is None when the home texture is used (its colour state lives in
		self.colour); otherwise it identifies the per-renderer copy.
		"""
		if renderer is None:
			return self.texture, self.colour, None
		from tauon.t_modules.t_window import renderer_key
		key = renderer_key(renderer)
		if key == renderer_key(self.bag.renderer):
			return self.texture, self.colour, None
		entry = self._alt_textures.get(key)
		if entry is None:
			raw_image = sdl3.IMG_Load(self.path.encode())
			texture = sdl3.SDL_CreateTextureFromSurface(renderer, raw_image)
			sdl3.SDL_DestroySurface(raw_image)
			entry = [texture, ColourRGBA(255, 255, 255, 255)]
			self._alt_textures[key] = entry
		return entry[0], entry[1], key

	def _destroy_alt_textures(self) -> None:
		for entry in self._alt_textures.values():
			sdl3.SDL_DestroyTexture(entry[0])
		self._alt_textures.clear()

	def reload(self) -> None:
		sdl3.SDL_DestroyTexture(self.texture)
		self._destroy_alt_textures()
		if self.scale_name:
			self.path = str(self.dirs.scaled_asset_directory / self.scale_name)
		self.__init__(bag=self.bag, path=self.path, reload=True, scale_name=self.scale_name)

	def render(self, x: float, y: float, colour: ColourRGBA, renderer: sdl3.LP_SDL_Renderer | None = None) -> None:
		texture, last_colour, alt_key = self._entry_for(renderer)
		if colour != last_colour:
			sdl3.SDL_SetTextureColorMod(texture, colour.r, colour.g, colour.b)
			sdl3.SDL_SetTextureAlphaMod(texture, colour.a)
			if alt_key is None:
				self.colour = colour
			else:
				self._alt_textures[alt_key][1] = colour
		self.rect.x = round(x)
		self.rect.y = round(y)
		sdl3.SDL_RenderTexture(renderer or self.bag.renderer, texture, None, self.rect)

class DConsole:
	"""GUI console with logs"""

	def __init__(self) -> None:
		self.show: bool = False
		self.fps = FPSCounter(window_size=20, min_update_interval=0.12, max_frame_time=0.5)

	def toggle(self) -> None:
		"""Toggle the GUI console with logs on and off"""
		self.show ^= True
		if self.show:
			self.fps.reset()

class GuiVar:
	"""Use to hold any variables for use in relation to UI"""

	def set_drag_source(self) -> None:
		self.drag_source_position = tuple(self.inp.click_location)
		self.drag_source_position_persist = tuple(self.inp.click_location)

	def delay_frame(self, t: float) -> None:
		self.frame_callback_list.append(TestTimer(t))

	def destroy_textures(self) -> None:
		sdl3.SDL_DestroyTexture(self.spec4_tex)
		sdl3.SDL_DestroyTexture(self.spec1_tex)
		sdl3.SDL_DestroyTexture(self.spec2_tex)
		sdl3.SDL_DestroyTexture(self.spec_level_tex)

	# def test_text_input(self):
	#	 if self.text_input_request and not self.text_input_active:
	#		 sdl3.SDL_StartTextInput()
	#		 self.update += 1
	#	 if not self.text_input_request and self.text_input_active:
	#		 sdl3.SDL_StopTextInput()
	#	 self.text_input_request = False

	def rescale(self) -> None:
		self.spec_y = round(5 * self.scale)
		self.spec_w = round(80 * self.scale)
		self.spec_h = round(20 * self.scale)
		self.spec1_rec = sdl3.SDL_FRect(0, self.spec_y, self.spec_w, self.spec_h)

		self.spec4_y = round(200 * self.scale)
		self.spec4_w = round(322 * self.scale)
		self.spec4_h = round(100 * self.scale)
		self.spec4_rec = sdl3.SDL_FRect(0, self.spec4_y, self.spec4_w, self.spec4_h)

		self.bar = sdl3.SDL_FRect(10, 10, round(3 * self.scale), 10)  # spec bar bin
		self.bar4 = sdl3.SDL_FRect(10, 10, round(3 * self.scale), 10)  # spec bar bin
		self.set_height = round(25 * self.scale)
		self.panelBY = round(51 * self.scale)
		self.panelY = round(30 * self.scale)
		self.panelY2 = round(30 * self.scale)
		self.playlist_top = self.panelY + (8 * self.scale)
		self.playlist_top_bk = self.playlist_top
		self.scroll_hide_box = (0, self.panelY, 28, self.bag.window_size[1] - self.panelBY - self.panelY)

		self.spec2_y = round(22 * self.scale)
		self.spec2_w = round(140 * self.scale)
		self.spec2 = [0] * self.spec2_y
		self.spec2_phase = 0
		self.spec2_buffers = []
		self.spec2_rec = sdl3.SDL_FRect(1230, round(4 * self.scale), self.spec2_w, self.spec2_y)
		self.spec2_source = sdl3.SDL_FRect(900, round(4 * self.scale), self.spec2_w, self.spec2_y)
		self.spec2_dest = sdl3.SDL_FRect(900, round(4 * self.scale), self.spec2_w, self.spec2_y)
		self.spec2_position = 0
		self.spec2_timer = Timer()
		self.spec2_timer.set()

		self.level_w = 5 * self.scale
		self.level_y = 16 * self.scale
		self.level_s = 1 * self.scale
		self.level_ww = round(79 * self.scale)
		self.level_hh = round(18 * self.scale)
		self.spec_level_rec = sdl3.SDL_FRect(
			0, round(self.level_y - 10 * self.scale), round(self.level_ww),round(self.level_hh))

		self.spec2_tex = sdl3.SDL_CreateTexture(
			self.bag.renderer, sdl3.SDL_PIXELFORMAT_ARGB8888, sdl3.SDL_TEXTUREACCESS_TARGET, self.spec2_w, self.spec2_y)
		self.spec4_tex = sdl3.SDL_CreateTexture(
			self.bag.renderer, sdl3.SDL_PIXELFORMAT_ARGB8888, sdl3.SDL_TEXTUREACCESS_TARGET, self.spec4_w, self.spec4_y)
		self.spec1_tex = sdl3.SDL_CreateTexture(
			self.bag.renderer, sdl3.SDL_PIXELFORMAT_ARGB8888, sdl3.SDL_TEXTUREACCESS_TARGET, self.spec_w, self.spec_h)
		self.spec_level_tex = sdl3.SDL_CreateTexture(
			self.bag.renderer, sdl3.SDL_PIXELFORMAT_ARGB8888, sdl3.SDL_TEXTUREACCESS_TARGET, self.level_ww, self.level_hh)
		sdl3.SDL_SetTextureBlendMode(self.spec4_tex, sdl3.SDL_BLENDMODE_BLEND)
		self.artist_panel_height = 320 * self.scale
		self.last_artist_panel_height = self.artist_panel_height

		self.window_control_hit_area_w = 100 * self.scale
		self.window_control_hit_area_h = 30 * self.scale

	def __init__(self, bag: Bag, tracklist_texture_rect: sdl3.SDL_FRect, tracklist_texture: sdl3.LP_SDL_Texture, main_texture_overlay_temp: sdl3.LP_SDL_Texture, main_texture: sdl3.LP_SDL_Texture, max_window_tex: int) -> None:
		self.bag: Bag = bag
		self.console: DConsole = bag.console
		self.inp: Input = Input(gui=self)
		self.keymaps: KeyMap = KeyMap(bag=bag, inp=self.inp)

		self.scale: float = self.bag.prefs.ui_scale

		self.panelY: int = 0
		self.panelY2: int = 0
		self.panelBY: float = 0

		self.window_id = 0
		self.update    = 2  # UPDATE
		self.update_layout: bool = True
		self.turbo:      bool = True
		self.turbo_next = 0
		self.pl_update  = 1
		self.lowered:           bool = False
		self.maximized:         bool = False
		self.side_drag:         bool = False
		self.ext_drop_mode:     bool = False
		self.quick_search_mode: bool = False
		self.b_info_bar:        bool = False
		self.editline: str = ""
		self.rename_index:        int = 0
		self.last_row:            int = 0
		self.album_v_gap:       float = 66
		self.album_h_gap:       float = 30
		self.album_v_slide_value: int = 50
		self.album_scroll_px: float = self.album_v_slide_value
		# Playlist Panel
		self.pl_rect = (2, 12, 10, 10)

		self.track_box: bool = False
		self.track_box_track_id: int = 0

		self.move_on_title: bool = False

		self.message_box: bool = False
		self.message_text: str = ""
		self.message_mode: str = "info"
		self.message_subtext: str = ""
		self.message_subtext2: str = ""
		self.message_box_confirm_reference = None
		self.message_box_use_reference: bool = True
		self.message_box_confirm_callback = None
		self.message_box_no_callback = None

		self.save_size = [450, 310]
		self.show_playlist: bool = True
		self.show_bottom_title: bool = False
		# self.show_top_title: bool = True
		self.search_error: bool = False

		self.level_update: bool = False
		self.level_time: Timer = Timer()
		self.level_peak: list[float] = [0, 0]
		self.level = 0
		self.time_passed = 0
		self.level_meter_colour_mode = 3

		self.vis = 0  # visualiser mode actual
		self.vis_want = 2  # visualiser mode setting
		self.spec: list[float] | None = None
		self.s_spec = [0] * 24
		self.s4_spec = [0] * 45
		self.update_spec = 0

		self.new_playlist_cooldown: bool = False
		self.playlist_hold_position = 0
		self.playlist_hold: bool = False
		self.selection_stage = 0
		self.playlist_scroll_pixels: float = 0

		self.shift_selection: list[int] = []

		# self.spec_rect = [0, 5, 80, 20]  # x = 72 + 24 - 6 - 10

		self.spec4_array: list[float] = []

		self.draw_spec4: bool = False

		self.combo_mode: bool = False
		# Custom Layout System (opt-in). When custom_mode is set the custom
		# layout engine composites over the frame; custom_edit toggles edit mode.
		self.custom_mode: bool = False
		self.custom_edit: bool = False
		# The Custom Layout MilkDrop Box widget owns the (singleton) visualiser:
		# gates the ArtBox / MetaBox milk paths off so both never run at once.
		self.milkdrop_in_widget: bool = False
		# The Custom Layout Sticks visualiser widget is in the layout: makes
		# update_layout_do() switch gui.vis to 4 so PHAZOR feeds spec4_array.
		self.vis4_in_widget: bool = False
		# Ditto for the Spectrogram widget (gui.vis 6): PHAZOR pushes raw
		# spectrum columns of spectrogram_bins values into spectrogram_buffers.
		self.spectrogram_in_widget: bool = False
		# When non-zero, render_gallery uses this row length instead of deriving
		# it from album_mode_art_size, and switches to edge-to-edge placement
		# inset by gallery_grid_margin (scaled px) on the left/right. Set (and
		# restored) around the call by the Custom Layout's Gallery: Compact
		# widget; 0 = preset behaviour.
		self.gallery_forced_row_len: int = 0
		self.gallery_grid_margin: int = 0
		# Smooth-scroll momentum channel used by render_gallery. Each Gallery:
		# Compact instance swaps in its own key so several galleries scroll
		# independently; "gallery" = the preset / Classic widget channel.
		self.gallery_scroll_key: str = "gallery"
		self.spectrogram_bins: int = 256
		self.spectrogram_buffers: list[list[float]] = []
		self.showcase_mode: bool = False
		self.timed_lyrics_edit_view: bool = False
		self.timed_lyrics_editing_now: bool = False
		self.lyrics_editor_update_now: list[bool] = [False, False]
		self.display_time_mode = 0

		self.pl_text_real_height = 12
		self.pl_title_real_height = 11

		self.row_extra = 0
		self.test: bool = False
		self.light_mode: bool = False

		self.level_2_click: bool = False
		self.universal_y_text_offset = 0

		self.star_text_y_offset = 0

		self.set_bar: bool = True
		self.set_mode: bool = False
		self.set_hold = -1
		self.set_label_hold = -1
		self.set_label_point = (0, 0)
		self.set_point = 0
		self.set_old = 0
		self.pl_st: list[list[str | int | bool]] = [
			["Artist", 156, False], ["Title", 188, False], ["T", 40, True], ["Album", 153, False],
			["P", 28, True], ["Starline", 86, True], ["Date", 48, True], ["Codec", 55, True],
			["Time", 53, True]]
		self.pl_box_h: int = 0

		# Leading inset before the first column in columns (set) mode. Kept as an
		# independent variable (not an entry in pl_st) so the first grip can drag
		# the whole column block left/right. Stored pre-scale (a base value at 1x)
		# and multiplied by gui.scale at each use, so it persists correctly across
		# ui-scale changes.
		self.pl_st_left: float = 16

		for item in self.pl_st:
			item[1] = item[1] * self.scale

		self.offset_extra: int = 0

		self.playlist_row_height:    int = 16
		self.playlist_text_offset: float = 0
		self.row_font_size:          int = 13
		self.compact_bar: bool = False
		self.tracklist_texture_rect: sdl3.SDL_FRect = tracklist_texture_rect
		self.tracklist_texture = tracklist_texture

		self.trunk_end = "..."  # "…"
		self.temp_themes: dict[str, ColoursClass] = {}
		self.theme_temp_current = -1

		self.pl_title_y_offset = 0
		self.pl_title_font_offset = -1

		self.playlist_box_d_click = -1

		self.gallery_show_text: bool = True
		self.bb_show_art: bool = False

		self.rename_folder_box: bool = False

		self.present: bool = False
		self.drag_source_position = (0, 0)
		self.drag_source_position_persist = (0, 0)
		#self.old_album_pos: int = -55
		self.album_playlist_width: int = 430

		self.album_tab_mode: bool = False
		self.main_art_box = (0, 0, 10, 10)
		self.gall_tab_enter: bool = False

		self.lightning_copy: bool = False

		self.gallery_animate_highlight_on = 0

		self.seek_cur_show: bool = False
		self.cur_time = "0"
		self.force_showcase_index = -1

		self.frame_callback_list: list[TestTimer] = []

		self.playlist_left: float | None = None
		self.image_downloading:     bool = False
		self.tc_cancel:             bool = False
		self.im_cancel:             bool = False
		self.force_search:          bool = False

		self.pl_pulse: bool = False

		self.view_name = "S"
		self.restart_album_mode: bool = False

		self.dtm3_index = -1
		self.dtm3_cum = 0
		self.dtm3_total = 0
		self.previous_playlist_id: int = 0

		self.star_mode = "line"
		self.heart_fields: list[list[float]] = [] # list of rectangles
		self.show_ratings: bool = False

		self.web_running: bool = False

		self.rsp: bool = True
		if self.bag.phone:
			self.rsp = False
		self.rspw: float = round(300 * self.scale)
		self.lsp: bool = False
		self.lspw: float = round(220 * self.scale)
		self.lsp_x: float = 0
		self.plw: float | None = None
		self.rsp_on_left: bool = False
		self.rsp_x: float = 0
		self.rsp_split_x: float = 0

		self.pref_rspw = 300

		self.pref_gallery_w = 600

		self.artist_info_panel: bool = False
		self.album_artist_dict: dict[int, str] = {}

		self.show_hearts: bool = True

		self.search_index: int = 0

		self.cursor_is = 0
		self.cursor_want = 0
		# 0 standard
		# 1 drag horizontal
		# 2 text
		# 3 hand

		self.power_bar = None
		self.gallery_scroll_field_left = 1
		self.combo_was_album: bool = False

		self.gallery_positions: dict[int, float] = {}

		self.remember_library_mode: bool = False

		self.first_in_grid = None

		self.art_aspect_ratio = 1
		self.art_drawn_rect = None
		self.art_unlock_ratio: bool = False
		self.art_max_ratio_lock = 1
		self.side_bar_drag_source = 0
		self.side_bar_drag_original = 0

		self.scroll_direction = 0
		self.add_music_folder_ready: bool = False

		self.playlist_current_visible_tracks = 0
		self.playlist_current_visible_tracks_id = 0

		self.theme_name = ""
		self.rename_playlist_box: bool = False
		self.queue_frame_draw = None  # Set when need draw frame later

		self.mode = GuiMode.MAIN

		self.save_position = [0, 0]

		self.draw_vis4_top: bool = False
		# self.vis_4_colour = ColourRGBA(0,0,0,255)
		self.vis_4_colour: ColourRGBA | None = None

		self.layer_focus = 0
		self.tab_menu_pl = 0

		self.tool_tip_lock_off_f: bool = False
		self.tool_tip_lock_off_b: bool = False

		self.auto_play_import: bool = False

		self.transcoding_batch_total = 0
		self.transcoding_batch_done = 0

		self.seek_bar_rect = (0, 0, 0, 0)
		self.volume_bar_rect = (0, 0, 0, 0)

		self.mini_mode_return_maximized: bool = False

		self.opened_config_file: bool = False

		self.notify_main_id: bool | None = None

		self.halt_image_rendering: bool = False
		self.generating_chart: bool = False

		self.top_bar_mode2: bool = False
		self.mode_toast_text = ""

		self.rescale()
		# self.smooth_scrolling = False

		self.compact_artist_list: bool = False

		self.rsp_full_lock: bool = False

		self.queue_toast_plural: bool = False
		self.reload_theme: bool = False
		self.theme_number = 0
		self.toast_queue_object: TauonQueueItem | None = None
		self.toast_love_object = None
		self.toast_love_added: bool = True

		self.force_side_on_drag: bool = False
		self.last_left_panel_mode = "playlist"
		self.showing_l_panel: bool = False
		self.l_panel_h: int = 0
		self.l_panel_y: int = 0

		self.downloading_bass: bool = False
		self.d_click_ref = -1

		self.max_window_tex = max_window_tex # Both X and Y of maximal Tauon window texture size
		self.main_texture = main_texture
		self.main_texture_overlay_temp = main_texture_overlay_temp

		self.preview_artist: str = ""
		self.preview_artist_location = (0, 0)
		self.preview_artist_loading: str = ""
		self.mouse_left_window: bool = False

		self.rendered_playlist_position = 0
		self.playlist_view_length: int = 0

		self.show_album_ratings: bool = False
		self.album_rating_hover: bool = False
		self.scrollbar_active: bool = False
		self.scrollbar_interaction_lock: bool = False
		self.gen_code_errors: bool = False

		self.regen_single = -1
		self.regen_single_id = None

		self.tracklist_bg_is_light: bool = False
		self.clear_image_cache_next = 0

		self.click_time = time.time()

		self.column_d_click_timer = Timer(10)
		self.column_d_click_on = -1
		self.column_sort_ani_timer = Timer(10)
		self.column_sort_down_icon = asset_loader(self.bag, self.bag.loaded_asset_dc, "sort-down.png", True)
		self.column_sort_up_icon = asset_loader(self.bag, self.bag.loaded_asset_dc, "sort-up.png", True)
		self.column_sort_ani_direction = 1
		self.column_sort_ani_x = 0

		self.inc_arrow               = asset_loader(self.bag, self.bag.loaded_asset_dc, "inc.png", True)
		self.dec_arrow               = asset_loader(self.bag, self.bag.loaded_asset_dc, "dec.png", True)
		self.corner_icon             = asset_loader(self.bag, self.bag.loaded_asset_dc, "corner.png", True)
		self.heart_icon              = MenuIcon(asset_loader(self.bag, self.bag.loaded_asset_dc, "heart-menu.png", True))
		self.heart_row_icon          = asset_loader(self.bag, self.bag.loaded_asset_dc, "heart-track.png", True)
		self.heart_notify_icon       = asset_loader(self.bag, self.bag.loaded_asset_dc, "heart-notify.png", True)
		self.heart_notify_break_icon = asset_loader(self.bag, self.bag.loaded_asset_dc, "heart-notify-break.png", True)
		self.star_pc_icon            = asset_loader(self.bag, self.bag.loaded_asset_dc, "star-pc.png", True)
		self.star_row_icon           = asset_loader(self.bag, self.bag.loaded_asset_dc, "star.png", True)
		self.star_half_row_icon      = asset_loader(self.bag, self.bag.loaded_asset_dc, "star-half.png", True)

		self.heartx_icon        = MenuIcon(asset_loader(self.bag, self.bag.loaded_asset_dc, "heart-menu.png", True))
		self.transcode_icon     = MenuIcon(asset_loader(self.bag, self.bag.loaded_asset_dc, "transcode.png", True))
		self.mod_folder_icon    = MenuIcon(asset_loader(self.bag, self.bag.loaded_asset_dc, "mod_folder.png", True))
		self.settings_icon      = MenuIcon(asset_loader(self.bag, self.bag.loaded_asset_dc, "settings2.png", True))
		self.rename_tracks_icon = MenuIcon(asset_loader(self.bag, self.bag.loaded_asset_dc, "pen.png", True))
		self.add_icon           = MenuIcon(asset_loader(self.bag, self.bag.loaded_asset_dc, "new.png", True))

		self.filter_icon      = MenuIcon(asset_loader(self.bag, self.bag.loaded_asset_dc, "filter.png", True))
		self.folder_icon      = MenuIcon(asset_loader(self.bag, self.bag.loaded_asset_dc, "folder.png", True))
		self.info_icon        = MenuIcon(asset_loader(self.bag, self.bag.loaded_asset_dc, "info.png", True))
		self.delete_icon      = MenuIcon(asset_loader(self.bag, self.bag.loaded_asset_dc, "del.png", True))
		self.revert_icon      = MenuIcon(asset_loader(self.bag, self.bag.loaded_asset_dc, "revert.png", True))
		self.radiorandom_icon = MenuIcon(asset_loader(self.bag, self.bag.loaded_asset_dc, "radiorandom.png", True))

		self.last_fm_icon       = asset_loader(self.bag, self.bag.loaded_asset_dc, "as.png", True)
		self.power_bar_icon     = asset_loader(self.bag, self.bag.loaded_asset_dc, "power.png", True)
		self.mac_circle         = asset_loader(self.bag, self.bag.loaded_asset_dc, "macstyle.png", True)

		self.restore_showcase_view: bool = False
		self.restore_radio_view: bool = False

		self.tracklist_center_mode: bool = False
		self.tracklist_inset_left = 0
		self.tracklist_inset_width = 0
		self.tracklist_highlight_width = 0
		self.highlight_left = 0
		self.tracklist_highlight_left = 0

		self.hide_tracklist_in_gallery: bool = False

		self.saved_prime_tab = 0
		self.saved_prime_direction = 0

		self.stop_sync: bool = False
		self.sync_progress = ""
		self.sync_speed = ""

		self.bar_hover_timer = Timer()

		self.level_decay_timer = Timer()

		self.showed_title: bool = False

		self.to_get = 0 # Used to store temporary import count display
		self.to_got: int | str = 0
		self.switch_showcase_off: bool = False

		self.backend_reloading: bool = False

		self.tray_active: bool = False
		self.buffering: bool = False
		self.buffering_text = ""

		self.update_on_drag: bool = False
		self.pl_update_on_drag: bool = False
		self.drop_playlist_target = 0
		self.discord_status: str = "Standby"
		self.mouse_unknown: bool = False
		self.macstyle = self.bag.prefs.macstyle
		self.radio_view: bool = False
		self.window_size = self.bag.window_size
		self.box_over: bool = False
		self.suggest_clean_db: bool = False
		self.style_worker_timer: Timer = Timer()

		self.shuffle_was_showcase: bool = False
		self.shuffle_was_random: bool = True
		self.shuffle_was_repeat: bool = False

		self.was_radio: bool = False
		self.fullscreen: bool = False
		self.mouse_in_window: bool = True

		self.write_tag_in_progress: bool = False
		self.tag_write_count = 0
		# self.text_input_request = False
		# self.text_input_active = False
		self.center_blur_pixel = (0, 0, 0)
		self.cursor_hand = sdl3.SDL_CreateSystemCursor(sdl3.SDL_SYSTEM_CURSOR_POINTER)
		self.cursor_standard = sdl3.SDL_CreateSystemCursor(sdl3.SDL_SYSTEM_CURSOR_DEFAULT)
		self.cursor_shift = sdl3.SDL_CreateSystemCursor(sdl3.SDL_SYSTEM_CURSOR_EW_RESIZE)
		# General-purpose vertical-resize cursor, available on every platform
		# (unlike cursor_top_side which is only NS on Windows/X11).
		self.cursor_ns = sdl3.SDL_CreateSystemCursor(sdl3.SDL_SYSTEM_CURSOR_NS_RESIZE)
		self.cursor_text = sdl3.SDL_CreateSystemCursor(sdl3.SDL_SYSTEM_CURSOR_TEXT)

		self.cursor_br_corner   = self.cursor_standard
		self.cursor_right_side  = self.cursor_standard
		self.cursor_top_side    = self.cursor_standard
		self.cursor_left_side   = self.cursor_standard
		self.cursor_bottom_side = self.cursor_standard

		self.toast_length = 1


		if bag.windows:
			self.cursor_br_corner = sdl3.SDL_CreateSystemCursor(sdl3.SDL_SYSTEM_CURSOR_NWSE_RESIZE)
			self.cursor_right_side = self.cursor_shift
			self.cursor_left_side = self.cursor_shift
			self.cursor_top_side = sdl3.SDL_CreateSystemCursor(sdl3.SDL_SYSTEM_CURSOR_NS_RESIZE)
			self.cursor_bottom_side = self.cursor_top_side

class StarStore:
	"""Functions for reading and setting play counts"""

	def __init__(self, tauon: Tauon, pctl: PlayerCtl) -> None:
		self.tauon: Tauon    = tauon
		self.pctl: PlayerCtl = pctl
		self.prefs: Prefs    = tauon.prefs
		self.after_scan: list[TrackClass] = tauon.after_scan
		self.db: dict[tuple[str, str, str], StarRecord] = {}

	def key(self, track_id: int) -> tuple[str, str, str]:
		track_object = self.pctl.master_library[track_id]
		return track_object.artist, track_object.title, track_object.filename

	def object_key(self, track: TrackClass) -> tuple[str, str, str]:
		return track.artist, track.title, track.filename

	def add(self, index: int, value: float) -> None:
		"""Increments the play time"""
		track_object = self.pctl.master_library[index]

		if self.after_scan and track_object in self.after_scan:
			return

		key = track_object.artist, track_object.title, track_object.filename

		if key in self.db:
			self.db[key].playtime += value
			if value < 0 and self.db[key].playtime < 0:
				self.db[key].playtime = 0
		else:
			self.db[key] = StarRecord(playtime=value)

	def get(self, index: int) -> float:
		"""Returns the track play time"""
		if index < 0:
			return 0
		return self.db.get(self.key(index), StarRecord()).playtime

	def get_rating(self, index: int) -> int:
		"""Returns the track user rating"""
		key = self.key(index)
		if key in self.db:
			# self.db[key]
			return self.db[key].rating
		return 0

	def set_rating(self, index: int, value: int, write: bool = False) -> None:
		"""Sets the track user rating"""
		key = self.key(index)
		if key not in self.db:
			self.db[key] = StarRecord()
		self.db[key].rating = value

		tr = self.pctl.get_track(index)
		if tr.file_ext == "SUB":
			self.db[key].rating = math.ceil(value / 2) * 2
			shooter(self.tauon.subsonic.set_rating, (tr, value))

		if self.prefs.write_ratings and write:
			logging.info("Writing rating..")
			assert value <= 10
			assert value >= 0

			if tr.file_ext in ("OGG", "OPUS"):
				tag = mutagen.oggvorbis.OggVorbis(tr.fullpath)
				if value == 0:
					if "FMPS_RATING" in tag:
						del tag["FMPS_RATING"]
						tag.save()
				else:
					tag["FMPS_RATING"] = [f"{value / 10:.2f}"]
					tag.save()

			elif tr.file_ext == "MP3":
				tag = mutagen.id3.ID3(tr.fullpath)

				# if True:
				#	 if value == 0:
				#		 tag.delall("POPM")
				#	 else:
				#		 p_rating = 0
				#
				#	 tag.add(mutagen.id3.POPM(email="Windows Media Player 9 Series", rating=int))

				if value == 0:
					changed = False
					frames = tag.getall("TXXX")
					for i in reversed(range(len(frames))):
						if frames[i].desc.lower() == "fmps_rating":
							changed = True
					if changed:
						tag.delall("TXXX:FMPS_RATING")
						tag.save()
				else:
					changed = False
					frames = tag.getall("TXXX")
					for i in reversed(range(len(frames))):
						if frames[i].desc.lower() == "fmps_rating":
							frames[i].text = f"{value / 10:.2f}"
							changed = True
					if not changed:
						tag.add(
							mutagen.id3.TXXX(
								encoding=mutagen.id3.Encoding.UTF8, text=f"{value / 10:.2f}",
								desc="FMPS_RATING"))
					tag.save()

			elif tr.file_ext == "FLAC":
				audio = mutagen.flac.FLAC(tr.fullpath)
				tags = audio.tags
				if value == 0:
					if "FMPS_Rating" in tags:
						del tags["FMPS_Rating"]
						audio.save()
				else:
					tags["FMPS_Rating"] = f"{value / 10:.2f}"
					audio.save()

			tr.FMPS_Rating = float(value / 10)
			if value == 0:
				tr.FMPS_Rating = None

	def get_by_object(self, track: TrackClass) -> float:
		return self.db.get(self.object_key(track), StarRecord()).playtime

	def get_total(self) -> float:
		return sum(item.playtime for item in self.db.values())

	def full_get(self, index: int) -> StarRecord | None:
		return self.db.get(self.key(index))

	def remove(self, index: int) -> None:
		key = self.key(index)
		if key in self.db:
			del self.db[key]

	def insert(self, index: int, record: StarRecord) -> None:
		key = self.key(index)
		self.db[key] = record

	def merge(self, index: int, record: StarRecord | None) -> None:
		if record is None or record == StarRecord():
			return
		key = self.key(index)
		if key not in self.db:
			self.db[key] = record
		else:
			self.db[key].playtime += record.playtime
			self.db[key].rating = record.rating

class AlbumStarStore:

	def __init__(self, tauon: Tauon) -> None:
		self.db: dict[str, int] = {}
		self.subsonic: SubsonicService = SubsonicService(tauon=tauon, album_star_store=self)

	def get_key(self, track_object: TrackClass) -> str:
		artist = track_object.album_artist
		if not artist:
			artist = track_object.artist
		return artist + ":" + track_object.album

	def get_rating(self, track_object: TrackClass) -> int:
		return self.db.get(self.get_key(track_object), 0)

	def set_rating(self, track_object: TrackClass, rating: int) -> None:
		self.db[self.get_key(track_object)] = rating
		if track_object.file_ext == "SUB":
			self.db[self.get_key(track_object)] = math.ceil(rating / 2) * 2
			self.subsonic.set_album_rating(track_object, rating)

	def set_rating_artist_title(self, artist: str, album: str, rating: int) -> None:
		self.db[artist + ":" + album] = rating

	def get_rating_artist_title(self, artist: str, album: str) -> int:
		return self.db.get(artist + ":" + album, 0)

class Fonts:
	"""Used to hold font sizes (I forget to use this)"""

	def __init__(self) -> None:
		self.tabs = 211
		self.panel_title = 213

		self.side_panel_line1 = 214
		self.side_panel_line2 = 313

		self.bottom_panel_time = 212

		# if system == 'Windows':
		#	 self.bottom_panel_time = 12  # The Arial bold font is too big so just leaving this as normal. (lazy)

class Input:
	"""Used to keep track of button states (or should be)"""

	def __init__(self, gui: GuiVar) -> None:
		self.gui = gui
		self.ab_click:            bool = False
		self.d_mouse_click:       bool = False # Double click
		self.mouse_click:         bool = False
		self.middle_click:        bool = False
		self.right_click:         bool = False
		self.level_2_right_click: bool = False
		self.level_2_enter:       bool = False
		self.backspace_press:      int = 0
		self.mouse_wheel:        float = 0
		self.mouse_wheel_precise: bool = False
		self.mouse_down:          bool = False
		self.mouse_up:            bool = False
		self.right_down:          bool = False
		self.click_location:      list[int] = [200, 200]
		self.last_click_location: list[int] = [0, 0]
		self.mouse_position:      list[int] = [0, 0]
		# Active view transform: screen = local + view_offset. Non-zero only while
		# something renders a sub-view in its own local coordinate space (the
		# Custom Layout reframing a widget). Use to_screen()/to_local() to convert
		# between that local space and real screen coordinates.
		self.view_offset:         tuple[int, int] = (0, 0)
		self.mouse_up_position:   list[int] = [0, 0]
		self.touch_position:      list[int] = [0, 0]
		self.touch_scroll_y:     float = 0
		self.touch_active:        bool = False
		self.touch_released:      bool = False
		self.active_touch_id = None
		self.trackpad_scroll_mode_until: float = 0.0
		self.scroll_debug_last_mode: str = ""
		self.scroll_debug_last_log: float = 0.0
		self.drag_mode:           bool = False
		self.quick_drag:          bool = False
		self.clicked:             bool = False

		self.key_del:             bool = False
		self.key_c_press:         bool = False
		self.key_v_press:         bool = False
		#self.key_f_press:        bool = False
		self.key_a_press:         bool = False
		self.key_s_press:         bool = False
		#self.key_t_press:        bool = False
		self.key_z_press:         bool = False
		self.key_x_press:         bool = False
		self.key_backspace_press: bool = False
		self.key_home_press:      bool = False
		self.key_end_press:       bool = False

		self.k_input:             bool = True
		self.key_return_press:    bool = False
		self.key_tab_press:       bool = False
		self.key_down_press:      bool = False
		self.key_up_press:        bool = False
		self.key_right_press:     bool = False
		self.key_left_press:      bool = False
		self.key_esc_press:       bool = False

		self.key_shift_down:      bool = False
		self.key_shiftr_down:     bool = False
		self.key_ctrl_down:       bool = False
		self.key_rctrl_down:      bool = False
		self.key_meta:            bool = False
		self.key_ralt:            bool = False
		self.key_lalt:            bool = False

		self.global_clicked:      bool = False

		self.media_key = ""
		self.input_text = ""
		self.key_focused = 0

	def to_screen(self, x: float, y: float) -> tuple[float, float]:
		"""Convert a point from the active local view space to real screen
		coordinates. Identity unless a view transform is active."""
		ox, oy = self.view_offset
		return (x + ox, y + oy)

	def to_local(self, x: float, y: float) -> tuple[float, float]:
		"""Convert a real screen point into the active local view space."""
		ox, oy = self.view_offset
		return (x - ox, y - oy)

	def test_shift(self, _: int) -> bool:
		return self.key_shift_down or self.key_shiftr_down

	def m_key_play(self) -> None:
		self.media_key = "Play"
		self.gui.update += 1

	def m_key_pause(self) -> None:
		self.media_key = "Pause"
		self.gui.update += 1

	def m_key_stop(self) -> None:
		self.media_key = "Stop"
		self.gui.update += 1

	def m_key_next(self) -> None:
		self.media_key = "Next"
		self.gui.update += 1

	def m_key_previous(self) -> None:
		self.media_key = "Previous"
		self.gui.update += 1

class KeyMap:

	def __init__(self, bag: Bag, inp: Input) -> None:
		self.bag: Bag = bag
		self.inp: Input = inp
		self.hits: list[str | sdl3.SDL_Scancode] = []  # The keys hit this frame
		self.maps: dict[str, list[tuple[str | sdl3.SDL_Scancode, list[str]]]] = {}  # Loaded from input.txt

	def load(self) -> None:
		path = self.bag.dirs.config_directory / "input.txt"
		with path.open(encoding="utf_8") as f:
			content = f.read().splitlines()
			for p in content:
				if len(p) == 0 or len(p) > 100:
					continue
				if p[0] == " " or p[0] == "#":
					continue

				items = p.split()
				if 1 < len(items) < 5:
					function = items[0]

					if items[1] in ("MB4", "MB5"):
						key = items[1]
					else:
						if self.bag.prefs.use_scancodes:
							key = sdl3.SDL_GetScancodeFromName(items[1].encode())
						else:
							key = sdl3.SDL_GetKeyFromName(items[1].encode())
						if key == 0:
							continue

					mod: list[str] = []

					if len(items) > 2:
						mod.append(items[2].lower())
					if len(items) > 3:
						mod.append(items[3].lower())

					if function in self.maps:
						self.maps[function].append((key, mod))
					else:
						self.maps[function] = [(key, mod)]

	def test(self, function: str) -> bool:
		inp = self.inp
		if not self.hits:
			return False
		if function not in self.maps:
			return False

		for code, mod in self.maps[function]:
			if code in self.hits:
				ctrl = (inp.key_ctrl_down or inp.key_rctrl_down) * 1
				shift = (inp.key_shift_down or inp.key_shiftr_down) * 10
				alt = (inp.key_lalt or inp.key_ralt) * 100

				if ctrl + shift + alt == ("ctrl" in mod) * 1 + ("shift" in mod) * 10 + ("alt" in mod) * 100:
					return True
		return False

class ColoursClass:
	"""Used to store colour values for UI elements

	These are changed for themes
	"""

	def grey(self, value: int) -> ColourRGBA:
		return ColourRGBA(value, value, value, 255)

	def alpha_grey(self, value: int) -> ColourRGBA:
		return ColourRGBA(255, 255, 255, value)

	def grey_blend_bg(self, value: int) -> ColourRGBA:
		return alpha_blend(ColourRGBA(255, 255, 255, value), self.box_background)

	def __init__(self) -> None:
		self.deco: str | None = None
		self.column_colours: dict[str, ColourRGBA] = {}
		self.column_colours_playing: dict[str, ColourRGBA] = {}

		self.last_album = ""
		self.link_text = ColourRGBA(100, 200, 252, 255)

		self.tb_line = self.grey(21)  # not currently used
		self.art_box = self.grey(24)

		self.volume_bar_background = self.grey(30)
		self.volume_bar_fill = self.grey(125)
		self.seek_bar_background = self.grey(30)
		self.seek_bar_fill = self.grey(80)

		self.tab_text_active = self.grey(230)
		self.tab_text = self.grey(215)
		self.tab_background = self.grey(25)
		self.tab_highlight = self.grey(40)
		self.tab_background_active = self.grey(45)

		self.title_text = ColourRGBA(190, 190, 190, 255)
		self.index_text = self.grey(70)
		self.time_text = self.grey(180)
		self.artist_text = ColourRGBA(195, 255, 104, 255)
		self.album_text = ColourRGBA(245, 240, 90, 255)

		self.index_playing = self.grey(190)
		self.artist_playing = ColourRGBA(195, 255, 104, 255)
		self.album_playing = ColourRGBA(245, 240, 90, 255)
		self.title_playing = self.grey(230)

		self.time_playing = ColourRGBA(180, 194, 107, 255)

		self.playlist_text_missing = self.grey(85)
		self.bar_time = self.grey(70)

		self.top_panel_background = self.grey(15)
		self.status_text_over: ColourRGBA | None = None
		self.status_text_normal: ColourRGBA | None = None


		self.side_panel_background = self.grey(18)
		self.lyrics_panel_background: ColourRGBA | None = None
		self.gallery_background = self.side_panel_background
		self.playlist_panel_background = self.grey(21)
		self.bottom_panel_colour = self.grey(15)

		self.row_playing_highlight = ColourRGBA(255, 255, 255, 4)
		self.row_select_highlight = ColourRGBA(255, 255, 255, 5)

		self.side_bar_line1 = self.grey(230)
		self.side_bar_line2 = self.grey(210)

		self.mode_button_off = self.grey(50)
		self.mode_button_over = self.grey(200)
		self.mode_button_active = self.grey(190)

		self.media_buttons_over = self.grey(220)
		self.media_buttons_active = self.grey(220)
		self.media_buttons_off = self.grey(55)

		self.star_line = ColourRGBA(100, 100, 100, 255)
		self.star_line_playing: ColourRGBA | None = None
		self.folder_title = ColourRGBA(130, 130, 130, 255)
		self.folder_line  = ColourRGBA(40, 40, 40, 255)

		self.scroll_colour = ColourRGBA(45, 45, 45, 255)

		self.level_1_bg   = ColourRGBA(0, 30, 0, 255)
		self.level_2_bg   = ColourRGBA(30, 30, 0, 255)
		self.level_3_bg   = ColourRGBA(30, 0, 0, 255)
		self.level_green  = ColourRGBA(20, 120, 20, 255)
		self.level_red    = ColourRGBA(190, 30, 30, 255)
		self.level_yellow = ColourRGBA(135, 135, 30, 255)

		self.vis_colour = self.grey(200)
		self.vis_bg = ColourRGBA(0, 0, 0, 255)

		self.menu_background: ColourRGBA | None = None  # self.grey(12)
		self.menu_highlight_background: ColourRGBA | None = None
		self.menu_text = ColourRGBA(230, 230, 230, 255)
		self.menu_text_disabled = self.grey(50)
		self.menu_icons = ColourRGBA(255, 255, 255, 25)
		self.menu_tab = self.grey(30)

		self.gallery_highlight = self.artist_playing

		self.status_info_text = ColourRGBA(245, 205, 0, 255)
		self.lyrics = self.grey(245)
		self.active_lyric = ColourRGBA(255, 210, 50, 255)

		self.corner_button        = ColourRGBA(255, 255, 255, 50)  # [60, 60, 60, 255]
		self.corner_button_active = ColourRGBA(255, 255, 255, 230)  # [230, 230, 230, 255]

		self.window_buttons_bg        = ColourRGBA(0, 0, 0, 50)
		self.window_buttons_bg_over   = ColourRGBA(255, 255, 255, 10)  # [80, 80, 80, 120]
		self.window_buttons_icon_over = ColourRGBA(255, 255, 255, 60)
		self.window_button_icon_off   = ColourRGBA(255, 255, 255, 40)
		self.window_button_x_on: ColourRGBA | None = None
		self.window_button_x_off = self.window_button_icon_off

		self.message_box_bg = self.grey(0)
		self.message_box_text = self.grey(230)

		self.lm = False

		self.pulse_colour = ColourRGBA(244, 212, 66, 255)

		self.mini_mode_background = ColourRGBA(19, 19, 19, 255)
		self.mini_mode_border     = ColourRGBA(45, 45, 45, 255)
		self.mini_mode_text_1     = ColourRGBA(255, 255, 255, 240)
		self.mini_mode_text_2     = ColourRGBA(255, 255, 255, 77)

		self.queue_drag_indicator_colour = ColourRGBA(200, 50, 240, 255)

		self.playlist_box_background = self.side_panel_background

		self.bar_title_text = None

		self.corner_icon = ColourRGBA(40, 40, 40, 255)
		self.queue_background: ColourRGBA | None = None  # self.side_panel_background #self.grey(18) # 18
		self.queue_card_background = self.grey(23)

		self.column_bar_background = ColourRGBA(30, 30, 30, 255)
		self.column_grip           = ColourRGBA(255, 255, 255, 14)
		self.column_bar_text       = ColourRGBA(240, 240, 240, 255)

		self.window_frame = ColourRGBA(30, 30, 30, 255)

		self.box_background = ColourRGBA(16, 16, 16, 255)
		self.box_border = rgb_add_hls(self.box_background, 0, 0.17, 0)
		self.box_text_border = rgb_add_hls(self.box_background, 0, 0.1, 0)
		self.box_text_label = rgb_add_hls(self.box_background, 0, 0.32, -0.1)
		self.box_check_border = ColourRGBA(255, 255, 255, 18)

		self.box_title_text = self.grey(245)
		self.box_text = self.grey(240)
		self.box_sub_text = self.grey_blend_bg(225)
		self.box_input_text = self.grey(225)
		self.box_button_text_highlight = self.grey(250)
		self.box_button_text = self.grey(225)
		self.box_button_background = alpha_blend(ColourRGBA(255, 255, 255, 11), self.box_background)
		self.box_thumb_background: ColourRGBA | None = None
		self.box_button_background_highlight = alpha_blend(ColourRGBA(255, 255, 255, 20), self.box_background)

		self.artist_bio_background = ColourRGBA(27, 27, 27, 255)
		self.artist_bio_text       = ColourRGBA(230, 230, 230, 255)

	def apply_transparency(self) -> None:
		self.top_panel_background.a = 140
		self.side_panel_background.a = 140
		self.art_box.a = 100
		self.window_frame.a = 100
		self.bottom_panel_colour.a = 200

		# colours.playlist_panel_background.a = 220
		# colours.playlist_box_background  = [0, 0, 0, 100]

	def post_config(self) -> None:
		if self.box_thumb_background is None:
			self.box_thumb_background = alpha_mod(self.box_button_background, 175)

		if self.lyrics_panel_background is None:
			self.lyrics_panel_background = self.side_panel_background
		if self.status_text_over is None:
			self.status_text_over = rgb_add_hls(self.top_panel_background, 0, 0.83, 0)
		if self.status_text_normal is None:
			self.status_text_normal = rgb_add_hls(self.top_panel_background, 0, 0.30, -0.15)

		# Pre calculate alpha blend for spec background
		self.vis_bg.r = int(0.05 * 255 + (1 - 0.05) * self.top_panel_background.r)
		self.vis_bg.g = int(0.05 * 255 + (1 - 0.05) * self.top_panel_background.g)
		self.vis_bg.b = int(0.05 * 255 + (1 - 0.05) * self.top_panel_background.b)
		self.vis_bg.a = int(0.05 * 255 + (1 - 0.05) * self.top_panel_background.a)

		self.message_box_bg = self.box_background
		self.sys_tab_bg = self.tab_background
		self.sys_tab_hl = self.tab_background_active
		self.toggle_box_on = self.folder_title
		self.toggle_box_on = ColourRGBA(255, 150, 100, 255)
		self.toggle_box_on = self.artist_playing
		if colour_value(self.toggle_box_on) < 150:
			self.toggle_box_on = ColourRGBA(160, 160, 160, 255)
		# self.time_sub = [255, 255, 255, 80]#alpha_blend(ColourRGBA(255, 255, 255, 80), self.bottom_panel_colour)

		self.time_sub = rgb_add_hls(self.bottom_panel_colour, 0, 0.29, 0)

		if test_lumi(self.bottom_panel_colour) < 0.2:
			# self.time_sub = [0, 0, 0, 80]
			self.time_sub = rgb_add_hls(self.bottom_panel_colour, 0, -0.15, -0.3)
		elif test_lumi(self.bottom_panel_colour) < 0.8:
			self.time_sub = ColourRGBA(255, 255, 255, 135)
		# self.time_sub = self.mode_button_off

		if self.bar_title_text is None:
			self.bar_title_text = self.side_bar_line1

		self.gallery_artist_line = alpha_mod(self.side_bar_line2, 120)

		if self.menu_highlight_background is None:
			self.menu_highlight_background = ColourRGBA(40, 40, 40, 255)

		if not self.queue_background:
			self.queue_background = self.side_panel_background

		if test_lumi(self.queue_background) > 0.8:
			self.queue_card_background = alpha_blend(ColourRGBA(255, 255, 255, 10), self.queue_background)

		if self.menu_background is None and not self.lm:
			self.menu_background = self.bottom_panel_colour

		self.message_box_text = self.box_text
		self.message_box_border = self.box_border

		if self.window_button_x_on is None:
			self.window_button_x_on = self.artist_playing

		if test_lumi(self.column_bar_background) < 0.4:
			self.column_bar_text = ColourRGBA(40, 40, 40, 200)
			self.column_grip     = ColourRGBA(255, 255, 255, 20)

	def light_mode(self) -> None:
		self.lm = True
		self.star_line_playing = ColourRGBA(255, 255, 255, 255)
		self.sys_tab_bg = self.grey(25)
		self.sys_tab_hl = self.grey(45)
		# self.box_background = self.grey(30)
		self.toggle_box_on = self.tab_background_active
		# if colour_value(self.tab_background_active) < 250:
		#	self.toggle_box_on = [255, 255, 255, 200]

		# self.time_sub = [0, 0, 0, 200]
		self.gallery_artist_line = self.grey(40)
		# self.bar_title_text = self.grey(30)
		self.status_text_normal = self.grey(70)
		self.status_text_over = self.grey(40)
		self.status_info_text = ColourRGBA(40, 40, 40, 255)

		# self.bar_title_text = self.grey(255)
		self.vis_bg = ColourRGBA(235, 235, 235, 255)
		# self.menu_background = [240, 240, 240, 250]
		# self.menu_text = self.grey(40)
		# self.menu_text_disabled = self.grey(180)
		# self.menu_highlight_background = [200, 200, 200, 250]
		if self.menu_background is None:
			self.menu_background = ColourRGBA(15, 15, 15, 250)
		if not self.menu_icons:
			self.menu_icons = ColourRGBA(0, 0, 0, 40)

		# self.menu_background = [40, 40, 40, 250]
		# self.menu_text = self.grey(220)
		# self.menu_text_disabled = self.grey(120)
		# self.menu_highlight_background = [120, 80, 220, 250]

		self.corner_button = self.grey(160)
		self.corner_button_active = self.grey(35)
		# self.window_buttons_bg = ColourRGBA(0, 0, 0, 5]
		self.message_box_bg = ColourRGBA(245, 245, 245, 255)
		self.message_box_text = self.grey(20)
		self.message_box_border = self.grey(40)
		self.gallery_background = self.grey(230)
		self.gallery_artist_line = self.grey(40)
		self.pulse_colour = ColourRGBA(212, 66, 244, 255)

		# tauon.view_box.off_colour = self.grey(200)

_MISC_TO_FIELD = {
	"album_artists": "album_artists", "artists": "artists", "artist_sort": "artist_sort",
	"codec": "codec", "container": "container", "FMPS_Rating": "FMPS_Rating", "genres": "genres",
	"musicbrainz_albumid": "musicbrainz_albumid", "musicbrainz_artistids": "musicbrainz_artistids",
	"musicbrainz_recordingid": "musicbrainz_recordingid",
	"musicbrainz_releasegroupid": "musicbrainz_releasegroupid",
	"musicbrainz_trackid": "musicbrainz_trackid",
	"parent-length": "parent_length", "parent-size": "parent_size", "POPM": "POPM",
	"position": "position", "rdat": "rdat",
	"replaygain_album_gain": "replaygain_album_gain", "replaygain_album_peak": "replaygain_album_peak",
	"replaygain_track_gain": "replaygain_track_gain", "replaygain_track_peak": "replaygain_track_peak",
	"subsonic-folder-id": "subsonic_folder_id", "tidal_album": "tidal_album",
}

class TrackClass:
	"""The fundamental object/data structure of a track"""

	__slots__ = [
		"index", "subtrack", "fullpath", "filename", "parent_folder_path", "parent_folder_name",
		"file_ext", "size", "modified_time",
		"is_network", "url_key", "art_url_key",
		"artist", "album_artist", "title", "composer", "length", "bitrate", "samplerate", "bit_depth",
		"album", "date", "track_number", "track_total", "start_time", "is_cue", "is_embed_cue",
		"cue_sheet", "genre", "found", "skips", "comment", "disc_number", "disc_total", "lyrics", "synced",
		"lfm_friend_likes", "lfm_scrobbles",
		"album_artists", "artists", "artist_sort", "codec", "container", "FMPS_Rating", "genres",
		"musicbrainz_albumid", "musicbrainz_artistids", "musicbrainz_recordingid",
		"musicbrainz_releasegroupid", "musicbrainz_trackid", "parent_length", "parent_size", "POPM",
		"position", "rdat", "replaygain_album_gain", "replaygain_album_peak",
		"replaygain_track_gain", "replaygain_track_peak", "subsonic_folder_id", "tidal_album",
	]

	def __init__(self) -> None:
		self.index:              int = 0
		self.subtrack:           int = 0
		self.fullpath:           str = ""
		self.filename:           str = ""
		self.parent_folder_path: str = ""
		self.parent_folder_name: str = ""
		self.file_ext:           str = ""
		self.size:               int = 0
		self.modified_time:      float = 0

		self.is_network:   bool = False
		self.url_key:      str = ""
		self.art_url_key:  str = ""

		self.artist:       str = ""
		self.album_artist: str = ""
		self.title:        str = ""
		self.composer:     str = ""
		self.length:     float = 0
		self.bitrate:      int = 0
		self.samplerate:   int = 0
		self.bit_depth:    int = 0
		self.album:        str = ""
		self.date:         str = ""
		self.track_number: str = ""
		self.track_total:  str = ""
		self.start_time:   int = 0
		self.is_cue:       bool = False
		self.is_embed_cue: bool = False
		self.cue_sheet:    str = ""
		self.genre:        str = ""
		self.found:        bool = True
		self.skips:        int = 0
		self.comment:      str = ""
		self.disc_number:  str = ""
		self.disc_total:   str = ""
		self.lyrics:       str = ""
		self.synced:       str = ""

		self.lfm_friend_likes   = set()
		self.lfm_scrobbles: int = 0

		self.album_artists = None
		self.artists = None
		self.artist_sort = None
		self.codec = None
		self.container = None
		self.FMPS_Rating = None
		self.genres = None
		self.musicbrainz_albumid = None
		self.musicbrainz_artistids = None
		self.musicbrainz_recordingid = None
		self.musicbrainz_releasegroupid = None
		self.musicbrainz_trackid = None
		self.parent_length = None
		self.parent_size = None
		self.POPM = None
		self.position = None
		self.rdat = None
		self.replaygain_album_gain = None
		self.replaygain_album_peak = None
		self.replaygain_track_gain = None
		self.replaygain_track_peak = None
		self.subsonic_folder_id = None
		self.tidal_album = None

# Low-cardinality string fields that repeat heavily across a library (many
# tracks share the same album/artist/folder/etc). Interning collapses the
# duplicates onto shared objects to save memory. High-cardinality fields
# (title, fullpath, filename, mbids, lyrics...) are deliberately excluded:
# they barely dedup, so interning them would just waste effort.
_INTERN_FIELDS = (
	"parent_folder_path", "parent_folder_name", "file_ext",
	"artist", "album_artist", "album", "artist_sort", "composer",
	"genre", "date", "codec", "container",
	"track_number", "track_total", "disc_number", "disc_total",
)
# List-valued fields whose repeated string elements are worth deduplicating.
_INTERN_LIST_FIELDS = ("artists", "album_artists", "genres")

def intern_track_strings(tr: TrackClass) -> None:
	"""Deduplicate repeated low-cardinality string fields via sys.intern.

	Interned strings are reclaimed once no track references them, so this is
	safe to run on every track load without leaking as the library changes.
	"""
	for field in _INTERN_FIELDS:
		value = getattr(tr, field)
		if type(value) is str and value:
			setattr(tr, field, sys.intern(value))
	for field in _INTERN_LIST_FIELDS:
		value = getattr(tr, field)
		if value:
			setattr(tr, field, [sys.intern(v) if type(v) is str else v for v in value])

# Extended-metadata fields shared by TrackFile (the tag scanner) and
# TrackClass. TrackFile mirrors these by name, so import copies them straight
# across instead of remapping a "misc" dict.
_TRACKFILE_METADATA_FIELDS = (
	"artists", "album_artists", "artist_sort", "genres",
	"musicbrainz_artistids", "musicbrainz_recordingid", "musicbrainz_trackid",
	"musicbrainz_albumid", "musicbrainz_releasegroupid",
	"replaygain_track_gain", "replaygain_track_peak",
	"replaygain_album_gain", "replaygain_album_peak",
	"FMPS_Rating", "rdat",
)

def copy_trackfile_metadata(nt: TrackClass, audio: TrackFile) -> None:
	"""Copy the shared extended-metadata fields from a scanned TrackFile onto a TrackClass."""
	for field in _TRACKFILE_METADATA_FIELDS:
		setattr(nt, field, getattr(audio, field))

class LoadClass:
	"""Object for import track jobs (passed to worker thread)"""

	def __init__(self) -> None:
		self.target:            str = ""
		self.playlist:          int = 0  # Playlist UID
		self.tracks:            list[TrackClass] = []
		self.stage:             int = 0
		self.playlist_position: int | None = None
		self.replace_stem:      bool = False
		self.notify:            bool = False
		self.play:              bool = False
		self.force_scan:        bool = False

class MOD(Structure):
	"""Access functions from libopenmpt for scanning tracker files"""

	_fields_ = [("ctl", c_char_p), ("value", c_char_p)]

class GMETrackInfo(Structure):
	_fields_ = [
		("length", c_int),
		("intro_length", c_int),
		("loop_length", c_int),
		("play_length", c_int),
		("fade_length", c_int),
		("i5", c_int),
		("i6", c_int),
		("i7", c_int),
		("i8", c_int),
		("i9", c_int),
		("i10", c_int),
		("i11", c_int),
		("i12", c_int),
		("i13", c_int),
		("i14", c_int),
		("i15", c_int),
		("system", c_char_p),
		("game", c_char_p),
		("song", c_char_p),
		("author", c_char_p),
		("copyright", c_char_p),
		("comment", c_char_p),
		("dumper", c_char_p),
		("s7", c_char_p),
		("s8", c_char_p),
		("s9", c_char_p),
		("s10", c_char_p),
		("s11", c_char_p),
		("s12", c_char_p),
		("s13", c_char_p),
		("s14", c_char_p),
		("s15", c_char_p),
	]

class WinTask:
	def __init__(self, tauon: Tauon, pctl: PlayerCtl) -> None:
		self.pctl: PlayerCtl = pctl
		self.prefs: Prefs = tauon.prefs
		self.tauon: Tauon = tauon
		self.start: float = time.time()
		self.updated_state = 0

	def update(self) -> None:

		# URL streams have no known duration, so show no progress for them either
		if self.pctl.playing_state in (PlayingState.STOPPED, PlayingState.URL_STREAM) and self.updated_state != 0:
			self.updated_state = 0
			sdl3.SDL_SetWindowProgressValue(self.tauon.t_window, 0.0)
			sdl3.SDL_SetWindowProgressState(self.tauon.t_window, sdl3.SDL_PROGRESS_STATE_NONE)

		elif self.prefs.taskbar_progress:

			if self.pctl.playing_state == PlayingState.PLAYING:
				if self.updated_state != 1:
					sdl3.SDL_SetWindowProgressState(self.tauon.t_window, sdl3.SDL_PROGRESS_STATE_NORMAL)

				self.updated_state = 1
				if self.pctl.playing_length > 1.1:
					frac = self.pctl.playing_time / self.pctl.playing_length
				else:
					frac = 0.0

				frac = min(max(frac, 0.0), 1.0)
				sdl3.SDL_SetWindowProgressValue(self.tauon.t_window, frac)

			elif self.pctl.playing_state == PlayingState.PAUSED and self.updated_state != 2:
				self.updated_state = 2
				sdl3.SDL_SetWindowProgressState(self.tauon.t_window, sdl3.SDL_PROGRESS_STATE_PAUSED)


class PlayerCtl:
	"""Main class that controls playback (play, pause, stepping, playlists, queue etc). Sends commands to backend."""

	# C-PC
	def __init__(self, tauon: Tauon) -> None:
		self.tauon: Tauon                     = tauon
		self.inp: Input                       = self.tauon.inp
		self.gui: GuiVar                      = self.tauon.gui
		self.bag: Bag                         = self.tauon.bag
		self.colours: ColoursClass            = self.tauon.colours
		self.smtc: bool                       = self.tauon.bag.smtc
		self.show_message              = self.tauon.show_message
		self.star_store: StarStore            = StarStore(tauon=tauon, pctl=self)
		self.draw: Drawing                    = Drawing(tauon=tauon, pctl=self)
		self.radiobox: RadioBox               = RadioBox(tauon=tauon, pctl=self)
		self.mini_lyrics_scroll: ScrollBox    = ScrollBox(tauon=tauon, pctl=self)
		self.playlist_panel_scroll: ScrollBox = ScrollBox(tauon=tauon, pctl=self)
		self.artist_info_scroll: ScrollBox    = ScrollBox(tauon=tauon, pctl=self)
		self.device_scroll: ScrollBox         = ScrollBox(tauon=tauon, pctl=self)
		self.artist_list_scroll: ScrollBox    = ScrollBox(tauon=tauon, pctl=self)
		self.gallery_scroll: ScrollBox        = ScrollBox(tauon=tauon, pctl=self)
		self.tree_view_scroll: ScrollBox      = ScrollBox(tauon=tauon, pctl=self)
		self.radio_view_scroll: ScrollBox     = ScrollBox(tauon=tauon, pctl=self)
		self.tree_view_box: TreeView          = TreeView(tauon=tauon, pctl=self)
		self.windows: bool                       = self.tauon.windows
		self.queue_box: QueueBox              = QueueBox(tauon=tauon, pctl=self)
		self.running:                    bool = True
		self.prefs: Prefs                     = self.bag.prefs
		self.sm: CDLL | None                  = self.bag.sm
		self.lastfm: LastFMapi                = LastFMapi(tauon=tauon, pctl=self)
		self.lfm_scrobbler: LastScrob         = LastScrob(tauon=tauon, pctl=self)
		self.artist_info_box: ArtistInfoBox   = ArtistInfoBox(tauon=tauon, pctl=self)
		self.artist_list_box: ArtistList      = ArtistList(tauon=tauon, pctl=self)
		self.install_directory: Path          = self.bag.dirs.install_directory
		self.loading_in_progress:        bool = False
		self.album_dex: list[int]                 = self.tauon.album_dex

		self.cargo: list[int]          = []
		# Database

		self.master_count: int = self.bag.master_count
		self.total_playtime: float = 0
		self.master_library: dict[int, TrackClass] = self.bag.master_library
		# Lets clients know when to invalidate cache
		self.db_inc: int = random.randint(0, 10000)
		# self.star_library = star_library
		self.LoadClass = LoadClass

		self.gen_codes: dict[int, str] = self.bag.gen_codes

		self.shuffle_pools: dict[int, list[int]] = {}
		self.after_import_flag = False
		self.quick_add_target = None

		self.album_mbid_release_cache = {}
		self.album_mbid_release_group_cache = {}
		self.mbid_image_url_cache = {}

		# ----------------------------------------
		# Misc player control

		self.url: str = ""
		# self.save_urls = url_saves
		self.tag_meta: str = ""
		self.found_tags: dict[str, str] = {}
		#self.encoder_pause = 0

		# Playback

		self.track_queue: list[int] = self.bag.track_queue
		self.default_playlist: list[int] = []
		self.queue_step: int = self.bag.playing_in_queue
		self.playing_time: float = 0
		self.last_real_position: float = 0
		self.playlist_playing_position: int = self.bag.playlist_playing  # track in playlist that is playing
		if self.playlist_playing_position is None:
			self.playlist_playing_position = -1
		self.playlist_view_position: int = self.bag.playlist_view_position
		self.selected_in_playlist: int = self.bag.selected_in_playlist
		self.target_open: str = ""
		self.target_object: TrackClass | None = None
		self.start_time = 0
		self.b_start_time = 0
		self.playerCommand: str = ""
		self.playerSubCommand: str = ""
		self.playerCommandReady: bool = False
		self.playing_state: PlayingState = PlayingState.STOPPED
		self.playing_length: float = 0
		self.jump_time:      float = 0.0
		self.random_mode:        bool = self.prefs.random_mode
		self.repeat_mode:        bool = self.prefs.repeat_mode
		self.album_repeat_mode:  bool = self.prefs.album_repeat_mode
		self.album_shuffle_mode: bool = self.prefs.album_shuffle_mode
		# self.album_shuffle_pool = []
		# self.album_shuffle_id = ""
		self.last_playing_time: float = 0
		self.multi_playlist: list[TauonPlaylist] = self.bag.multi_playlist
		self.active_playlist_viewing: int = self.bag.active_playlist_viewing  # the playlist index that is being viewed
		self.active_playlist_playing: int = self.bag.active_playlist_playing  # the playlist index that is playing from
		self.force_queue: list[TauonQueueItem] = self.bag.p_force_queue
		self.pause_queue: bool = False
		self.left_time: float = 0
		self.left_index: int = 0
		self.player_volume: float = self.bag.volume
		self.volume_store: float = 50  # Used to save the previous volume when muted
		self.new_time: float = 0
		#self.time_to_get = []
		self.a_time: float = 0
		self.b_time: float = 0
		# self.playlist_backup = []
		self.active_replaygain: int = 0
		self.stop_mode: StopMode = StopMode.OFF
		self.stop_ref: tuple[str, str] | None = None

		self.record_stream: bool = False
		self.record_title: str = ""

		#self.gst_devices = []  # Display names
		#self.gst_outputs = {}  # Display name : (sink, device)
		self.mpris: MPRIS | None = None
		self.tray_update = None
		self.eq = [0] * 2  # not used
		self.enable_eq = True  # not used

		self.playing_time_int = 0  # playing time but with no decimel
		self.ab_repeat_a: float = -1.0
		self.ab_repeat_b: float = -1.0

		self.windows_progress = WinTask(tauon, self)

		self.finish_transition = False
		# self.queue_target = 0
		self.start_time_target = 0

		self.decode_time = 0
		self.download_time = 0

		self.radio_meta_on = ""

		self.radio_scrobble_trip = True
		self.radio_scrobble_timer = Timer()

		self.radio_image_bin = None
		self.radio_rate_timer = Timer(2)
		self.radio_poll_timer = Timer(2)

		self.volume_update_timer = Timer()
		self.wake_past_time = 0

		self.regen_in_progress = False
		self.notify_in_progress = False
		self.mac_nowplaying_art_generation = 0
		self.mac_nowplaying_art_track_index = -1
		self.mac_nowplaying_art_path = ""
		self.mac_nowplaying_art_ready = False

		self.radio_playlists = self.bag.radio_playlists
		self.radio_playlist_viewing = self.bag.radio_playlist_viewing
		self.tag_history: dict[str, dict[str, str]] = {}

		self.commit: int | None = None

		self.buffering_percent = 0

	# def re_import(pl: int) -> None:
	#
	#	 path = pctl.multi_playlist[pl].last_folder
	#	 if path == "":
	#		 return
	#	 for i in reversed(range(len(pctl.multi_playlist[pl].playlist_ids))):
	#		 if path.replace('\\', '/') in pctl.master_library[pctl.multi_playlist[pl].playlist_ids[i]].parent_folder_path:
	#			 del pctl.multi_playlist[pl].playlist_ids[i]
	#
	#	 load_order = LoadClass()
	#	 load_order.replace_stem = True
	#	 load_order.target = path
	#	 load_order.playlist = pctl.multi_playlist[pl].uuid_int
	#	 tauon.load_orders.append(copy.deepcopy(load_order))

	def resolve_full_playlist_path(self, playlist: TauonPlaylist, get_name: bool = False) -> str:

		target = playlist.playlist_file
		if target.endswith(("/", "\\")):
			name = filename_safe(playlist.title)
			if not name:
				name = str(playlist.uuid_int)
			target += name
			if playlist.export_type == "xspf":
				target += ".xspf"
			if playlist.export_type == "m3u":
				target += ".m3u"
		if get_name:
			path = Path(target)
			return path.name
		return target


	def index_key(self, index: int) -> (list[int | str] | Literal["a"]):
		tr = self.master_library[index]
		s = str(tr.track_number)
		d = str(tr.disc_number)

		if "/" in d:
			d = d.split("/", maxsplit=1)[0]

		# Make sure the value for disc number is an int, make 1 if 0, otherwise ignore
		if d:
			try:
				dd = int(d)
				if dd < 2:
					dd = 1
				d = str(dd)
			except ValueError:
				logging.debug(f"Failed to parse disc_number '{tr.disc_number}' as int, using an empty string instead")
				d = ""
			except Exception:
				logging.exception(f"Unknown exception parsing disc_number '{tr.disc_number}' as int")
				d = ""


		# Add the disc number for sorting by CD, make it '1' if there isn't one
		if s or d:
			s = f"1d{s}" if not d else f"{d}d{s}"
		# Use the filename if we dont have any metadata to sort by,
		# since it could likely have the track number in it
		else:
			s = tr.filename

		if (not tr.disc_number or tr.disc_number == "0") and tr.is_cue:
			s = tr.filename + "-" + s

		# This splits the line by groups of numbers, causing the sorting algorithm to sort
		# by those numbers. Should work for filenames, even with the disc number in the name
		try:
			return [tryint(c) for c in re.split(r"(\d+)", s)]
		except Exception:
			logging.exception("Failed to parse as int, returning 'a'")
			return "a"

	def re_import2(self, pl: int) -> None:
		paths = self.multi_playlist[pl].last_folder

		reduce_paths(paths)

		for path in paths:
			if os.path.isdir(path):
				load_order = LoadClass()
				load_order.replace_stem = True
				load_order.target = path
				load_order.notify = True
				load_order.playlist = self.multi_playlist[pl].uuid_int
				self.tauon.load_orders.append(copy.deepcopy(load_order))

		if paths:
			self.show_message(_("Rescanning folders..."), mode="info")

	def rescan_all_folders(self) -> None:
		for i, p in enumerate(self.multi_playlist):
			self.re_import2(i)
		self.tauon.playlist_autoscan = True
		self.tauon.thread_manager.ready("worker")


	def try_reload_playlist_from_file(self, playlist: TauonPlaylist, _warnings: bool = False) -> None:
		"""Reload designated playlist from file if it meets the requirements"""
		if not playlist.auto_import:
			return

		code = self.gen_codes.get(playlist.uuid_int)
		if code and "self" not in code:
			logging.warning(f"Playlist to import has a generator!: {playlist.title}")
			return

		path = Path(self.resolve_full_playlist_path(playlist))
		if not path.exists() or not path.is_file():
			logging.error(f"Playlist file not found: {path}")
			return
		try:
			current_size = path.stat().st_size
		except FileNotFoundError:
			logging.error(f"Playlist file not found: {path}")  # noqa: TRY400
			return
		except Exception:
			logging.exception("Unknown exception!")
			return

		if current_size != playlist.file_size:
			logging.info(f"Reload playlist from changed file: {playlist.title}")
			if playlist.export_type == "m3u":
				p, stations = self.tauon.parse_m3u(str(path))
				playlist.playlist_ids[:] = p[:]

			elif playlist.export_type == "xspf":
				p, stations, _name = self.tauon.parse_xspf(str(path))
				playlist.playlist_ids[:] = p[:]

			playlist.file_size = path.stat().st_size
			if stations:
				self.tauon.add_stations(stations, playlist.title)


	def switch_playlist(self, number: int, cycle: bool = False, quiet: bool = False) -> None:
		# Close any active menus
		# for instance in Menu.instances:
		# 	instance.active = False
		close_all_menus()
		if self.gui.radio_view:
			if cycle:
				self.radio_playlist_viewing += number
			else:
				self.radio_playlist_viewing = number
			if self.radio_playlist_viewing > len(self.radio_playlists) - 1:
				self.radio_playlist_viewing = 0
			return

		self.gui.previous_playlist_id = self.multi_playlist[self.active_playlist_viewing].uuid_int

		self.gui.pl_update = 1
		self.gui.search_index = 0
		self.gui.column_d_click_on = -1
		self.gui.search_error = False
		if self.gui.quick_search_mode:
			self.gui.force_search = True

		# if pl_follow:
		# 	self.multi_playlist[self.playlist_active][1] = copy.deepcopy(self.playlist_playing)

		if self.gui.showcase_mode and self.gui.combo_mode and not quiet:
			self.tauon.view_standard()

		self.multi_playlist[self.active_playlist_viewing].playlist_ids = self.default_playlist
		self.multi_playlist[self.active_playlist_viewing].position = self.playlist_view_position
		self.multi_playlist[self.active_playlist_viewing].selected = self.selected_in_playlist

		if self.tauon.gall_pl_switch_timer.get() > 240:
			self.gui.gallery_positions.clear()
		self.tauon.gall_pl_switch_timer.set()

		self.gui.gallery_positions[self.gui.previous_playlist_id] = self.gui.album_scroll_px

		if cycle:
			self.active_playlist_viewing += number
		else:
			self.active_playlist_viewing = number

		while self.active_playlist_viewing > len(self.multi_playlist) - 1:
			self.active_playlist_viewing -= len(self.multi_playlist)
		while self.active_playlist_viewing < 0:
			self.active_playlist_viewing += len(self.multi_playlist)

		id = self.multi_playlist[self.active_playlist_viewing].uuid_int

		if self.prefs.always_auto_update_playlists:
			self.try_reload_playlist_from_file(self.multi_playlist[self.active_playlist_viewing], True)
		self.render_playlist()

		self.default_playlist = self.multi_playlist[self.active_playlist_viewing].playlist_ids
		self.playlist_view_position = self.multi_playlist[self.active_playlist_viewing].position
		self.selected_in_playlist = self.multi_playlist[self.active_playlist_viewing].selected
		logging.debug("Position changed by playlist change")
		self.gui.shift_selection = [self.selected_in_playlist]

		code = self.gen_codes.get(id)
		if code is not None and self.tauon.check_auto_update_okay(code, self.active_playlist_viewing):
			self.gui.regen_single_id = id
			self.tauon.thread_manager.ready("worker")

		if self.prefs.album_mode:
			self.tauon.reload_albums(True)
			if id in self.gui.gallery_positions:
				self.gui.album_scroll_px = self.gui.gallery_positions[id]
			else:
				self.tauon.goto_album(self.playlist_view_position)

		if self.prefs.auto_goto_playing:
			self.show_current(this_only=True, playing=False, highlight=True, no_switch=True)

		if self.prefs.shuffle_lock:
			self.tauon.view_box.lyrics(hit=True)
			if self.active_playlist_viewing is not None:
				self.active_playlist_playing = self.active_playlist_viewing
				self.tauon.random_track()


	def cycle_playlist_pinned(self, step: int) -> None:
		if self.gui.radio_view:
			self.radio_playlist_viewing += step * -1
			if self.radio_playlist_viewing > len(self.radio_playlists) - 1:
				self.radio_playlist_viewing = 0
			if self.radio_playlist_viewing < 0:
				self.radio_playlist_viewing = len(self.radio_playlists) - 1
			return

		if step > 0:
			p = self.active_playlist_viewing
			le = len(self.multi_playlist)
			on = p
			on -= 1
			while True:
				if on < 0:
					on = le - 1
				if on == p:
					break
				if self.multi_playlist[on].hidden is False or not self.prefs.tabs_on_top or (
						self.gui.lsp and self.prefs.left_panel_mode == "playlist"):
					self.switch_playlist(on)
					break
				on -= 1

		elif step < 0:
			p = self.active_playlist_viewing
			le = len(self.multi_playlist)
			on = p
			on += 1
			while True:
				if on == le:
					on = 0
				if on == p:
					break
				if self.multi_playlist[on].hidden is False or not self.prefs.tabs_on_top or (
						self.gui.lsp and self.prefs.left_panel_mode == "playlist"):
					self.switch_playlist(on)
					break
				on += 1

	def move_radio_playlist(self, source: int, dest: int) -> None:
		if dest > source:
			dest += 1
		try:
			temp = self.radio_playlists[source]
			self.radio_playlists[source] = "old"
			self.radio_playlists.insert(dest, temp)
			self.radio_playlists.remove("old")
			self.radio_playlist_viewing = self.radio_playlists.index(temp)
		except Exception:
			logging.exception("Playlist move error")

	def move_playlist(self, source: int, dest: int) -> None:
		if dest > source:
			dest += 1
		try:
			active = self.multi_playlist[self.active_playlist_playing]
			view = self.multi_playlist[self.active_playlist_viewing]

			temp = self.multi_playlist[source]
			self.multi_playlist[source] = "old"
			self.multi_playlist.insert(dest, temp)
			self.multi_playlist.remove("old")

			self.active_playlist_playing = self.multi_playlist.index(active)
			self.active_playlist_viewing = self.multi_playlist.index(view)
			self.default_playlist = self.multi_playlist[self.active_playlist_viewing].playlist_ids
		except Exception:
			logging.exception("Playlist move error")

	def delete_playlist(self, index: int, force: bool = False, check_lock: bool = False) -> None:
		if self.gui.radio_view:
			del self.radio_playlists[index]
			if not self.radio_playlists:
				self.radio_playlists = [RadioPlaylist(uid=uid_gen(),name="Default", stations=[])]
			return

		if check_lock and self.tauon.pl_is_locked(index):
			self.show_message(_("Playlist is locked to prevent accidental deletion"))
			return

		if not force and self.tauon.pl_is_locked(index):
			self.show_message(_("Playlist is locked to prevent accidental deletion"))
			return

		if self.gui.rename_playlist_box:
			return

		# Set screen to be redrawn
		self.gui.pl_update = 1
		self.gui.update += 1

		# Backup the playlist to be deleted
		# self.playlist_backup.append(self.multi_playlist[index])
		# self.playlist_backup.append(self.multi_playlist[index])
		self.tauon.undo.bk_playlist(index)

		# If we're deleting the final playlist, delete it and create a blank one in place
		if len(self.multi_playlist) == 1:
			logging.warning("Deleting final playlist and creating a new Default one")
			self.multi_playlist.clear()
			self.multi_playlist.append(self.tauon.pl_gen())
			self.default_playlist = self.multi_playlist[0].playlist_ids
			self.active_playlist_playing = 0
			return

		# Take note of the id of the playing playlist
		old_playing_id = self.multi_playlist[self.active_playlist_playing].uuid_int

		# Take note of the id of the viewed open playlist
		old_view_id = self.multi_playlist[self.active_playlist_viewing].uuid_int

		# Delete the requested playlist
		del self.multi_playlist[index]

		# Re-set the open viewed playlist number by uid
		for i, pl in enumerate(self.multi_playlist):
			if pl.uuid_int == old_view_id:
				self.active_playlist_viewing = i
				break
		else:
			# logging.info("Lost the viewed playlist!")
			# Try find the playing playlist and make it the viewed playlist
			for i, pl in enumerate(self.multi_playlist):
				if pl.uuid_int == old_playing_id:
					self.active_playlist_viewing = i
					break
			else:
				# Playing playlist was deleted, lets just move down one playlist
				if self.active_playlist_viewing > 0:
					self.active_playlist_viewing -= 1

		# Re-initiate the now viewed playlist
		if old_view_id != self.multi_playlist[self.active_playlist_viewing].uuid_int:
			self.default_playlist = self.multi_playlist[self.active_playlist_viewing].playlist_ids
			self.playlist_view_position = self.multi_playlist[self.active_playlist_viewing].position
			logging.debug("Position reset by playlist delete")
			self.selected_in_playlist = self.multi_playlist[self.active_playlist_viewing].selected
			self.gui.shift_selection = [self.selected_in_playlist]

			if self.prefs.album_mode:
				self.tauon.reload_albums(True)
				self.tauon.goto_album(self.playlist_view_position)

		# Re-set the playing playlist number by uid
		for i, pl in enumerate(self.multi_playlist):

			if pl.uuid_int == old_playing_id:
				self.active_playlist_playing = i
				break
		else:
			logging.info("Lost the playing playlist!")
			self.active_playlist_playing = self.active_playlist_viewing
			self.playlist_playing_position = -1

		self.tauon.test_show_add_home_music()

		# Cleanup
		ids: list[int] = []
		for p in self.multi_playlist:
			ids.append(p.uuid_int)

		for key in list(self.gui.gallery_positions.keys()):
			if key not in ids:
				del self.gui.gallery_positions[key]
		for key in list(self.gen_codes.keys()):
			if key not in ids:
				del self.gen_codes[key]

		self.db_inc += 1

	def delete_playlist_force(self, index: int) -> None:
		self.delete_playlist(index, force=True, check_lock=True)

	def delete_playlist_by_id(self, pl_id: int, force: bool = False, check_lock: bool = False) -> None:
		pl = self.id_to_pl(pl_id)
		if pl is None:
			return
		self.delete_playlist(pl, force=force, check_lock=check_lock)

	def delete_playlist_ask(self, index: int) -> None:
		if self.gui.radio_view:
			self.delete_playlist_force(index)
			return
		gen = self.gen_codes.get(self.pl_to_id(index), "")
		if (gen and not gen.startswith("self ")) or len(self.multi_playlist[index].playlist_ids) < 2:
			self.delete_playlist(index)
			return

		self.gui.message_box_confirm_callback = self.delete_playlist_by_id
		self.gui.message_box_no_callback = None
		self.gui.message_box_confirm_reference = (self.pl_to_id(index), True, True)
		self.show_message(_("Are you sure you want to delete playlist: {name}?").format(name=self.multi_playlist[index].title), mode="confirm")

	def id_to_pl(self, pl_id: int) -> int | None:
		for i, item in enumerate(self.multi_playlist):
			if item.uuid_int == pl_id:
				return i
		return None

	def pl_to_id(self, pl: int) -> int:
		return self.multi_playlist[pl].uuid_int

	def notify_database_changed(self) -> None:
		self.db_inc += 1
		self.tauon.bg_save()

	def update_tag_history(self) -> None:
		if self.prefs.auto_rec:
			self.tag_history[self.radiobox.song_key] = {
				"title": self.radiobox.dummy_track.title,
				"artist": self.radiobox.dummy_track.artist,
				"album": self.radiobox.dummy_track.album,
				# "image": self.radio_image_bin
			}

	def radio_progress(self) -> None:
		if self.radiobox.loaded_url and "radio.plaza.one" in self.radiobox.loaded_url and self.radio_poll_timer.get() > 0:
			self.radio_poll_timer.force_set(-10)
			response = requests.get("https://api.plaza.one/status", timeout=10)

			if response.status_code == 200:
				d = json.loads(response.text)
				if "song" in d and "artist" in d["song"] and "title" in d["song"]:
					self.tag_meta = d["song"]["artist"] + " - " + d["song"]["title"]

		if self.tag_meta:
			if self.radio_rate_timer.get() > 7 and self.radio_meta_on != self.tag_meta:
				self.radio_rate_timer.set()
				self.radio_scrobble_trip = False
				self.radio_meta_on = self.tag_meta

				self.radiobox.dummy_track.art_url_key = ""
				self.radiobox.dummy_track.title = ""
				self.radiobox.dummy_track.date = ""
				self.radiobox.dummy_track.artist = ""
				self.radiobox.dummy_track.album = ""
				self.radiobox.dummy_track.lyrics = ""
				self.radiobox.dummy_track.date = ""

				tags = self.found_tags
				if "title" in tags:
					self.radiobox.dummy_track.title = tags["title"]
					if "artist" in tags:
						self.radiobox.dummy_track.artist = tags["artist"]
					if "year" in tags:
						self.radiobox.dummy_track.date = tags["year"]
					if "album" in tags:
						self.radiobox.dummy_track.album = tags["album"]

				elif self.tag_meta.count(
						"-") == 1 and ":" not in self.tag_meta and "advert" not in self.tag_meta.lower():
					artist, title = self.tag_meta.split("-")
					self.radiobox.dummy_track.title = title.strip()
					self.radiobox.dummy_track.artist = artist.strip()

				if self.tag_meta:
					self.radiobox.song_key = self.tag_meta
				else:
					self.radiobox.song_key = self.radiobox.dummy_track.artist + " - " + self.radiobox.dummy_track.title

				self.update_tag_history()
				if self.radiobox.loaded_url not in self.radiobox.websocket_source_urls:
					self.radio_image_bin = None
				logging.info("NEXT RADIO TRACK")

				try:
					self.tauon.get_radio_art()
				except Exception:
					logging.exception("Get art error")

				self.refresh_now_playing(mpris=False)
				if self.mpris:
					self.mpris.update(force=True)

				self.lfm_scrobbler.listen_track(self.radiobox.dummy_track)
				self.lfm_scrobbler.start_queue()

			if self.radio_scrobble_trip is False and self.radio_scrobble_timer.get() > 45:
				self.radio_scrobble_trip = True
				self.lfm_scrobbler.scrob_full_track(copy.deepcopy(self.radiobox.dummy_track))

	def update_shuffle_pool(self, pl_id: int) -> None:
		pl = self.id_to_pl(pl_id)
		if pl is None:
			self.shuffle_pools.pop(pl_id, None)
			return
		new_pool = copy.deepcopy(self.multi_playlist[pl].playlist_ids)
		random.shuffle(new_pool)
		self.shuffle_pools[pl_id] = new_pool
		logging.info("Refill shuffle pool")

	def refresh_now_playing_fire(self, force: bool = False) -> None:
		if self.mpris is not None:
			self.mpris.update(force=force)
		if self.tauon.update_play_lock is not None:
			self.tauon.update_play_lock()
		# if self.tray_update is not None:
		#	 self.tray_update()
		self.notify_in_progress = False

	def update_macos_nowplaying_art_async(self, helper, track_index: int, generation: int) -> None:
		art_path = ""
		try:
			track = self.get_track(track_index)
			art_path = self.tauon.thumb_tracks.path(track) or ""
		except Exception:
			logging.exception("Failed to get thumb path for macOS Now Playing")

		if generation != self.mac_nowplaying_art_generation:
			return

		current_track = self.playing_object()
		if helper is not self.tauon.bag.nowplaying_helper or current_track is None or current_track.index != track_index:
			return

		self.mac_nowplaying_art_track_index = track_index
		self.mac_nowplaying_art_path = art_path
		self.mac_nowplaying_art_ready = True

		state = 0
		if self.playing_state == PlayingState.PLAYING:
			state = 1
		if self.playing_state == PlayingState.PAUSED:
			state = 2

		try:
			helper.update(
				title=current_track.title,
				artist=current_track.artist,
				album=current_track.album,
				art_path=art_path,
				state=state,
				duration=float(self.playing_length),
				elapsed=float(self.playing_time),
			)
		except Exception:
			logging.exception("Failed to update macOS Now Playing helper")

	def refresh_now_playing(self, mpris: bool = True, force: bool = False) -> None:
		self.tauon.tray_releases += 1
		if self.tauon.tray_lock.locked():
			try:
				self.tauon.tray_lock.release()
			except RuntimeError as e:
				if str(e) == "release unlocked lock":
					logging.error("RuntimeError: Attempted to release already unlocked tray_lock")  # noqa: TRY400
				else:
					logging.exception("Unknown RuntimeError trying to release tray_lock")
			except Exception:
				logging.exception("Failed to release tray_lock")

		if mpris and self.smtc:
			tr = self.playing_object()
			if tr:
				state = 0
				if self.playing_state == PlayingState.PLAYING:
					state = 1
				if self.playing_state == PlayingState.PAUSED:
					state = 2
				image_path = ""
				try:
					image_path = self.tauon.thumb_tracks.path(tr)
				except Exception:
					logging.exception("Failed to set image_path from thumb_tracks.path")

				if image_path is None:
					image_path = ""

				image_path = image_path.replace("/", "\\")
				#logging.info(image_path)

				self.sm.update(
					state, tr.title.encode("utf-16"), len(tr.title), tr.artist.encode("utf-16"), len(tr.artist),
					image_path.encode("utf-16"), len(image_path))

		helper = self.tauon.bag.nowplaying_helper
		if helper is not None:
			tr = self.playing_object()
			try:
				if tr:
					state = 0
					if self.playing_state == PlayingState.PLAYING:
						state = 1
					if self.playing_state == PlayingState.PAUSED:
						state = 2

					art_path = self.mac_nowplaying_art_path if tr.index == self.mac_nowplaying_art_track_index else ""
					if tr.index != self.mac_nowplaying_art_track_index:
						self.mac_nowplaying_art_generation += 1
						self.mac_nowplaying_art_track_index = tr.index
						self.mac_nowplaying_art_path = ""
						self.mac_nowplaying_art_ready = False
						shoot = threading.Thread(
							target=self.update_macos_nowplaying_art_async,
							args=(helper, tr.index, self.mac_nowplaying_art_generation),
							daemon=True,
						)
						shoot.start()
					elif self.mac_nowplaying_art_ready:
						helper.update(
							title=tr.title,
							artist=tr.artist,
							album=tr.album,
							art_path=art_path,
							state=state,
							duration=float(self.playing_length),
							elapsed=float(self.playing_time),
						)
				else:
					self.mac_nowplaying_art_generation += 1
					self.mac_nowplaying_art_track_index = -1
					self.mac_nowplaying_art_path = ""
					self.mac_nowplaying_art_ready = False
					helper.clear()
			except Exception:
				logging.exception("Failed to update macOS Now Playing helper")

		if self.mpris is not None and mpris is True:
			while self.notify_in_progress:
				time.sleep(0.01)
			self.notify_in_progress = True
			shoot = threading.Thread(target=self.refresh_now_playing_fire, args=(force,))
			shoot.daemon = True
			shoot.start()
		if self.prefs.art_bg or (self.gui.mode == GuiMode.MINI and self.prefs.mini_mode_mode == MiniModeMode.SLATE):
			self.tauon.thread_manager.ready("style")

		self.windows_progress.update()

	def get_url(self, track_object: TrackClass) -> tuple[list[str] | str | None, dict[str, str] | None]:
		if track_object.file_ext == "TIDAL":
			return self.tauon.tidal.resolve_stream(track_object), None
		if track_object.file_ext == "PLEX":
			return self.tauon.plex.resolve_stream(track_object.url_key), None

		if track_object.file_ext == "JELY":
			return self.tauon.jellyfin.resolve_stream(track_object.url_key)

		if track_object.file_ext == "SUB":
			self.tauon.subsonic.scan_lyrics(track_object)
			return self.tauon.subsonic.resolve_stream(track_object.url_key)

		if track_object.file_ext == "TAU":
			return self.tauon.tau.resolve_stream(track_object.url_key), None

		return None, None

	def playing_playlist(self) -> list[int]:
		return self.multi_playlist[self.active_playlist_playing].playlist_ids

	def playing_ready(self) -> bool:
		return len(self.track_queue) > 0

	def selected_ready(self) -> bool:
		return bool(self.default_playlist) and -1 < self.selected_in_playlist < len(self.default_playlist)

	def render_playlist(self) -> None:
		self.gui.pl_update = 1

	def show_selected(self) -> int:
		if self.gui.playlist_view_length < 1:
			return 0

		for i in range(len(self.multi_playlist[self.active_playlist_viewing].playlist_ids)):
			if i == self.selected_in_playlist:
				if i < self.playlist_view_position:
					self.playlist_view_position = i - random.randint(2, int((self.gui.playlist_view_length / 3) * 2) + int(self.gui.playlist_view_length / 6))
					logging.debug("Position changed show selected (a)")
				elif abs(self.playlist_view_position - i) > self.gui.playlist_view_length:
					self.playlist_view_position = i
					logging.debug("Position changed show selected (b)")
					if i > 6:
						self.playlist_view_position -= 5
						logging.debug("Position changed show selected (c)")
					if i > self.gui.playlist_view_length * 1 and i + (self.gui.playlist_view_length * 2) < len(
							self.multi_playlist[self.active_playlist_viewing].playlist_ids) and i > 10:
						self.playlist_view_position = i - random.randint(2, int(self.gui.playlist_view_length / 3) * 2)
						logging.debug("Position changed show selected (d)")
					break
		self.render_playlist()
		return 0

	def get_track(self, track_index: int) -> TrackClass:
		"""Get track object by track_index"""
		return self.master_library[track_index]

	def get_track_in_playlist(self, track_index: int, playlist_index: int) -> TrackClass | None:
		"""Get track object by playlist_index and track_index"""
		if playlist_index == -1:
			playlist_index = self.active_playlist_viewing
		try:
			playlist = self.multi_playlist[playlist_index].playlist_ids
			return self.get_track(playlist[track_index])
		except IndexError:
			logging.exception("Failed getting track object by playlist_index and track_index!")
		except Exception:
			logging.exception("Unknown error getting track object by playlist_index and track_index!")
		return None

	def show_object(self) -> TrackClass | None:
		"""The track to show in the metadata side panel"""
		target_track = None

		if self.playing_state == PlayingState.URL_STREAM:
			return self.radiobox.dummy_track

		if self.playing_state in (PlayingState.PLAYING, PlayingState.PAUSED):
			target_track = self.playing_object()

		elif self.playing_state == PlayingState.STOPPED and self.prefs.meta_shows_selected:
			if -1 < self.selected_in_playlist < len(self.multi_playlist[self.active_playlist_viewing].playlist_ids):
				target_track = self.get_track(self.multi_playlist[self.active_playlist_viewing].playlist_ids[self.selected_in_playlist])

		elif self.playing_state == PlayingState.STOPPED and self.prefs.meta_persists_stop:
			# A saved queue position can become stale when the queue was changed
			# before the previous shutdown. Do not let metadata rendering prevent
			# Tauon from starting in that case.
			if 0 <= self.queue_step < len(self.track_queue):
				target_track = self.master_library[self.track_queue[self.queue_step]]

		if self.prefs.meta_shows_selected_always \
		and -1 < self.selected_in_playlist < len(self.multi_playlist[self.active_playlist_viewing].playlist_ids):
			target_track = self.get_track(self.multi_playlist[self.active_playlist_viewing].playlist_ids[self.selected_in_playlist])

		return target_track

	def playing_object(self) -> TrackClass | None:
		if self.playing_state == PlayingState.URL_STREAM:
			return self.radiobox.dummy_track

		if 0 <= self.queue_step < len(self.track_queue):
			return self.master_library[self.track_queue[self.queue_step]]
		return None

	def title_text(self) -> str:
		line = ""
		track = self.playing_object()
		if track:
			title = track.title or ""
			artist = track.artist or ""

			if not title and not artist:
				if self.playing_state == PlayingState.URL_STREAM:
					return self.tag_meta
				return clean_string(track.filename)

			if artist:
				line += artist
			if title:
				if line:
					line += "  -  "
				line += title

		return line

	def show(self) -> int | None:
		if not self.track_queue:
			return 0
		return None

	def show_current(
		self, select: bool = True, playing: bool = True, quiet: bool = False, this_only: bool = False, highlight: bool = False,
		index: int | None = None, no_switch: bool = False, folder_list: bool = True,
	) -> int | None:

		# logging.info("show------")
		# logging.info(select)
		# logging.info(playing)
		# logging.info(quiet)
		# logging.info(this_only)
		# logging.info(highlight)
		# logging.info("--------")
		logging.debug("Position set by show playing")

		if not self.track_queue:
			return 0

		track_index = self.track_queue[self.queue_step]
		if index is not None:
			track_index = index

		# Switch to source playlist
		if not no_switch and self.active_playlist_viewing != self.active_playlist_playing and (
				track_index not in self.multi_playlist[self.active_playlist_viewing].playlist_ids):
			self.switch_playlist(self.active_playlist_playing)

		if self.gui.playlist_view_length < 1:
			return 0

		for i in range(len(self.multi_playlist[self.active_playlist_viewing].playlist_ids)):
			if self.multi_playlist[self.active_playlist_viewing].playlist_ids[i] == track_index:

				if self.playlist_playing_position < len(self.multi_playlist[self.active_playlist_viewing].playlist_ids) and \
						self.active_playlist_viewing == self.active_playlist_playing and track_index == \
						self.multi_playlist[self.active_playlist_viewing].playlist_ids[self.playlist_playing_position] and \
						i != self.playlist_playing_position:
					# continue
					i = self.playlist_playing_position

				if select:
					self.selected_in_playlist = i

				if playing:
					# Make the found track the playing track
					self.playlist_playing_position = i
					self.active_playlist_playing = self.active_playlist_viewing

				vl = self.gui.playlist_view_length
				if self.multi_playlist[self.active_playlist_viewing].uuid_int == self.gui.playlist_current_visible_tracks_id:
					vl = self.gui.playlist_current_visible_tracks

				if not (quiet and self.playing_object().length < 15):
				# or (abs(self.playlist_view_position - playlist_id) < vl - 1)):

					# Align to album if in view range (and folder titles are active)
					ap = self.tauon.get_album_info(i)[1][0]

					if not (quiet and self.playlist_view_position <= i <= self.playlist_view_position + vl) and (
					not abs(i - ap) > vl - 2) and not self.multi_playlist[self.active_playlist_viewing].hide_title:
						self.playlist_view_position = ap

					# Move to a random offset ---

					elif i == self.playlist_view_position - 1 and self.playlist_view_position > 1:
						self.playlist_view_position -= 1

					# Move a bit if its just out of range
					elif self.playlist_view_position + vl - 2 == i and i < len(
							self.multi_playlist[self.active_playlist_viewing].playlist_ids) - 5:
						self.playlist_view_position += 3

					# We know its out of range if above view position
					elif i < self.playlist_view_position:
						self.playlist_view_position = i - random.randint(2, int((
							self.gui.playlist_view_length / 3) * 2) + int(self.gui.playlist_view_length / 6))

					# If its below we need to test if its in view. If playing track in view, don't jump
					elif abs(self.playlist_view_position - i) >= vl:
						self.playlist_view_position = i
						if i > 6:
							self.playlist_view_position -= 5
						if i > self.gui.playlist_view_length and i + (self.gui.playlist_view_length * 2) < len(
								self.multi_playlist[self.active_playlist_viewing].playlist_ids) and i > 10:
							self.playlist_view_position = i - random.randint(2,
								int(self.gui.playlist_view_length / 3) * 2)
				break
		else:  # Search other all other playlists
			if not this_only:
				for i, playlist in enumerate(self.multi_playlist):
					if track_index in playlist.playlist_ids:
						self.switch_playlist(i, quiet=True)
						self.show_current(select, playing, quiet, this_only=True, index=track_index)
						break

		self.playlist_view_position = max(self.playlist_view_position, 0)

		# if self.playlist_view_position > len(self.multi_playlist[self.active_playlist_viewing].playlist_ids) - 1:
		#	 logging.info("Run Over")

		if select:
			self.gui.shift_selection = []

		self.render_playlist()

		if not quiet and self.tauon.custom.gallery_locate(self.selected_in_playlist, highlight=highlight):
			pass  # located in a custom-layout gallery widget instead of the preset
		elif self.prefs.album_mode and not quiet:
			if highlight:
				self.gui.gallery_animate_highlight_on = self.tauon.goto_album(self.selected_in_playlist)
				self.tauon.gallery_select_animate_timer.set()
			else:
				self.tauon.goto_album(self.selected_in_playlist)

		if self.prefs.left_panel_mode == "artist list" and self.gui.lsp and not quiet:
			self.artist_list_box.locate_artist(self.playing_object())

		if folder_list and self.prefs.left_panel_mode == "folder view" and self.gui.lsp and not quiet and not self.tree_view_box.lock_pl:
			self.tree_view_box.show_track(self.playing_object())

		return 0

	def toggle_mute(self) -> None:
		if self.player_volume > 0:
			self.volume_store = self.player_volume
			self.player_volume = 0
		else:
			self.player_volume = self.volume_store

		self.set_volume()

	def set_volume(self, notify: bool = True) -> None:
		self.volume_update_timer.set()

		if self.playerCommandReady:
			# send vol command later if command busy. Solution not great.
			def govol() -> None:
				time.sleep(1)
				if not self.playerCommandReady:
					self.playerCommand = "volume"
					self.playerCommandReady = True
				time.sleep(1)
				if not self.playerCommandReady:
					self.playerCommand = "volume"
					self.playerCommandReady = True
			shooter(govol)
		else:
			self.playerCommand = "volume"
			self.playerCommandReady = True
		if notify:
			self.refresh_now_playing()

	def clear_ab_repeat(self, update_gui: bool = True) -> None:
		self.ab_repeat_a = -1.0
		self.ab_repeat_b = -1.0
		if update_gui:
			self.tauon.gui.update += 1

	def reset_ab_repeat_on_track_change(self, track_id: int) -> None:
		if self.ab_repeat_a < 0 and self.ab_repeat_b < 0:
			return
		if self.target_object is not None and self.target_object.index == track_id:
			return
		self.clear_ab_repeat()

	def revert(self) -> None:
		if self.queue_step == 0:
			return

		prev = 0
		while len(self.track_queue) > prev + 1 and prev < 5:
			if self.track_queue[len(self.track_queue) - 1 - prev] == self.left_index:
				self.queue_step = len(self.track_queue) - 1 - prev
				self.jump_time = self.left_time
				self.playing_time = self.left_time
				self.decode_time = self.left_time
				break
			prev += 1
		else:
			self.queue_step -= 1
			self.jump_time = 0.0
			self.playing_time = 0
			self.decode_time = 0

		if not len(self.track_queue) > self.queue_step >= 0:
			logging.error("There is no previous track?")
			return

		self.reset_ab_repeat_on_track_change(self.track_queue[self.queue_step])
		self.target_open = self.master_library[self.track_queue[self.queue_step]].fullpath
		self.target_object = self.master_library[self.track_queue[self.queue_step]]
		self.start_time = self.master_library[self.track_queue[self.queue_step]].start_time
		self.start_time_target = self.start_time
		self.playing_length = self.master_library[self.track_queue[self.queue_step]].length
		self.playerCommand = "open"
		self.playerCommandReady = True
		self.playing_state = PlayingState.PLAYING

		if self.tauon.stream_proxy.download_running:
			self.tauon.stream_proxy.stop()

		self.show_current()
		self.render_playlist()

	def deduct_shuffle(self, track_id: int) -> None:
		if self.multi_playlist and self.random_mode:
			pl = self.multi_playlist[self.active_playlist_playing]
			pl_id = pl.uuid_int

			if pl_id not in self.shuffle_pools:
				self.update_shuffle_pool(pl.uuid_int)

			pool = self.shuffle_pools[pl_id]
			if not pool:
				del self.shuffle_pools[pl_id]
				self.update_shuffle_pool(pl.uuid_int)
			pool = self.shuffle_pools[pl_id]

			if track_id in pool:
				pool.remove(track_id)

	def play_target_rr(self, play: bool = True) -> None:
		self.tauon.thread_manager.ready_playback()
		self.playing_length = self.master_library[self.track_queue[self.queue_step]].length

		if self.playing_length > 2:
			random_start = random.randrange(1, int(self.playing_length) - 45 if self.playing_length > 50 else int(
				self.playing_length))
		else:
			random_start = 0

		self.playing_time = random_start
		target_id = self.track_queue[self.queue_step]
		self.reset_ab_repeat_on_track_change(target_id)
		self.target_open = self.master_library[target_id].fullpath
		self.target_object = self.master_library[target_id]
		self.start_time = self.master_library[target_id].start_time
		self.start_time_target = self.start_time
		self.jump_time = random_start
		if play:
			self.playerCommand = "open"
			if not self.prefs.use_jump_crossfade:
				self.playerSubCommand = "now"
			self.playerCommandReady = True
			self.playing_state = PlayingState.PLAYING
		self.radiobox.loaded_station = None

		if self.tauon.stream_proxy.download_running:
			self.tauon.stream_proxy.stop()

		if self.prefs.update_title:
			self.tauon.update_title_do()

		self.deduct_shuffle(self.target_object.index)

	def play_target(self, _gapless: bool = False, jump: bool = False, play: bool = True, update_gui: bool = True) -> None:
		self.tauon.thread_manager.ready_playback()

		#logging.info(self.track_queue)
		self.playing_time = 0
		self.decode_time = 0
		target = self.master_library[self.track_queue[self.queue_step]]
		self.reset_ab_repeat_on_track_change(target.index)
		self.target_open = target.fullpath
		self.target_object = target
		self.start_time = target.start_time
		self.start_time_target = self.start_time
		self.playing_length = target.length
		self.last_playing_time = 0
		self.commit = None
		self.radiobox.loaded_station = None

		if self.tauon.stream_proxy and self.tauon.stream_proxy.download_running:
			self.tauon.stream_proxy.stop()

		if self.multi_playlist[self.active_playlist_playing].persist_time_positioning:
			t = (target.position if target.position is not None else 0)
			if t:
				self.playing_time = 0
				self.decode_time = 0
				self.jump_time = t

		if play:
			self.playerCommand = "open"
			if jump:  # and not prefs.use_jump_crossfade:
				self.playerSubCommand = "now"
			self.playerCommandReady = True
			self.playing_state = PlayingState.PLAYING

		self.update_change(update_gui)
		self.deduct_shuffle(target.index)

	def abort_gapless_transition(self, track: TrackClass, resume_time: float) -> None:
		self.target_open = track.fullpath
		self.target_object = track
		self.start_time = track.start_time
		self.start_time_target = self.start_time
		self.playing_length = track.length
		self.jump_time = resume_time
		self.playing_time = resume_time
		self.decode_time = resume_time
		self.last_playing_time = resume_time
		self.gui.update_spec = 0
		self.commit = None
		self.radiobox.loaded_station = None
		self.playerCommand = "open"
		self.playerSubCommand = "now"
		self.playerCommandReady = True
		self.playing_state = PlayingState.PLAYING

	def update_change(self, update_gui: bool = True) -> None:
		if self.prefs.update_title and update_gui:
			self.tauon.update_title_do()
		self.refresh_now_playing()
		# Wake and start Discord RPC worker immediately on change
		try:
			self.tauon._signal_discord()
		except Exception:
			# Fallback to legacy start if signalling fails
			self.tauon.hit_discord()
		if update_gui:
			self.render_playlist()

		if self.lfm_scrobbler.a_sc:
			self.lfm_scrobbler.a_sc = False
			self.a_time = 0

		self.lfm_scrobbler.start_queue()

		if (self.prefs.album_mode or not self.gui.rsp) and (self.gui.theme_name == "Carbon" or self.prefs.colour_from_image):
			target = self.playing_object()
			# Skip only when the already-applied theme is for this album. The
			# theme cache is keyed on album (theme_temp_current); last_album is a
			# folder path that the cache-apply path doesn't keep in sync, so
			# comparing against it here left stale themes after revisiting an
			# album (issue #2205).
			if target and self.prefs.colour_from_image and target.album == self.gui.theme_temp_current:
				return

			self.tauon.album_art_gen.display(target, (0, 0), (50, 50), theme_only=True)

	def jump(self, index: int, pl_position: int | None = None, jump: bool = True) -> None:
		self.lfm_scrobbler.start_queue()
		if self.stop_mode == StopMode.TRACK:  # Disable auto stop track
			self.stop_mode = StopMode.OFF
		if self.stop_mode == StopMode.ALBUM and self.playing_state != PlayingState.STOPPED:  # Disable auto stop album if album different
			tr = self.get_track(index)
			if (tr.parent_folder_path, tr.album) != self.stop_ref:
				self.stop_mode = StopMode.OFF
				self.stop_ref = None
		if self.stop_mode == StopMode.ALBUM_PERSIST:  # Assign new current album for stopping
			tr = self.get_track(index)
			self.stop_ref = (tr.parent_folder_path, tr.album)

		if self.force_queue and not self.pause_queue:
			if self.force_queue[0].type == QueueType.ALBUM and self.force_queue[0].album_stage == 1:
				if self.get_track(self.force_queue[0].track_id).parent_folder_path != self.get_track(index).parent_folder_path:
					del self.force_queue[0]

		if len(self.track_queue) > 0:
			self.left_time = self.playing_time
			self.left_index = self.track_queue[self.queue_step]

			if self.playing_state == PlayingState.PLAYING and self.left_time > 5 and self.playing_length - self.left_time > 15:
				self.master_library[self.left_index].skips += 1

		self.gui.update_spec = 0
		self.active_playlist_playing = self.active_playlist_viewing
		self.track_queue.append(index)
		self.queue_step = len(self.track_queue) - 1
		self.gui.playlist_hold = False
		self.play_target(jump=jump)

		if pl_position is not None:
			self.playlist_playing_position = pl_position

		self.gui.pl_update = 1

	def back(self) -> None:

		play = True
		if self.playing_state == PlayingState.PAUSED and not self.prefs.resume_on_jump:
			play = False
			self.playerCommand = "stop"
			self.playerCommandReady = True

		if self.playing_state != PlayingState.URL_STREAM and self.prefs.back_restarts and self.playing_time > 6:
			self.seek_time(0)
			self.render_playlist()
			return

		if len(self.track_queue) > 0:
			self.left_time = self.playing_time
			self.left_index = self.track_queue[self.queue_step]

		self.gui.update_spec = 0
		# Move up
		if self.random_mode is False and len(self.playing_playlist()) > self.playlist_playing_position > 0:

			if len(self.track_queue) > 0 and self.playing_playlist()[self.playlist_playing_position] != \
					self.track_queue[
						self.queue_step]:

				try:
					p = self.playing_playlist().index(self.track_queue[self.queue_step])
				except Exception:
					logging.exception("Failed to change playing_playlist")
					p = random.randrange(len(self.playing_playlist()))
				if p is not None:
					self.playlist_playing_position = p

			self.playlist_playing_position -= 1
			self.track_queue.append(self.playing_playlist()[self.playlist_playing_position])
			self.queue_step = len(self.track_queue) - 1
			self.play_target(jump=True, play=play)

		elif self.random_mode is True and self.queue_step > 0:
			self.queue_step -= 1
			self.play_target(jump=True, play=play)
		else:
			logging.info("BACK: NO CASE!")
			self.show_current()

		if self.active_playlist_viewing == self.active_playlist_playing:
			self.show_current(False, True)

		if self.prefs.album_mode:
			self.tauon.goto_album(self.playlist_playing_position)
		if self.gui.combo_mode and self.active_playlist_viewing == self.active_playlist_playing:
			self.show_current()

		self.render_playlist()
		self.refresh_now_playing()
		self.tauon.notify_song()
		self.lfm_scrobbler.start_queue()
		self.gui.pl_update += 1

	def stop(self, block: bool = False, run: bool = False, update_gui: bool = True) -> int:
		stream_proxy = getattr(self.tauon, "stream_proxy", None)
		stream_state = stream_proxy.state_log() if stream_proxy else "unavailable"
		self.playerCommand = "stop"
		if run:
			self.playerCommand = "runstop"
		if block:
			self.playerSubCommand = "return"

		self.playerCommandReady = True

		if self.tauon.thread_manager.player_lock.locked():
			try:
				self.tauon.thread_manager.player_lock.release()
			except RuntimeError as e:
				if str(e) == "release unlocked lock":
					logging.error("RuntimeError: Attempted to release already unlocked player_lock")  # noqa: TRY400
				else:
					logging.exception("Unknown RuntimeError trying to release player_lock")
			except Exception:
				logging.exception("Unknown exception trying to release player_lock")

		self.record_stream = False
		if len(self.track_queue) > 0:
			self.left_time = self.playing_time
			self.left_index = self.track_queue[self.queue_step]

		if self.tauon.radiobox.load_connecting or self.playing_state == PlayingState.URL_STREAM:
			# Keep loaded_station so play() can resume the radio after a stop
			self.tauon.radiobox.abort_load(clear_station=False)

		previous_state = self.playing_state
		self.playing_state = PlayingState.STOPPED
		if update_gui:
			self.playing_time = 0
			self.decode_time = 0
			self.render_playlist()

			self.gui.update_spec = 0
			# gui.update_level = True  # Allows visualiser to enter decay sequence
			self.gui.update = True
			if self.prefs.update_title:
				self.tauon.update_title_do()  # Update title bar text

		if self.tauon.stream_proxy and self.tauon.stream_proxy.download_running:
			logging.info(f"Player stop is stopping radio stream: {self.tauon.stream_proxy.state_log()}")
			self.tauon.stream_proxy.stop()

		if block:
			sleep_timeout(lambda: self.playerSubCommand != "stopped", 2)
			if self.tauon.stream_proxy.download_running:
				sleep_timeout(lambda: self.tauon.stream_proxy.download_running, 2)

		self.refresh_now_playing()
		self.lfm_scrobbler.start_queue()
		return previous_state

	def pause(self) -> None:
		if self.playing_state == PlayingState.URL_STREAM:
			return

		if self.playing_state == PlayingState.PLAYING:
			self.playerCommand = "pauseon"
			self.playing_state = PlayingState.PAUSED
		elif self.playing_state == PlayingState.PAUSED:
			self.playerCommand = "pauseoff"
			self.playing_state = PlayingState.PLAYING
			self.tauon.notify_song()

		self.playerCommandReady = True

		self.render_playlist()
		self.refresh_now_playing()

	def pause_only(self) -> None:
		if self.playing_state == PlayingState.PLAYING:
			self.playerCommand = "pauseon"
			self.playing_state = PlayingState.PAUSED

			self.playerCommandReady = True
			self.render_playlist()
			self.refresh_now_playing()

	def play_pause(self) -> None:
		if self.playing_state == PlayingState.URL_STREAM:
			self.stop()
		elif self.playing_state != PlayingState.STOPPED:
			self.pause()
		else:
			self.play()

	def seek_decimal(self, decimal: float) -> None:
		# if self.commit:
		#	 return
		if self.playing_state in (PlayingState.PLAYING, PlayingState.PAUSED):
			if decimal > 1:
				decimal = 1
			elif decimal < 0:
				decimal = 0
			self.new_time = self.playing_length * decimal
			#logging.info('seek to:' + str(self.new_time))
			self.playerCommand = "seek"
			self.playerCommandReady = True
			self.playing_time = self.new_time

			self.windows_progress.update()

			if self.mpris is not None:
				self.mpris.seek_do(self.playing_time)

	def seek_time(self, new: float) -> None:
		# if self.commit:
		#	 return
		if self.playing_state in (PlayingState.PLAYING, PlayingState.PAUSED):

			if new > self.playing_length - 0.5:
				self.advance()
				return

			if new < 0.4:
				new = 0

			self.new_time = new
			self.playing_time = new

			self.playerCommand = "seek"
			self.playerCommandReady = True

			if self.mpris is not None:
				self.mpris.seek_do(self.playing_time)

	def play(self, update_gui: bool = True) -> None:
		# Unpause if paused
		if self.playing_state == PlayingState.PAUSED:
			self.playerCommand = "pauseoff"
			self.playerCommandReady = True
			self.playing_state = PlayingState.PLAYING
			self.refresh_now_playing()

		# If stopped
		elif self.playing_state == PlayingState.STOPPED:

			if self.radiobox.loaded_station:
				self.radiobox.start(self.radiobox.loaded_station)
				return

			# If the queue is empty
			if self.track_queue == [] and len(self.multi_playlist[self.active_playlist_playing].playlist_ids) > 0:
				self.track_queue.append(self.multi_playlist[self.active_playlist_playing].playlist_ids[0])
				self.queue_step = 0
				self.playlist_playing_position = 0

				self.play_target(update_gui)

			# If the queue is not empty, play?
			elif len(self.track_queue) > 0:
				if self.stop_mode == StopMode.ALBUM_PERSIST:  # Assign new current album for stopping
					tr = self.playing_object()
					self.stop_ref = (tr.parent_folder_path, tr.album)
				self.play_target(update_gui)

		if update_gui:
			self.render_playlist()

	def purge_track(self, track_id: int, fast: bool = False) -> None:
		"""Remove a track from the database"""
		# Remove from all playlists
		if not fast:
			for playlist in self.multi_playlist:
				while track_id in playlist.playlist_ids:
					self.album_dex.clear()
					playlist.playlist_ids.remove(track_id)
		# Stop if track is playing track
		if self.track_queue and self.track_queue[self.queue_step] == track_id \
		and self.playing_state != PlayingState.STOPPED:
			self.stop(block=True)
		# Remove from playback history
		while track_id in self.track_queue:
			self.track_queue.remove(track_id)
			self.queue_step -= 1
		# Remove track from force queue
		for i in reversed(range(len(self.force_queue))):
			if self.force_queue[i].track_id == track_id:
				del self.force_queue[i]
		del self.master_library[track_id]

	def test_progress(self) -> None:
		# Fuzzy reload lastfm for rescrobble
		if self.lfm_scrobbler.a_sc and self.playing_time < 1:
			self.lfm_scrobbler.a_sc = False
			self.a_time = 0

		# Update the UI if playing time changes a whole number
		# next_round = int(self.playing_time)
		# if self.playing_time_int != next_round:
		#	 #if not prefs.power_save:
		#	 #self.gui.update += 1
		#	 self.playing_time_int = next_round

		tr = self.playing_object()
		gap_extra = 2
		if tr and tr.is_network:
			# Network tracks can transition gaplessly when streamed natively;
			# otherwise wait for the very end like before
			feeder = getattr(self.tauon, "stream_feeder", None)
			if not (self.prefs.network_stream and feeder is not None and feeder.enabled):
				gap_extra = 0

		if self.tauon.chrome_mode:
			gap_extra = 3

		self.windows_progress.update()

		if self.commit is not None:
			return

		if self.playing_state == PlayingState.PLAYING and self.ab_repeat_b > self.ab_repeat_a >= 0:
			if self.ab_repeat_b <= self.decode_time <= self.ab_repeat_b + 2 and self.playing_length > 0:
				self.decode_time = self.ab_repeat_a
				self.seek_decimal(self.ab_repeat_a / self.playing_length)
				return
		if self.ab_repeat_a >= 0:
			gap_extra = 0  # disable gapless for ab repeat

		if self.playing_state == PlayingState.PLAYING and self.multi_playlist[self.active_playlist_playing].persist_time_positioning:
			tr = self.playing_object()
			if tr:
				tr.position = self.decode_time

		if self.playing_state == PlayingState.PLAYING and self.decode_time + gap_extra >= self.playing_length and self.decode_time > 0.2:

			# Allow some time for backend to provide a length
			if self.playing_time < 6 and self.playing_length == 0:
				return
			if self.a_time < 2:
				return

			self.decode_time = 0

			pp = self.playing_playlist()

			stopped = False
			if self.stop_mode != StopMode.OFF:  # and not self.force_queue and not (self.force_queue and self.pause_queue):
				if self.stop_mode == StopMode.TRACK:
					self.stop(run=True)
					self.stop_mode = StopMode.OFF
					stopped = True
				if self.stop_mode == StopMode.ALBUM:
					tr = self.playing_object()
					i = self.advance(dry=True)
					if i is None:
						self.stop(run=True)
						self.stop_mode = StopMode.OFF
						stopped = True
					else:
						tr2 = self.get_track(i)
						if (tr.parent_folder_path, tr.album) != (tr2.parent_folder_path, tr2.album):
							self.stop(run=True)
							self.stop_mode = StopMode.OFF
							stopped = True
				if self.stop_mode == StopMode.TRACK_PERSIST:
					self.stop(run=True)
					stopped = True
				if self.stop_mode == StopMode.ALBUM_PERSIST:
					i = self.advance(dry=True)
					if i is None:
						self.stop(run=True)
						stopped = True
					else:
						tr2 = self.get_track(i)
						if self.stop_ref != (tr2.parent_folder_path, tr2.album):
							self.stop(run=True)
							stopped = True
				if stopped is True:
					if self.force_queue or (not self.force_queue and not self.random_mode and not self.repeat_mode):
						self.advance(play=False)
					self.gui.update += 2
					return

			if self.force_queue and not self.pause_queue:
				id = self.advance(end=True, quiet=True, dry=True)
				if id is not None:
					self.start_commit(id)
					return
				self.advance(end=True, quiet=True)

			elif self.repeat_mode is True:
				if self.album_repeat_mode:
					if not pp:
						self.stop(run=True)
						self.gui.update += 2
						return
					if self.playlist_playing_position > len(pp) - 1:
						self.playlist_playing_position = 0  # TODO(Taiko): Hack fix, race condition bug?

					ti = self.get_track(pp[self.playlist_playing_position])

					i = self.playlist_playing_position

					# Test if next track is in same folder
					if i + 1 < len(pp):
						nt = self.get_track(pp[i + 1])
						if ti.parent_folder_path == nt.parent_folder_path:
							# The next track is in the same folder
							# so advance normally
							self.advance(quiet=True, end=True)
							return

					# We need to backtrack to see where the folder begins
					i -= 1
					while i >= 0:
						nt = self.get_track(pp[i])
						if ti.parent_folder_path != nt.parent_folder_path:
							i += 1
							break
						i -= 1
					i = max(i, 0)

					self.selected_in_playlist = i
					self.gui.shift_selection = [i]

					self.jump(pp[i], i, jump=False)

				elif self.prefs.playback_follow_cursor and self.playing_ready() \
						and self.selected_ready() \
						and self.default_playlist[self.selected_in_playlist] != self.playing_object().index:

					logging.info("Repeat follow cursor")

					self.playing_time = 0
					self.decode_time = 0
					self.active_playlist_playing = self.active_playlist_viewing
					self.playlist_playing_position = self.selected_in_playlist

					self.track_queue.append(self.default_playlist[self.selected_in_playlist])
					self.queue_step = len(self.track_queue) - 1
					self.play_target(jump=False)
					self.render_playlist()
					self.lfm_scrobbler.start_queue()

				else:
					id = self.track_queue[self.queue_step]
					self.commit = id
					target = self.get_track(id)
					self.target_open = target.fullpath
					self.target_object = target
					self.start_time = target.start_time
					self.start_time_target = self.start_time
					self.playerCommand = "open"
					self.playerSubCommand = "repeat"
					self.playerCommandReady = True

					#self.render_playlist()
					self.lfm_scrobbler.start_queue()

					# Reload lastfm for rescrobble
					if self.lfm_scrobbler.a_sc:
						self.lfm_scrobbler.a_sc = False
						self.a_time = 0

			elif self.random_mode is False and len(pp) > self.playlist_playing_position + 1 and \
					self.master_library[pp[self.playlist_playing_position]].is_cue is True \
					and self.master_library[pp[self.playlist_playing_position + 1]].filename == \
					self.master_library[pp[self.playlist_playing_position]].filename and int(
				self.master_library[pp[self.playlist_playing_position]].track_number) == int(
				self.master_library[pp[self.playlist_playing_position + 1]].track_number) - 1:

				#  not (self.force_queue and not self.pause_queue) and \

				# We can shave it closer
				if not self.playing_time + 0.1 >= self.playing_length:
					return

				logging.info("Do transition CUE")
				self.playlist_playing_position += 1
				self.queue_step += 1
				self.track_queue.append(pp[self.playlist_playing_position])
				self.playing_state = PlayingState.PLAYING
				self.playing_time = 0
				self.decode_time = 0
				self.playing_length = self.master_library[self.track_queue[self.queue_step]].length
				self.start_time = self.master_library[self.track_queue[self.queue_step]].start_time
				self.start_time_target = self.start_time
				self.lfm_scrobbler.start_queue()

				self.gui.update += 1
				self.gui.pl_update = 1

				if self.prefs.update_title:
					self.tauon.update_title_do()
				self.refresh_now_playing()
			else:
				# self.advance(quiet=True, end=True)

				id = self.advance(quiet=True, end=True, dry=True)
				if id is not None:
					#logging.info("Commit")
					self.start_commit(id)
					return

				self.advance(quiet=True, end=True)
				self.playing_time = 0
				self.decode_time = 0

	def start_commit(self, commit_id: int, repeat: bool = False) -> None:
		self.commit = commit_id
		target = self.get_track(commit_id)
		self.reset_ab_repeat_on_track_change(target.index)
		self.target_open = target.fullpath
		self.target_object = target
		self.start_time = target.start_time
		self.start_time_target = self.start_time
		self.playerCommand = "open"
		if repeat:
			self.playerSubCommand = "repeat"
		self.playerCommandReady = True

	def advance(
		self, rr: bool = False, quiet: bool = False, inplace: bool = False, end: bool = False,
		force: bool = False, play: bool = True, dry: bool = False,
	) -> int | None:

		if self.playing_state == PlayingState.PAUSED and not self.prefs.resume_on_jump:
			play = False
			if not dry:
				self.playerCommand = "stop"
				self.playerCommandReady = True

		# Temporary Workaround for UI block causing unwanted dragging
		if not dry:
			self.tauon.quick_d_timer.set()

		quiet = False  # Feature disabled intentionally, not a bug

		# Trim the history if it gets too long
		if not dry:
			while len(self.track_queue) > 250:
				self.queue_step -= 1
				del self.track_queue[0]

		# Save info about the track we are leaving
		if not dry and len(self.track_queue) > 0:
			self.left_time = self.playing_time
			self.left_index = self.track_queue[self.queue_step]

		# Test to register skip (not currently used for anything)
		if not dry and self.playing_state == PlayingState.PLAYING and 1 < self.left_time < 45:
			self.master_library[self.left_index].skips += 1
			#logging.info('skip registered')

		if not dry:
			self.playing_time = 0
			self.decode_time = 0
			self.playing_length = 100
			self.gui.update_spec = 0

		old = self.queue_step
		end_of_playlist = False

		# Force queue (middle click on track)
		if len(self.force_queue) > 0 and not self.pause_queue:

			q = self.force_queue[0]
			target_index = q.track_id

			if q.type == QueueType.ALBUM:
				if q.album_stage == 0:
					# We have not started playing the album yet
					# So we go to that track
					# (This is a copy of the track code, but we don't delete the item)

					if not dry:
						pl = self.id_to_pl(q.playlist_id)
						if pl is not None:
							self.active_playlist_playing = pl

						if target_index not in self.playing_playlist():
							del self.force_queue[0]
							self.advance()
							return None

					if dry:
						return target_index

					self.playlist_playing_position = q.position
					self.track_queue.append(target_index)
					self.queue_step = len(self.track_queue) - 1
					# self.queue_target = len(self.track_queue) - 1
					#if play:
					self.play_target(jump=not end, play=play)

					#  Set the flag that we have entered the album
					self.force_queue[0].album_stage = 1

					# This code is mirrored below -------
					ok_continue = True

					# Check if we are at end of playlist
					pl = self.multi_playlist[self.active_playlist_playing].playlist_ids
					if self.playlist_playing_position > len(pl) - 3:
						ok_continue = False

					# Check next song is in album
					if ok_continue and self.get_track(pl[self.playlist_playing_position + 1]).parent_folder_path != self.get_track(target_index).parent_folder_path:
						ok_continue = False

					# -----------

				elif q.album_stage == 1:
					# We have previously started playing this album

					# Check to see if we still are:
					ok_continue = True

					if self.get_track(target_index).parent_folder_path != self.playing_object().parent_folder_path:
						# Remember to set jumper check this too (leave album if we jump to some other track, i.e. double click))
						ok_continue = False

					pl = self.multi_playlist[self.active_playlist_playing].playlist_ids

					# Check next song is in album
					if ok_continue:

						# Check if we are at end of playlist, or already at end of album
						if self.playlist_playing_position >= len(pl) - 1 or (self.playlist_playing_position < len(
								pl) - 1 and \
								self.get_track(pl[self.playlist_playing_position + 1]).parent_folder_path != self.get_track(
							target_index).parent_folder_path):

							if dry:
								return None

							del self.force_queue[0]
							self.advance()
							return None


						# Check if 2 songs down is in album, remove entry in queue if not
						if self.playlist_playing_position < len(pl) - 2 and \
								self.get_track(pl[self.playlist_playing_position + 2]).parent_folder_path != self.get_track(
							target_index).parent_folder_path:
							ok_continue = False

					# if ok_continue:
					# We seem to be still in the album. Step down one and play
					if not dry:
						self.playlist_playing_position += 1

					if len(pl) <= self.playlist_playing_position:
						if dry:
							return None
						logging.info("END OF PLAYLIST!")
						del self.force_queue[0]
						self.advance()
						return None

					if dry:
						return pl[self.playlist_playing_position + 1]
					self.track_queue.append(pl[self.playlist_playing_position])
					self.queue_step = len(self.track_queue) - 1
					# self.queue_target = len(self.track_queue) - 1
					#if play:
					self.play_target(jump=not end, play=play)

				if not ok_continue:
					# It seems this item has expired, remove it and call advance again

					if dry:
						return None

					logging.info("Remove expired album from queue")
					del self.force_queue[0]

					if q.auto_stop:
						self.stop_mode = StopMode.TRACK
					if self.prefs.stop_end_queue and not self.force_queue:
						self.stop_mode = StopMode.TRACK

					if self.queue_box.scroll_position > 0:
						self.queue_box.scroll_position -= 1

						# self.advance()
						# return

			else:
				# This is track type
				pl = self.id_to_pl(q.playlist_id)
				if not dry and pl is not None:
					self.active_playlist_playing = pl

				if target_index not in self.playing_playlist():
					if dry:
						return None
					del self.force_queue[0]
					self.advance()
					return None

				if dry:
					return target_index

				self.playlist_playing_position = q.position
				self.track_queue.append(target_index)
				self.queue_step = len(self.track_queue) - 1
				# self.queue_target = len(self.track_queue) - 1
				#if play:
				self.play_target(jump=not end, play=play)
				del self.force_queue[0]
				if q.auto_stop:
					self.stop_mode = StopMode.TRACK
				if self.prefs.stop_end_queue and not self.force_queue:
					self.stop_mode = StopMode.TRACK
				if self.queue_box.scroll_position > 0:
					self.queue_box.scroll_position -= 1

		# Stop if playlist is empty
		elif len(self.playing_playlist()) == 0:
			if dry:
				return None
			self.stop()
			return 0

		# Playback follow cursor
		elif self.prefs.playback_follow_cursor and self.playing_ready() \
				and self.selected_ready() \
				and self.default_playlist[self.selected_in_playlist] != self.playing_object().index:

			if dry:
				return self.default_playlist[self.selected_in_playlist]

			self.active_playlist_playing = self.active_playlist_viewing
			self.playlist_playing_position = self.selected_in_playlist

			self.track_queue.append(self.default_playlist[self.selected_in_playlist])
			self.queue_step = len(self.track_queue) - 1
			#if play:
			self.play_target(jump=not end, play=play)

		# If random, jump to random track
		elif (self.random_mode or rr) and len(self.playing_playlist()) > 0 and not (
				self.album_shuffle_mode or self.prefs.album_shuffle_lock_mode):
			# self.queue_step += 1
			new_step = self.queue_step + 1

			if new_step == len(self.track_queue):

				if self.album_repeat_mode and self.repeat_mode:
					# Album shuffle mode
					pp = self.playing_playlist()
					k = self.playlist_playing_position
					# ti = self.get_track(pp[k])
					ti = self.master_library[self.track_queue[self.queue_step]]

					if ti.index not in pp:
						if dry:
							return None
						logging.info("No tracks to repeat!")
						return 0

					matches: list[tuple[int, int]] = []
					for i, p in enumerate(pp):

						if self.get_track(p).parent_folder_path == ti.parent_folder_path:
							matches.append((i, p))

					if matches:
						# Avoid a repeat of same track
						if len(matches) > 1 and (k, ti.index) in matches:
							matches.remove((k, ti.index))

						i, p = random.choice(matches)  # not used

						if self.prefs.true_shuffle:
							id = ti.parent_folder_path
							while True:
								if id in self.shuffle_pools:
									pool = self.shuffle_pools[id]

									if not pool:
										del self.shuffle_pools[id]  # Trigger a refill
										continue

									ref = pool.pop()
									if dry:
										pool.append(ref)
										return ref[1]
									# ref = random.choice(pool)
									# pool.remove(ref)

									if ref[1] not in pp:  # Check track still in the live playlist
										logging.info("Track not in pool")
										continue

									i, p = ref  # Find position of reference in playlist
									break

								# Refill the pool
								random.shuffle(matches)
								self.shuffle_pools[id] = matches
								logging.info("Refill folder shuffle pool")

						self.playlist_playing_position = i
						self.track_queue.append(p)
				else:
					# Normal select from playlist
					if self.prefs.true_shuffle:
						# True shuffle avoids repeats by using a pool
						pl = self.multi_playlist[self.active_playlist_playing]
						id = pl.uuid_int

						while True:

							if id in self.shuffle_pools:
								pool = self.shuffle_pools[id]
								if not pool:
									del self.shuffle_pools[id]  # Trigger a refill
									continue

								ref = pool.pop()
								if dry:
									pool.append(ref)
									return ref
								# ref = random.choice(pool)
								# pool.remove(ref)

								if ref not in pl.playlist_ids:  # Check track still in the live playlist
									continue

								random_jump = pl.playlist_ids.index(ref)  # Find position of reference in playlist
								break

							# Refill the pool
							self.update_shuffle_pool(pl.uuid_int)
					else:
						random_jump = random.randrange(len(self.playing_playlist()))  # not used

					self.playlist_playing_position = random_jump
					self.track_queue.append(self.playing_playlist()[random_jump])

			if inplace and self.queue_step > 1:
				del self.track_queue[self.queue_step]
			else:
				if dry:
					return self.track_queue[new_step]
				self.queue_step = new_step

			if rr:
				if dry:
					return None
				self.play_target_rr(play=play)
			else:
				self.play_target(jump=not end, play=play)


		# If not random mode, Step down 1 on the playlist
		elif self.random_mode is False and len(self.playing_playlist()) > 0:
			# Stop at end of playlist
			if self.playlist_playing_position == len(self.playing_playlist()) - 1:
				if dry:
					return None
				if self.prefs.end_setting == "stop":
					self.playing_state = PlayingState.STOPPED
					self.playerCommand = "runstop"
					self.playerCommandReady = True
					end_of_playlist = True

				elif self.prefs.end_setting in ("advance", "cycle"):
					# If at end playlist and not cycle mode, stop playback
					if self.active_playlist_playing == len(
							self.multi_playlist) - 1 and self.prefs.end_setting != "cycle":
						self.playing_state = PlayingState.STOPPED
						self.playerCommand = "runstop"
						self.playerCommandReady = True
						end_of_playlist = True

					else:
						p = self.active_playlist_playing
						for i in range(len(self.multi_playlist)):

							k = (p + i + 1) % len(self.multi_playlist)

							# Skip a playlist if empty
							if not (self.multi_playlist[k].playlist_ids):
								continue

							# Skip a playlist if hidden
							if self.multi_playlist[k].hidden and self.prefs.tabs_on_top:
								continue

							# Set found playlist as playing the first track
							self.active_playlist_playing = k
							self.playlist_playing_position = -1
							self.advance(end=end, force=True, play=play)
							break

						else:
							# Restart current if no other eligible playlist found
							self.playlist_playing_position = -1
							self.advance(end=end, force=True, play=play)

						return None

				elif self.prefs.end_setting == "repeat":
					self.playlist_playing_position = -1
					self.advance(end=end, force=True, play=play)
					return None

				self.gui.update += 3

			else:
				if self.playlist_playing_position > len(self.playing_playlist()) - 1:
					if dry:
						return None
					self.playlist_playing_position = 0

				elif not force and self.track_queue and self.playing_playlist()[
					self.playlist_playing_position] != self.track_queue[
					self.queue_step] and self.track_queue[self.queue_step] in self.playing_playlist():
					try:
						if dry:
							return None
						self.playlist_playing_position = self.playing_playlist().index(
							self.track_queue[self.queue_step])
					except Exception:
						logging.exception("Failed to set playlist_playing_position")

				if len(self.playing_playlist()) == self.playlist_playing_position + 1:
					return None

				if dry:
					return self.playing_playlist()[self.playlist_playing_position + 1]
				self.playlist_playing_position += 1
				self.track_queue.append(self.playing_playlist()[self.playlist_playing_position])

				# logging.info("standand advance")
				# self.queue_target = len(self.track_queue) - 1
				# if end:
				#	 self.play_target_gapless(jump= not end)
				# else:
				self.queue_step = len(self.track_queue) - 1
				#if play:
				self.play_target(jump=not end, play=play)

		elif self.random_mode and (self.album_shuffle_mode or self.prefs.album_shuffle_lock_mode):
			# Album shuffle mode
			logging.info("Album shuffle mode")
			po = self.playing_object()
			redraw = False

			# Checks
			if po is not None and len(self.playing_playlist()) > 0:
				# If we at end of playlist, we'll go to a new album
				if len(self.playing_playlist()) == self.playlist_playing_position + 1:
					redraw = True
				# If the next track is a new album, go to a new album
				elif po.parent_folder_path != self.get_track(
						self.playing_playlist()[self.playlist_playing_position + 1]).parent_folder_path:
					redraw = True
				# Always redraw on press in album shuffle lockdown
				if self.prefs.album_shuffle_lock_mode and not end:
					redraw = True

				if not redraw:
					if dry:
						return self.playing_playlist()[self.playlist_playing_position + 1]
					self.playlist_playing_position += 1
					self.track_queue.append(self.playing_playlist()[self.playlist_playing_position])
					self.queue_step = len(self.track_queue) - 1
					# self.queue_target = len(self.track_queue) - 1
					#if play:
					self.play_target(jump=not end, play=play)
				else:
					if dry:
						return None
					albums: list[int] = []
					current_folder = ""
					for i in range(len(self.playing_playlist())):
						if i == 0:
							albums.append(i)
							current_folder = self.master_library[self.playing_playlist()[i]].parent_folder_path
						elif self.master_library[self.playing_playlist()[i]].parent_folder_path != current_folder:
							current_folder = self.master_library[self.playing_playlist()[i]].parent_folder_path
							albums.append(i)

					random.shuffle(albums)

					for a in albums:
						if self.get_track(self.playing_playlist()[a]).parent_folder_path != self.playing_object().parent_folder_path:
							self.playlist_playing_position = a
							self.track_queue.append(self.playing_playlist()[a])
							self.queue_step = len(self.track_queue) - 1
							# self.queue_target = len(self.track_queue) - 1
							#if play:
							self.play_target(jump=not end, play=play)
							break
					else:
						# There was no different album; restart from the first album in the playlist.
						a = 0
						self.playlist_playing_position = a
						self.track_queue.append(self.playing_playlist()[a])
						self.queue_step = len(self.track_queue) - 1
						#if play:
						self.play_target(jump=not end, play=play)
						# logging.info("THERE IS ONLY ONE ALBUM IN THE PLAYLIST")
						# self.stop()
		else:
			logging.error("ADVANCE ERROR - NO CASE!")

		if dry:
			return None

		if self.active_playlist_viewing == self.active_playlist_playing:
			self.show_current(quiet=quiet)
		elif self.prefs.auto_goto_playing:
			self.show_current(quiet=quiet, this_only=True, playing=False, highlight=True, no_switch=True)

		# if self.prefs.album_mode:
		#	 self.tauon.goto_album(self.playlist_playing)

		self.render_playlist()

		self.refresh_now_playing()
		self.lfm_scrobbler.start_queue()
		if play:
			self.tauon.notify_song(end_of_playlist, delay=1.3)
		return None

	def reset_missing_flags(self) -> None:
		for value in self.master_library.values():
			value.found = True
		self.gui.pl_update += 1

class LastFMapi:
	def __init__(self, tauon: Tauon, pctl: PlayerCtl) -> None:
		self.tauon: Tauon = tauon
		self.star_store: StarStore = pctl.star_store
		self.show_message = tauon.show_message
		self.last_fm_enable: bool = tauon.bag.last_fm_enable
		self.gui: GuiVar = self.tauon.gui
		self.pctl: PlayerCtl = pctl
		self.prefs: Prefs = self.tauon.prefs
		self.sg: SessionKeyGenerator | None = None
		self.url: str | None = None
		self.API_SECRET = "6e433964d3ff5e817b7724d16a9cf0cc"  # noqa: S105
		self.connected = False
		self.API_KEY = "bfdaf6357f1dddd494e5bee1afe38254"
		self.scanning_username = ""

		self.network: LibreFMNetwork | None = None
		self.lastfm_network: LastFMNetwork | None = None
		self.tries = 0

		self.scanning_friends = False
		self.scanning_loves = False
		self.scanning_scrobbles = False

	def get_network(self) -> type[LibreFMNetwork | LastFMNetwork]:
		if self.prefs.use_libre_fm:
			return pylast.LibreFMNetwork
		return pylast.LastFMNetwork

	def auth1(self) -> None:
		r"""Step 1 where the user clicks \"Login\""""
		if not self.last_fm_enable:
			self.show_message(_("Optional module python-pylast not installed"), mode="warning")
			return

		if self.network is None:
			self.no_user_connect()

		self.sg = pylast.SessionKeyGenerator(self.network)
		try:
			self.url = self.sg.get_web_auth_url()
		except pylast.NetworkError:
			logging.exception("Failed to get web auth URL from pylast due to a network error")
			self.show_message("Failed to get web auth URL from pylast", "Network error")
			return
		except Exception:
			logging.exception("Failed to get web auth URL from pylast due to an unknown issue")
			self.show_message("Failed to get web auth URL from pylast", "Unknown error")
			return
		logging.info(str(self.url))
		copy_to_clipboard(self.url)
		self.show_message(_("Web auth page opened"), _("Once authorised click the 'done' button."), mode="arrow")
		webbrowser.open(self.url, new=2, autoraise=True)

	def auth2(self) -> None:
		r"""Step 2 where the user clicks \"Done\""""
		if self.sg is None:
			self.show_message(_("You need to log in first"))
			return

		try:
			# session_key = self.sg.get_web_auth_session_key(self.url)
			session_key, username = self.sg.get_web_auth_session_key_username(self.url)
			self.prefs.last_fm_token = session_key
			self.network = self.get_network()(api_key=self.API_KEY, api_secret=
			self.API_SECRET, session_key=self.prefs.last_fm_token)
			# user = self.network.get_authenticated_user()
			# username = user.get_name()
			self.prefs.last_fm_username = username

		except Exception as e:
			if "Unauthorized Token" in str(e):
				logging.exception("Not authorized")
				self.show_message(_("Error - Not authorized"), mode="error")
			else:
				logging.exception("Unknown error")
				self.show_message(_("Error"), _("Unknown error."), mode="error")

		if not self.tauon.toggle_lfm_auto(mode=1):
			self.tauon.toggle_lfm_auto()

	def auth3(self) -> None:
		"""Used for 'logout'"""
		self.prefs.last_fm_token = None
		self.prefs.last_fm_username = ""
		self.show_message(_("Logout will complete on app restart."))

	def connect(self, m_notify: bool = True) -> bool | None:
		if not self.last_fm_enable:
			return False

		if self.connected is True:
			if m_notify:
				self.show_message(_("Already connected to Last.fm"))
			return True

		if self.prefs.last_fm_token is None:
			self.show_message(_("No Last.Fm account registered"), _("Authorise an account in settings"), mode="info")
			return None

		logging.info("Attempting to connect to Last.fm network")

		try:
			self.network = self.get_network()(
				api_key=self.API_KEY, api_secret=self.API_SECRET, session_key=self.prefs.last_fm_token)  # , username=lfm_username, password_hash=lfm_hash)

			self.connected = True
			if m_notify:
				self.show_message(_("Connection to Last.fm was successful."), mode="done")

			logging.info("Connection to lastfm appears successful")
			return True

		except Exception as e:
			logging.exception("Error connecting to Last.fm network")
			self.show_message(_("Error connecting to Last.fm network"), str(e), mode="warning")
			return False

	def toggle(self) -> None:
		self.prefs.scrobble_hold ^= True

	def details_ready(self) -> bool:
		return bool(self.prefs.last_fm_token)

	def last_fm_only_connect(self) -> bool:
		if not self.last_fm_enable:
			return False
		try:
			self.lastfm_network = pylast.LastFMNetwork(api_key=self.API_KEY, api_secret=self.API_SECRET)
			logging.info("Connection appears successful")
			return True

		except Exception as e:
			logging.exception("Error communicating with Last.fm network")
			self.show_message(_("Error communicating with Last.fm network"), str(e), mode="warning")
			return False

	def no_user_connect(self) -> bool:
		if not self.last_fm_enable:
			return False
		try:
			self.network = self.get_network()(api_key=self.API_KEY, api_secret=self.API_SECRET)
			logging.info("Connection appears successful")
			return True

		except Exception as e:
			logging.exception("Error communicating with Last.fm network")
			self.show_message(_("Error communicating with Last.fm network"), str(e), mode="warning")
			return False

	def get_all_scrobbles_estimate_time(self) -> float | None:
		if not self.connected:
			self.connect(False)
		if not self.connected or not self.prefs.last_fm_username:
			return None

		user = pylast.User(self.prefs.last_fm_username, self.network)
		total = user.get_playcount()

		if total:
			return 0.04364 * total
		return 0

	def get_all_scrobbles(self) -> None:
		if not self.connected:
			self.connect(False)
		if not self.connected or not self.prefs.last_fm_username:
			return

		try:
			self.scanning_scrobbles = True
			self.network.enable_rate_limit()
			user = pylast.User(self.prefs.last_fm_username, self.network)
			# username = user.get_name()
			self.tauon.perf_timer.set()
			tracks = user.get_recent_tracks(None)

			counts: dict[tuple[str, str], int] = {}

			# Count up the unique pairs
			for track in tracks:
				key = (str(track.track.artist), str(track.track.title))
				c = counts.get(key, 0)
				counts[key] = c + 1

			touched: list[int] = []

			# Add counts to matching tracks
			for key, value in counts.items():
				artist, title = key
				artist = artist.lower()
				title = title.lower()

				for track in self.pctl.master_library.values():
					t_artist = track.artist.lower()
					artists = [x.lower() for x in get_split_artists(track)]
					if t_artist == artist or artist in artists or (
							track.album_artist and track.album_artist.lower() == artist):
						if track.title.lower() == title:
							if track.index in touched:
								track.lfm_scrobbles += value
							else:
								track.lfm_scrobbles = value
								touched.append(track.index)
		except Exception:
			logging.exception("Scanning failed. Try again?")
			self.gui.pl_update += 1
			self.scanning_scrobbles = False
			self.show_message(_("Scanning failed. Try again?"), mode="error")
			return

		logging.info(self.tauon.perf_timer.get())
		self.gui.pl_update += 1
		self.scanning_scrobbles = False
		self.tauon.bg_save()
		self.show_message(_("Scanning scrobbles complete"), mode="done")

	def artist_info(self, artist: str) -> tuple[bool, str | None, str, str | None, str | None] | tuple[bool, str, str]:
		if self.lastfm_network is None and self.last_fm_only_connect() is False:
			return False, "", ""

		if artist:
			first_artist = get_first_artist(artist)
			logging.info(f"Artist info lookup: '{artist}' -> first_artist='{first_artist}'")
			attempts = [artist] if artist != first_artist else []
			attempts.append(first_artist)
		else:
			attempts = []

		for attempt_artist in attempts:
			try:
				logging.info(f"Trying Last.fm: '{attempt_artist}'")
				l_artist = pylast.Artist(attempt_artist, self.lastfm_network)
				bio = l_artist.get_bio_content()
				mbid = l_artist.get_mbid()
				url = l_artist.get_url()
				logging.info(f"Success for '{attempt_artist}'")
				return True, bio, "", mbid, url
			except Exception:
				logging.exception(f"last.fm get artist info failed for '{attempt_artist}'")

		return False, "", "", "", ""

	def artist_mbid(self, artist: str) -> str | None:
		if self.lastfm_network is None and self.last_fm_only_connect() is False:
			return ""

		if artist:
			first_artist = get_first_artist(artist)
			attempts = [artist] if artist != first_artist else []
			attempts.append(first_artist)
		else:
			attempts = []

		for attempt_artist in attempts:
			try:
				l_artist = pylast.Artist(attempt_artist, self.lastfm_network)
				return l_artist.get_mbid()
			except Exception:
				logging.exception("last.fm get artist mbid info failed")

		return ""

	def sync_pull_love(self, track_object: TrackClass) -> None:
		if not self.prefs.lastfm_pull_love or not (track_object.artist and track_object.title):
			return
		if not self.last_fm_enable:
			return
		if self.prefs.auto_lfm:
			self.connect(False)
		if not self.connected:
			return

		try:
			track = self.network.get_track(track_object.artist, track_object.title)
			if not track:
				logging.error("Get love: track not found")
				return
			track.username = self.prefs.last_fm_username

			remote_loved = track.get_userloved()

			if track_object.title != track.get_correction() or track_object.artist != track.get_artist().get_correction():
				logging.warning(f"pyLast/Last.fm bug workaround. API thought {track_object.artist} - {track_object.title} loved status was: {remote_loved}")
				return

			if remote_loved is None:
				logging.error("Error getting loved status")
				return

			local_loved = self.tauon.love(set=False, track_id=track_object.index, notify=False, sync=False)

			if remote_loved != local_loved:
				self.tauon.love(set=True, track_id=track_object.index, notify=False, sync=False)
		except Exception:
			logging.exception("Failed to pull love")

	def scrobble(self, track_object: TrackClass, timestamp: int | None = None) -> bool:
		if not self.last_fm_enable:
			return True
		if self.prefs.scrobble_hold:
			return True
		if self.prefs.auto_lfm:
			self.connect(False)

		if timestamp is None:
			timestamp = int(time.time())

		# lastfm_user = self.network.get_user(self.username)

		title = track_object.title
		album = track_object.album
		artist = get_artist_strip_feat(track_object)
		album_artist = track_object.album_artist

		logging.info("Submitting scrobble...")

		# Act
		try:
			if title and artist:
				if album:
					if album_artist and album_artist != artist:
						self.network.scrobble(
							artist=artist, title=title, album=album, album_artist=album_artist, timestamp=timestamp)
					else:
						self.network.scrobble(artist=artist, title=title, album=album, timestamp=timestamp)
				else:
					self.network.scrobble(artist=artist, title=title, timestamp=timestamp)
				# logging.info('Scrobbled')

				# Pull loved status

				self.sync_pull_love(track_object)
			else:
				logging.warning("Not sent, incomplete metadata")

		except Exception as e:
			logging.exception("Failed to Scrobble!")
			if "retry" in str(e):
				logging.warning("Retrying in a couple seconds...")
				time.sleep(7)

				try:
					self.network.scrobble(artist=artist, title=title, timestamp=timestamp)
					# logging.info('Scrobbled')
					return True
				except Exception:
					logging.exception("Failed to retry!")

			# self.show_message(_("Error: Could not scrobble. ", str(e), mode='warning')
			logging.error("Error connecting to last.fm")
			self.tauon.scrobble_warning_timer.set()
			self.gui.update += 1
			self.gui.delay_frame(5)

			return False
		return True

	def get_bio(self, artist: str) -> str:
		if self.lastfm_network is None and self.last_fm_only_connect() is False:
			return ""

		first_artist = get_first_artist(artist)
		attempts = [artist] if artist != first_artist else []
		attempts.append(first_artist)

		for attempt_artist in attempts:
			try:
				artist_object = pylast.Artist(attempt_artist, self.lastfm_network)
				return artist_object.get_bio_summary(language="en")
			except Exception:
				logging.exception(f"Last.fm bio lookup failed for '{attempt_artist}'")

		return ""
		# logging.info(artist_object.get_cover_image())
		# logging.info("\n\n")
		# logging.info(bio)
		# logging.info("\n\n")
		# logging.info(artist_object.get_bio_content())
		# return bio
		# else:
		#	return ""

	def love(self, artist: str, title: str) -> None:
		if not self.connected and self.prefs.auto_lfm:
			self.connect(False)
			self.prefs.scrobble_hold = True
		if self.connected and artist and title:
			track = self.network.get_track(artist, title)
			track.love()

	def unlove(self, artist: str, title: str) -> None:
		if not self.last_fm_enable:
			return
		if not self.connected and self.prefs.auto_lfm:
			self.connect(False)
			self.prefs.scrobble_hold = True
		if self.connected and artist and title:
			track = self.network.get_track(artist, title)
			track.love()
			track.unlove()

	def clear_friends_love(self) -> None:
		count = 0
		for index, tr in self.pctl.master_library.items():
			count += len(tr.lfm_friend_likes)
			tr.lfm_friend_likes.clear()

		self.show_message(_("Removed {N} loves.").format(N=count))

	def get_friends_love(self) -> None:
		if not self.last_fm_enable:
			return
		self.scanning_friends = True

		try:
			username = self.prefs.last_fm_username
			logging.info(f"Username is {username}")

			if not username:
				self.scanning_friends = False
				self.show_message(_("There was an error, try re-log in"))
				return

			if self.network is None:
				self.no_user_connect()

			self.network.enable_rate_limit()
			lastfm_user = self.network.get_user(username)
			friends = lastfm_user.get_friends(limit=None)
			self.show_message(_("Getting friend data..."), _("This may take a very long time."), mode="info")
			for friend in friends:
				self.scanning_username = friend.name
				logging.info(f"Getting friend loves: {friend.name}")

				try:
					loves = friend.get_loved_tracks(limit=None)
				except Exception:
					logging.exception("Failed to get_loved_tracks!")
					continue

				for track in loves:
					title = track.track.title.casefold()
					artist = track.track.artist.name.casefold()
					for index, tr in self.pctl.master_library.items():
						if tr.title.casefold() == title and tr.artist.casefold() == artist:
							tr.lfm_friend_likes.add(friend.name)
							logging.info("MATCH")
							logging.info(f"     {artist} - {title}")
							logging.info(f"      ----- {friend.name}")

		except Exception:
			logging.exception("There was an error getting friends loves")
			self.show_message(_("There was an error getting friends loves"), "", mode="warning")

		self.scanning_friends = False

	def dl_love(self) -> None:
		if not self.last_fm_enable:
			return
		username = self.prefs.last_fm_username
		self.show_message(_("Scanning loved tracks for: {username}").format(username=username), mode="info")
		self.scanning_username = username

		if not username:
			self.show_message(_("No username found"), mode="error")
			return

		if len(username) > 25:
			logging.error("Aborted due to long username")
			return

		self.scanning_loves = True

		logging.info("Connect for friend scan")

		try:
			if self.network is None:
				self.no_user_connect()

			self.network.enable_rate_limit()
			logging.info("Get user...")
			lastfm_user = self.network.get_user(username)
			tracks = lastfm_user.get_loved_tracks(limit=None)

			matches = 0
			updated = 0

			for track in tracks:
				title = track.track.title.casefold()
				artist = track.track.artist.name.casefold()

				for index, tr in self.pctl.master_library.items():
					if tr.title.casefold() == title and tr.artist.casefold() == artist:
						matches += 1
						logging.info("MATCH:")
						logging.info(f"     {artist} - {title}")
						star = self.star_store.full_get(index)
						if star is None:
							star = StarRecord()
						if not star.loved:
							updated += 1
							logging.info("     NEW LOVE")
							star.loved = True

						self.star_store.insert(index, star)

			self.scanning_loves = False
			if len(tracks) == 0:
				self.show_message(_("User has no loved tracks."))
				return
			if matches > 0 and updated == 0:
				self.show_message(_("{N} matched tracks are up to date.").format(N=str(matches)))
				return
			if matches > 0 and updated > 0:
				self.show_message(_("{N} tracks matched. {T} were updated.").format(N=str(matches), T=str(updated)))
				return
			self.show_message(_("Of {N} loved tracks, no matches were found in local db").format(N=str(len(tracks))))
			return
		except Exception:
			logging.exception("This doesn't seem to be working :(")
			self.show_message(_("This doesn't seem to be working :("), mode="error")
		self.scanning_loves = False

	def update(self, track_object: TrackClass) -> int | None:
		if not self.last_fm_enable:
			return None
		if self.prefs.scrobble_hold:
			return 0
		if self.prefs.auto_lfm:
			if self.connect(False) is False:
				self.prefs.auto_lfm = False
		else:
			return 0

		# logging.info('Updating Now Playing')

		title = track_object.title
		album = track_object.album
		artist = get_artist_strip_feat(track_object)

		try:
			if title and artist:
				self.network.update_now_playing(
					artist=artist, title=title, album=album)
				return 0
			logging.error("Not sent, incomplete metadata")
			return 0
		except Exception as e:
			logging.exception("Error connecting to last.fm.")
			if "retry" in str(e):
				return 2
				# self.show_message(_("Could not update Last.fm. ", str(e), mode='warning')
			self.pctl.b_time -= 5000
			return 1

class ListenBrainz:

	def __init__(self, tauon: Tauon) -> None:
		self.bag          = tauon.bag
		self.prefs        = tauon.prefs
		self.t_title      = tauon.t_title
		self.n_version    = tauon.n_version
		self.show_message = tauon.show_message
		self.enable       = tauon.prefs.enable_lb
		# self.url = "https://api.listenbrainz.org/1/submit-listens"

	def url(self) -> str:
		url = self.prefs.listenbrainz_url
		if not url:
			url = "https://api.listenbrainz.org/"
		if not url.endswith("/"):
			url += "/"
		return url + "1/submit-listens"

	def listen_full(self, track_object: TrackClass, time: int) -> bool | None:
		if self.enable is False:
			return True
		if self.prefs.scrobble_hold is True:
			return True
		if self.prefs.lb_token is None:
			self.show_message(_("ListenBrainz is enabled but there is no token."), _("How did this even happen."), mode="error")
			return None

		title = track_object.title
		album = track_object.album
		artist = get_artist_strip_feat(track_object)

		if title == "" or artist == "":
			return True

		data = {"listen_type": "single", "payload": []}
		metadata = {
			"track_name": title,
			**({"release_name": album} if album else {}),
			"artist_name": artist,
			}

		additional: dict[str, str] = {}

		# MusicBrainz Artist IDs
		if track_object.musicbrainz_artistids is not None:
			additional["artist_mbids"] = track_object.musicbrainz_artistids

		# MusicBrainz Release ID
		if track_object.musicbrainz_albumid is not None:
			additional["release_mbid"] = track_object.musicbrainz_albumid

		# MusicBrainz Recording ID
		if track_object.musicbrainz_recordingid is not None:
			additional["recording_mbid"] = track_object.musicbrainz_recordingid

		# MusicBrainz Track ID
		if track_object.musicbrainz_trackid is not None:
			additional["track_mbid"] = track_object.musicbrainz_trackid

		if additional:
			metadata["additional_info"] = additional

		# logging.info(additional)
		data["payload"].append({"track_metadata": metadata})
		data["payload"][0]["listened_at"] = time

		r = requests.post(self.url(), headers={"Authorization": "Token " + self.prefs.lb_token}, data=json.dumps(data), timeout=10)
		if r.status_code != 200:
			self.show_message(_("There was an error submitting data to ListenBrainz"), r.text, mode="warning")
			return False
		return True

	def listen_playing(self, track_object: TrackClass) -> None:
		if self.enable is False:
			return
		if self.prefs.scrobble_hold is True:
			return
		if self.prefs.lb_token is None:
			self.show_message(_("ListenBrainz is enabled but there is no token."), _("How did this even happen."), mode="error")
		title = track_object.title
		album = track_object.album
		artist = get_artist_strip_feat(track_object)

		if title == "" or artist == "":
			return

		data = {"listen_type": "playing_now", "payload": []}
		metadata = {
			"track_name": title,
			**({"release_name": album} if album else {}),
			"artist_name": artist,
			}

		additional: dict[str, str] = {}

		# MusicBrainz Artist IDs
		if track_object.musicbrainz_artistids is not None:
			additional["artist_mbids"] = track_object.musicbrainz_artistids

		# MusicBrainz Release ID
		if track_object.musicbrainz_albumid is not None:
			additional["release_mbid"] = track_object.musicbrainz_albumid

		# MusicBrainz Recording ID
		if track_object.musicbrainz_recordingid is not None:
			additional["recording_mbid"] = track_object.musicbrainz_recordingid

		# MusicBrainz Track ID
		if track_object.musicbrainz_trackid is not None:
			additional["track_mbid"] = track_object.musicbrainz_trackid

		if track_object.track_number:
			try:
				additional["tracknumber"] = str(int(track_object.track_number))
			except Exception:
				logging.exception("Error trying to get track_number")

		if track_object.length:
			additional["duration"] = str(int(track_object.length))

		additional["media_player"] = self.t_title
		additional["submission_client"] = self.t_title
		additional["media_player_version"] = str(self.n_version)

		metadata["additional_info"] = additional
		data["payload"].append({"track_metadata": metadata})
		# data["payload"][0]["listened_at"] = int(time.time())

		r = requests.post(self.url(), headers={"Authorization": "Token " + self.prefs.lb_token}, data=json.dumps(data), timeout=10)
		if r.status_code != 200:
			self.show_message(_("There was an error submitting data to ListenBrainz"), r.text, mode="warning")
			logging.error("There was an error submitting data to ListenBrainz")
			logging.error(r.status_code)
			logging.error(r.json())

	def paste_key(self) -> None:
		text = copy_from_clipboard()
		if text == "":
			self.show_message(_("There is no text in the clipboard"), mode="error")
			return

		if self.prefs.listenbrainz_url:
			self.prefs.lb_token = text
			return

		if len(text) == 36 and text[8] == "-":
			self.prefs.lb_token = text
		else:
			self.show_message(_("That is not a valid token."), mode="error")

	def clear_key(self) -> None:
		self.prefs.lb_token = ""
		save_prefs(self.bag)
		self.enable = False

class LastScrob:

	def __init__(self, tauon: Tauon, pctl: PlayerCtl) -> None:
		self.pctl    = pctl
		self.tauon   = tauon
		self.lb      = tauon.lb
		self.gui     = tauon.gui
		self.prefs   = tauon.prefs
		self.lastfm  = pctl.lastfm
		self.a_index = -1
		self.a_sc    = False
		self.a_pt    = False
		self.running = False
		self.queue: list[tuple[TrackClass, int, str]] = []

	def scrobble_allowed(self, track_object: TrackClass) -> bool:
		"""Whether Tauon may scrobble this track to Last.fm/ListenBrainz/Maloja.

		Lets the user disable scrobbling per streaming service when that service
		already scrobbles on its own (e.g. a Jellyfin plugin or an Airsonic/TIDAL
		account linked directly to Last.fm), avoiding double scrobbles.
		"""
		prefs = self.prefs
		ext = track_object.file_ext
		if ext == "JELY":
			return prefs.scrobble_jellyfin
		if ext == "TIDAL":
			return prefs.scrobble_tidal
		if ext == "SUB":
			return prefs.scrobble_subsonic
		if ext == "PLEX":
			return prefs.scrobble_plex
		return True

	def start_queue(self) -> None:
		self.running = True
		mini_t = threading.Thread(target=self.process_queue)
		mini_t.daemon = True
		mini_t.start()

	def process_queue(self) -> None:
		time.sleep(0.4)

		while self.queue:
			try:
				tr = self.queue.pop()

				self.gui.pl_update = 1
				logging.info(f"Submit Scrobble {tr[0].artist} - {tr[0].title}")

				success = True

				if tr[2] == "lfm" and self.prefs.auto_lfm and (self.lastfm.connected or self.lastfm.details_ready()):
					success = self.lastfm.scrobble(tr[0], tr[1])
				elif tr[2] == "lb" and self.lb.enable:
					success = self.lb.listen_full(tr[0], tr[1])
				elif tr[2] == "maloja":
					success = self.tauon.maloja_scrobble(tr[0], tr[1])
				elif tr[2] == "air":
					success = self.tauon.subsonic.listen(tr[0], submit=True)

				if not success:
					logging.info("Re-queue scrobble")
					self.queue.append(tr)
					time.sleep(10)
					break

			except Exception:
				logging.exception("SCROBBLE QUEUE ERROR")

		if not self.queue:
			self.tauon.scrobble_warning_timer.force_set(1000)

		self.running = False

	def update(self, add_time: float) -> None:
		if self.pctl.queue_step > len(self.pctl.track_queue) - 1:
			logging.info("Queue step error 1")
			return

		if self.a_index != self.pctl.track_queue[self.pctl.queue_step]:
			self.pctl.a_time = 0
			self.pctl.b_time = 0
			self.a_index = self.pctl.track_queue[self.pctl.queue_step]
			self.a_pt = False
			self.a_sc = False
		if self.pctl.playing_time == 0 and self.a_sc is True:
			logging.info("Reset scrobble timer")
			self.pctl.a_time = 0
			self.pctl.b_time = 0
			self.a_pt = False
			self.a_sc = False

		if self.pctl.a_time > 6 and self.a_pt is False and self.pctl.master_library[self.a_index].length > 30:
			self.a_pt = True
			self.listen_track(self.pctl.master_library[self.a_index])
			# if prefs.auto_lfm and (lastfm.connected or lastfm.details_ready()) and not prefs.scrobble_hold:
			#	 mini_t = threading.Thread(target=lastfm.update, args=([pctl.master_library[self.a_index]]))
			#	 mini_t.daemon = True
			#	 mini_t.start()
			#
			# if lb.enable and not prefs.scrobble_hold:
			#	 mini_t = threading.Thread(target=lb.listen_playing, args=([pctl.master_library[self.a_index]]))
			#	 mini_t.daemon = True
			#	 mini_t.start()

		if self.pctl.a_time > 6 and self.a_pt:
			self.pctl.b_time += add_time
			if self.pctl.b_time > 20:
				self.pctl.b_time = 0
				self.listen_track(self.pctl.master_library[self.a_index])

		send_full = False
		if self.pctl.master_library[self.a_index].length > 30 and self.pctl.a_time > self.pctl.master_library[self.a_index].length \
				* 0.50 and self.a_sc is False:
			self.a_sc = True
			send_full = True

		if self.a_sc is False and self.pctl.master_library[self.a_index].length > 30 and self.pctl.a_time > 240:
			self.a_sc = True
			send_full = True

		if send_full:
			self.scrob_full_track(self.pctl.master_library[self.a_index])

	def listen_track(self, track_object: TrackClass) -> None:
		# logging.info("LISTEN")

		if track_object.is_network and track_object.file_ext == "SUB":
			self.tauon.subsonic.listen(track_object, submit=False)

		if track_object.is_network and not self.scrobble_allowed(track_object):
			return

		if not self.prefs.scrobble_hold:
			if self.prefs.auto_lfm and (self.tauon.lastfm.connected or self.tauon.lastfm.details_ready()):
				mini_t = threading.Thread(target=self.tauon.lastfm.update, args=([track_object]))
				mini_t.daemon = True
				mini_t.start()

			if self.lb.enable:
				mini_t = threading.Thread(target=self.lb.listen_playing, args=([track_object]))
				mini_t.daemon = True
				mini_t.start()

	def scrob_full_track(self, track_object: TrackClass) -> None:
		# logging.info("SCROBBLE")
		track_object.lfm_scrobbles += 1
		self.gui.pl_update += 1

		if track_object.is_network:
			if track_object.file_ext == "SUB":
				self.queue.append((track_object, int(time.time()), "air"))

		if not self.scrobble_allowed(track_object):
			return

		if not self.prefs.scrobble_hold:
			if self.prefs.auto_lfm and (self.tauon.lastfm.connected or self.tauon.lastfm.details_ready()):
				self.queue.append((track_object, int(time.time()), "lfm"))
			if self.lb.enable:
				self.queue.append((track_object, int(time.time()), "lb"))
			if self.prefs.maloja_url and self.prefs.maloja_enable:
				self.queue.append((track_object, int(time.time()), "maloja"))

class Strings:

	def __init__(self) -> None:
		self.day = _("day")
		self.days = _("days")

		self.scan_chrome = _("Scanning for Chromecasts...")
		self.cast_to = _("Cast to: %s")
		self.no_chromecasts = _("No Chromecast devices found")
		self.stop_cast = _("End Cast")

		self.web_server_stopped = _("Web server stopped.")

		self.menu_open_tauon = _("Open Tauon Music Box")
		self.menu_play_pause = _("Play/Pause")
		self.menu_next = _("Next Track")
		self.menu_previous = _("Previous Track")
		self.menu_quit = _("Quit")

class Chunker:

	def __init__(self) -> None:
		self.master_count = 0
		self.chunks = {}
		self.header = None
		self.headers = []
		self.h2 = None

		self.clients = {}

class MenuIcon:

	def __init__(self, asset: WhiteModImageAsset | LoadImageAsset) -> None:
		self.asset = asset
		self.colour = ColourRGBA(170, 170, 170, 255)
		self.base_asset = None
		self.base_asset_mod = None
		self.colour_callback = None
		self.mode_callback = None
		self.xoff = 0
		self.yoff = 0

class MenuItem:
	__slots__ = [
		"title",           # 0
		"is_sub_menu",     # 1
		"func",            # 2
		"render_func",     # 3
		"no_exit",         # 4
		"pass_ref",        # 5
		"hint",            # 6
		"icon",            # 7
		"show_test",       # 8
		"pass_ref_deco",   # 9
		"disable_test",    # 10
		"set_ref",         # 11
		"args",            # 12
		"sub_menu_number", # 13
		"sub_menu_width",  # 14
		"incrementor",     # 15
		"inc_get",         # 16
		"inc_minus",       # 17
		"inc_plus",        # 18
		"check_test",      # 19
	]
	def __init__(
		self, title: str, func, render_func: Callable[..., Decorator] | None = None, no_exit: bool = False, pass_ref: bool = False, hint=None, icon: MenuIcon | None = None, show_test: Callable[..., bool] | None = None,
		pass_ref_deco: bool = False, disable_test: Callable[..., bool] | None = None, set_ref: object | None = None, is_sub_menu: bool = False, args=None, sub_menu_number: int | None = None, sub_menu_width: int = 0,
		check_test: Callable[[], bool | None] | None = None,
	) -> None:
		self.title: str = title
		self.is_sub_menu: bool = is_sub_menu
		self.func = func
		self.render_func = render_func
		self.no_exit = no_exit
		self.pass_ref = pass_ref
		self.hint = hint
		self.icon: MenuIcon | None = icon
		self.show_test = show_test
		self.pass_ref_deco: bool = pass_ref_deco
		self.disable_test = disable_test
		self.set_ref: object | None = set_ref
		self.args = args
		self.sub_menu_number: int | None = sub_menu_number
		self.sub_menu_width: int = sub_menu_width
		# Incrementor row: a label on the left with [-] value [+] stepper buttons
		# on the right. inc_get(ref) returns the number to display; inc_minus(ref)
		# / inc_plus(ref) step it. Clicking the buttons does not close the menu;
		# clicking the label does nothing. See Menu.add_incrementor / draw_incrementor.
		self.incrementor: bool = False
		self.inc_get = None
		self.inc_minus = None
		self.inc_plus = None
		# Toggle / radio state indicator: a callable (no args) returning the
		# current on/off state. When set, the row draws a small state box
		# before the label (accent-filled when on, faint outline when off)
		# instead of the legacy "✓ " text prefix. See Menu.draw_check_box.
		self.check_test = check_test

class ThreadManager:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon = tauon
		self.prefs = tauon.prefs
		self.worker1:  threading.Thread | None = None  # Artist list, download monitor, folder move, importing, db cleaning, transcoding
		self.worker2:  threading.Thread | None = None  # Art bg, search
		self.worker3:  threading.Thread | None = None  # Gallery rendering
		self.playback: threading.Thread | None = None
		self.player_lock:       threading.Lock = threading.Lock()
		self.d: dict[str, tuple[Callable[..., None], list, threading.Thread | None]] = {}

	def ready(self, name: str) -> None:
		if self.d[name][2] is None or not self.d[name][2].is_alive():
			shoot = threading.Thread(target=self.d[name][0], args=self.d[name][1])
			shoot.daemon = True
			shoot.start()
			self.d[name][2] = shoot

	def ready_playback(self) -> None:
		if self.playback is None or not self.playback.is_alive():
			if self.prefs.backend == Backend.PHAZOR:
				self.playback = threading.Thread(target=player4, args=[self.tauon])
			# elif self.prefs.backend == Backend.GSTREAMER:
			# 	from tauon.t_modules.t_gstreamer import player3
			# 	self.playback = threading.Thread(target=player3, args=[tauon])
			self.playback.daemon = True
			self.playback.start()

	def check_playback_running(self) -> bool:
		if self.playback is None:
			return False
		return self.playback.is_alive()

class Menu:
	"""Right click context menu generator"""

	# TODO(Martin): Global class vars!
	switch = 0
	count = switch + 1
	instances: ClassVar[list[Menu]] = []
	active = False

	def rescale(self) -> None:
		self.vertical_size = round(self.base_v_size * self.gui.scale)
		self.h = self.vertical_size
		self.w = self.request_width * self.gui.scale
		if self.gui.scale == 2:
			self.w += 15

	def __init__(self, tauon: Tauon, width: int, show_icons: bool = False) -> None:
		self.tauon:           Tauon = tauon
		self.gui:            GuiVar = tauon.gui
		self.inp:             Input = tauon.inp
		self.ddt:             TDraw = tauon.ddt
		self.coll                   = tauon.coll
		self.fields:         Fields = tauon.fields
		self.colours:  ColoursClass = tauon.colours
		self.window_size: list[int] = tauon.window_size

		self.base_v_size = 22
		self.active: bool = False
		self.request_width: int = width
		self.close_next_frame: bool = False
		# True while the click currently being processed dismissed this menu (a
		# press outside its popup window). Lets toggle buttons skip reopening on
		# the same click; reset on the next button-down event.
		self.click_dismissed: bool = False
		self.clicked: bool = False
		self.pos: list[float] = [0, 0]
		self.rescale()

		self.reference: object | None = 0
		self.items: list[MenuItem | None] = []
		self.subs: list[list[MenuItem]] = []
		self.selected = -1
		self.up: bool = False
		self.down: bool = False
		self.font = 412
		self.show_icons: bool = show_icons
		self.sub_arrow = MenuIcon(asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "sub.png", True))

		self.id = Menu.count
		self.break_height = round(4 * tauon.gui.scale)

		Menu.count += 1

		self.sub_number:     int = 0
		self.sub_active:     int = -1
		self.sub_y_position: int = 0
		Menu.instances.append(self)

		self.spring_loading_timer: Timer = Timer()
		self.can_be_spring_clicked: bool = False

		# When set (popup-menu mode), the menu draws into this SecondaryWindow's
		# renderer/ddt instead of the main window, and self.pos is treated as
		# local to that window. None means draw inline in the main window.
		self.popup_window = None
		# Screen-relative anchor (offset from the main window's top-left) where
		# the popup window should appear, recorded at activate() time.
		self.popup_anchor: list[int] = [0, 0]
		# When True the anchor marks the menu's BOTTOM edge (opens upward) - used
		# for bottom-panel menus like the playback menu.
		self.popup_bottom_anchor: bool = False
		# Decided in activate(): True => draw in a separate popup window (the menu
		# or a submenu would otherwise spill outside the main window); False =>
		# draw inline in the main window (the common case).
		self.use_popup: bool = False

	@property
	def render_ddt(self) -> TDraw:
		return self.popup_window.ddt if self.popup_window is not None else self.ddt

	@property
	def render_renderer(self):
		"""Renderer to draw image assets on (None = main renderer)."""
		return self.popup_window.renderer if self.popup_window is not None else None

	@property
	def pointer(self) -> list[int]:
		"""Pointer position to hit-test the menu against.

		In popup mode the menu is drawn (and the pointer reported) in the popup
		window's own local pixel space, kept entirely separate from the main
		window's inp.mouse_position. Only the popup the pointer is actually in
		(tauon.active_pointer_window) reports a real position; the other menu
		window reports off-screen so its items don't false-hover or false-click
		from a stale pointer.
		"""
		win = self.popup_window
		if win is not None:
			if win is self.tauon.active_pointer_window:
				return list(win.last_local)
			return [-100000, -100000]
		return self.inp.mouse_position

	def deco(self, _=_) -> Decorator:
		return Decorator(self.colours.menu_text, self.colours.menu_background, None)

	def click(self) -> None:
		self.clicked = True
		# cheap hack to prevent scroll bar from being activated when closing menu
		self.inp.click_location = [0, 0]

	def add(self, menu_item: MenuItem) -> None:
		if menu_item.render_func is None:
			menu_item.render_func = self.deco
		self.items.append(menu_item)

	def add_incrementor(self, title: str, get_value, on_minus, on_plus, show_test=None) -> None:
		"""Add an incrementor row: label on the left, a [-] value [+] stepper on
		the right. get_value/on_minus/on_plus are each called with the menu's
		reference. The stepper buttons don't close the menu; the label is inert."""
		item = MenuItem(title, lambda *_: None, show_test=show_test)
		item.render_func = self.deco
		item.incrementor = True
		item.inc_get = get_value
		item.inc_minus = on_minus
		item.inc_plus = on_plus
		self.items.append(item)

	def add_incrementor_to_sub(self, sub_menu_index: int, title: str, get_value, on_minus, on_plus, show_test=None) -> None:
		"""Incrementor row (see add_incrementor), appended to a submenu."""
		item = MenuItem(title, lambda *_: None, show_test=show_test)
		item.render_func = self.deco
		item.incrementor = True
		item.inc_get = get_value
		item.inc_minus = on_minus
		item.inc_plus = on_plus
		self.subs[sub_menu_index].append(item)

	def br(self) -> None:
		self.items.append(None)

	def add_sub(self, title: str, width: int, show_test=None) -> None:
		self.items.append(MenuItem(title, self.deco, sub_menu_width=width, show_test=show_test, is_sub_menu=True, sub_menu_number=self.sub_number))
		self.sub_number += 1
		self.subs.append([])

	def add_to_sub(self, sub_menu_index: int, menu_item: MenuItem) -> None:
		if menu_item.render_func is None:
			menu_item.render_func = self.deco
		self.subs[sub_menu_index].append(menu_item)

	def test_item_active(self, item: MenuItem) -> bool:
		return not (item.show_test is not None and item.show_test(self.reference) is False)

	def is_item_disabled(self, item: MenuItem) -> bool | None:
		if item.disable_test is not None:
			if item.pass_ref_deco:
				return item.disable_test(self.reference)
			return item.disable_test()
		return None

	def draw_check_box(self, item: MenuItem, x: float, y: float) -> float:
		"""Draw the toggle/radio state box for an item with check_test at the
		label position: a small square filled with the theme's toggle accent
		when on, a faint outline when off (dimmed when the item is disabled).
		Returns the width the label should shift right to clear it.
		"""
		gui = self.gui
		ddt = self.render_ddt
		colours = self.colours
		s = round(9 * gui.scale)
		bx = round(x)
		by = round(y + (self.h - s) / 2)
		disabled = bool(self.is_item_disabled(item))
		if item.check_test():
			c = colours.toggle_box_on
			if disabled:
				c = colours.menu_text_disabled
			ddt.rect((bx, by, s, s), ColourRGBA(c.r, c.g, c.b, 150 if disabled else 255))
		else:
			line = colours.menu_text_disabled if disabled else colours.menu_text
			c = ColourRGBA(line.r, line.g, line.b, 70)
			b = max(1, round(1 * gui.scale))
			ddt.rect((bx, by, s, b), c)
			ddt.rect((bx, by + s - b, s, b), c)
			ddt.rect((bx, by + b, b, s - b * 2), c)
			ddt.rect((bx + s - b, by + b, b, s - b * 2), c)
		return s + round(7 * gui.scale)

	def render_icon(self, x: float, y: float, icon: MenuIcon | None, selected: bool, fx: Decorator) -> None:
		colours = self.colours
		gui     = self.gui
		renderer = self.render_renderer
		if colours.lm:
			selected = True

		if icon is not None:
			x += icon.xoff * gui.scale
			y += icon.yoff * gui.scale

			colour: ColourRGBA | None = None

			if icon.base_asset is None:
				# Colourise mode
				if icon.colour_callback is not None:  # and icon.colour_callback() is not None:
					colour = icon.colour_callback()
				elif selected and fx.text_colour != colours.menu_text_disabled:
					colour = icon.colour

				if colour is None and icon.base_asset_mod:
					colour = colours.menu_icons
					# if colours.lm:
					#	 colour = ColourRGBA(160, 160, 160, 255)
					icon.base_asset_mod.render(x, y, colour, renderer=renderer)
					return

				if colour is None:
					# colour = ColourRGBA(145, 145, 145, 70)
					colour = colours.menu_icons  # ColourRGBA(255, 255, 255, 35)
					# colour = ColourRGBA(50, 50, 50, 255)

				icon.asset.render(x, y, colour, renderer=renderer)
			else:
				if not is_grey(colours.menu_background):
					return  # Since these are currently pre-rendered greyscale, they are
					# Incompatible with coloured backgrounds. Fix TODO
				if selected and fx.text_colour == colours.menu_text_disabled:
					icon.base_asset.render(x, y, renderer=renderer)
					return

				# Pre-rendered mode
				if icon.mode_callback is not None:
					if icon.mode_callback():
						icon.asset.render(x, y, renderer=renderer)
					else:
						icon.base_asset.render(x, y, renderer=renderer)
				elif selected:
					icon.asset.render(x, y, renderer=renderer)
				else:
					icon.base_asset.render(x, y, renderer=renderer)

	def draw_incrementor(self, item: MenuItem, x_run: float, y_run: float, bg: ColourRGBA, ytoff: float, width: float) -> float:
		"""Draw an incrementor row's right side: [-] value [+]. Handles clicks on
		the two square stepper buttons (which don't close the menu). ``width`` is
		the row width (main column or submenu). Returns the x of the left edge of
		the minus button so the caller can bound the label.
		"""
		gui     = self.gui
		ddt     = self.render_ddt
		colours = self.colours
		scale   = gui.scale

		bs = int(self.h)                             # square button side = full row height
		by = int(y_run)
		plus_x  = int(x_run + width - bs)            # flush with the row's right edge
		num_w   = round(26 * scale)
		num_x   = plus_x - num_w
		minus_x = int(num_x - bs)

		dark = not (is_light(bg) or colours.lm)
		glyph_c = rgb_add_hls(bg, 0, 0.55 if dark else -0.55, 0)

		def button(bx: int, plus: bool) -> None:
			r = (bx, by, bs, bs)
			self.fields.add(r)
			hover = coll_point(self.pointer, r)
			face = rgb_add_hls(bg, 0, (0.16 if hover else 0.09) if dark else (-0.16 if hover else -0.09), 0)
			ddt.rect(r, face)
			gl = round(bs * 0.44)                    # glyph arm length
			gt = max(1, round(1.5 * scale))          # glyph thickness
			cx, cy = bx + bs / 2, by + bs / 2
			ddt.rect((round(cx - gl / 2), round(cy - gt / 2), gl, gt), glyph_c)
			if plus:
				ddt.rect((round(cx - gt / 2), round(cy - gl / 2), gt, gl), glyph_c)
			if hover and self.clicked:
				cb = item.inc_plus if plus else item.inc_minus
				if cb is not None:
					cb(self.reference)
					gui.update += 1

		button(minus_x, False)
		button(plus_x, True)

		value = item.inc_get(self.reference) if item.inc_get is not None else ""
		ddt.text((round(num_x + num_w / 2), int(y_run + ytoff), 2), str(value), colours.menu_text, self.font, bg=bg)

		return minus_x

	def render(self) -> None:
		tauon   = self.tauon
		gui     = self.gui
		ddt     = self.render_ddt
		inp     = self.inp
		colours = self.colours

		if self.active:
			if Menu.switch != self.id:
				self.active = False

				for menu in Menu.instances:
					if menu.active:
						break
				else:
					Menu.active = False

				return



			# ytoff = 3
			y_run = round(self.pos[1])
			to_call = None

			# if window_size[1] < 250 * gui.scale:
			#	 self.h = round(14 * gui.scale)
			#	 ytoff = -1 * gui.scale
			# else:
			self.h = self.vertical_size
			ytoff = round(self.h * 0.71 - 13 * gui.scale)

			x_run = self.pos[0]

			# In popup-window mode the window is sized to fit the menu and the
			# compositor keeps it on-screen, so the in-window column-wrap / flip
			# behaviour below must be disabled by using effectively-infinite bounds.
			if self.popup_window is not None:
				bounds = (1 << 24, 1 << 24)
			else:
				bounds = self.window_size

			springing = self.can_be_spring_clicked and self.spring_loading_timer.get() > 0.3

			for i in range(len(self.items)):
				#logging.info(self.items[i])

				# Draw menu break
				if self.items[i] is None:
					if is_light(colours.menu_background):
						break_colour = rgb_add_hls(colours.menu_background, 0, -0.1, -0.1)
					else:
						break_colour = rgb_add_hls(colours.menu_background, 0, 0.06, 0)

					rect = (x_run, y_run, self.w, self.break_height - 1)
					if coll_point(self.pointer, rect):
						self.clicked = False

					ddt.rect_a((x_run, y_run), (self.w, self.break_height), colours.menu_background)

					ddt.rect_a((x_run, y_run + 2 * gui.scale), (self.w, 2 * gui.scale), break_colour)

					# Draw tab
					ddt.rect_a((x_run, y_run), (4 * gui.scale, self.break_height), colours.menu_tab)
					y_run += self.break_height

					continue

				if self.test_item_active(self.items[i]) is False:
					continue
				# if self.items[i][1] is False and self.items[i][8] is not None:
				#	 if self.items[i][8](1) == False:
				#		 continue

				# Get properties for menu item
				if self.items[i].render_func is not None:
					if self.items[i].pass_ref_deco:
						fx = self.items[i].render_func(self.reference)
					else:
						fx = self.items[i].render_func()
				else:
					fx = self.deco()

				label = fx.text if fx.text is not None else self.items[i].title

				# Show text as disabled if disable_test() passes
				if self.is_item_disabled(self.items[i]):
					fx.text_colour = colours.menu_text_disabled

				# Draw item background, black by default
				ddt.rect_a((x_run, y_run), (self.w, self.h), fx.bg_colour)
				bg = fx.bg_colour

				# Detect if mouse is over this item
				selected = False
				rect = (x_run, y_run, self.w, self.h - 1)
				self.fields.add(rect)

				if coll_point(self.pointer, (x_run, y_run, self.w, self.h - 1)):
					ddt.rect_a((x_run, y_run), (self.w, self.h), colours.menu_highlight_background)  # [15, 15, 15, 255]
					selected = True
					bg = alpha_blend(colours.menu_highlight_background, bg)

					# Call menu items callback if clicked
					if self.items[i].incrementor:
						pass  # stepper buttons handled after the label; label/row click is inert
					elif self.items[i].is_sub_menu is False:
						if self.clicked or (springing and not self.inp.right_down and not self.inp.mouse_down ):
							to_call = i
							if self.items[i].set_ref is not None:
								self.reference = self.items[i].set_ref
							self.inp.mouse_down = False
							self.close_next_frame = True
							gui.update += 1
						if springing:
							self.sub_active = -1
					elif self.clicked or springing:
						self.clicked = False
						self.sub_active = self.items[i].sub_menu_number
						self.sub_y_position = y_run

				# Draw tab
				ddt.rect_a((x_run, y_run), (4 * gui.scale, self.h), colours.menu_tab)

				# Draw Icon
				x = 12 * gui.scale
				if self.items[i].is_sub_menu is False and self.show_icons:
					icon = self.items[i].icon
					self.render_icon(x_run + x, y_run + 5 * gui.scale, icon, selected, fx)

				if self.show_icons:
					x += 25 * gui.scale

				# Toggle / radio state box
				if self.items[i].check_test is not None:
					x += self.draw_check_box(self.items[i], x_run + x, y_run)

				# Draw arrow icon for sub menu
				if self.items[i].is_sub_menu is True:
					if is_light(bg) or colours.lm:
						colour = rgb_add_hls(bg, 0, -0.6, -0.1)
					else:
						colour = rgb_add_hls(bg, 0, 0.1, 0)

					if self.sub_active == self.items[i].func:
						if is_light(bg) or colours.lm:
							colour = rgb_add_hls(bg, 0, -0.8, -0.1)
						else:
							colour = rgb_add_hls(bg, 0, 0.40, 0)

					# colour = ColourRGBA(50, 50, 50, 255)
					# if selected:
					#	 colour = ColourRGBA(150, 150, 150, 255)
					# if self.sub_active == self.items[i][2]:
					#	 colour = ColourRGBA(150, 150, 150, 255)
					self.sub_arrow.asset.render(x_run + self.w - 13 * gui.scale, y_run + 7 * gui.scale, colour, renderer=self.render_renderer)

				# Render the items label (narrowed to clear the stepper for incrementors)
				if self.items[i].incrementor:
					left_bound = self.draw_incrementor(self.items[i], x_run, y_run, bg, ytoff, self.w)
					label_max_w = max(1, int(left_bound - (x_run + x) - 6 * gui.scale))
					# Swallow any click on this row so the menu stays open: the label
					# is inert and only the stepper buttons act (handled above in
					# draw_incrementor), so a click anywhere else here does nothing.
					if coll_point(self.pointer, (x_run, y_run, self.w, self.h - 1)):
						self.clicked = False
				else:
					label_max_w = self.w - (x + 9 * gui.scale)
				ddt.text((x_run + x, y_run + ytoff), label, fx.text_colour, self.font, max_w=label_max_w, bg=bg)

				# Render the items hint
				if self.items[i].hint is not None and not self.items[i].incrementor:

					if is_light(bg) or colours.lm:
						hint_colour = rgb_add_hls(bg, 0, -0.30, -0.3)
					else:
						hint_colour = rgb_add_hls(bg, 0, 0.15, 0)

					# colo = alpha_blend(ColourRGBA(255, 255, 255, 50), bg)
					ddt.text((x_run + self.w - 5, y_run + ytoff, 1), self.items[i].hint, hint_colour, self.font, bg=bg)

				y_run += self.h

				if y_run > bounds[1] - self.h:
					direc = 1
					if self.pos[0] > bounds[0] // 2:
						direc = -1
					x_run += self.w * direc
					y_run = self.pos[1]

				# Inline mode: draw the active submenu beside its parent item here
				# (popup mode draws it into its own window via draw_popup_menus).
				# Placement only guarantees the main column fits, so a submenu
				# that would overflow flips to the left side / clamps upward.
				if self.popup_window is None and self.sub_active > -1 \
						and self.items[i].is_sub_menu and self.sub_active == self.items[i].sub_menu_number:
					sub_w, sub_h = self.submenu_size()
					sx = x_run + self.w
					if sx + sub_w > bounds[0]:
						sx = x_run - sub_w
					sy = min(self.sub_y_position, bounds[1] - sub_h)
					self.render_submenu(int(sx), int(max(0, sy)))

			# Process Click Actions
			if to_call is not None and not self.is_item_disabled(self.items[to_call]):
				if self.items[to_call].pass_ref:
					self.items[to_call].func(self.reference)
				else:
					self.items[to_call].func()

			if self.clicked or inp.key_esc_press or self.close_next_frame:
				self.close_next_frame = False
				self.active = False
				self.clicked = False

				inp.last_click_location[0] = 0
				inp.last_click_location[1] = 0

				for menu in Menu.instances:
					if menu.active:
						break
				else:
					Menu.active = False

				# Render the menu outline
				# ddt.rect_a(self.pos, (self.w, self.h * len(self.items)), colours.grey(40))
			self.can_be_spring_clicked = self.can_be_spring_clicked and ( self.inp.right_down or self.inp.mouse_down )

	def activate(self, in_reference: object = 0, position: list[int] | None = None, bottom_anchor: bool = False) -> None:
		Menu.active = True

		if position is not None:
			self.pos = [position[0], position[1]]
		else:
			self.pos = [copy.deepcopy(self.inp.mouse_position[0]), copy.deepcopy(self.inp.mouse_position[1])]

		# If activated from within a local view space (e.g. a Custom Layout widget
		# being rendered reframed), the position is in that local space; convert it
		# to real screen coordinates. Identity when no transform is active.
		self.pos = list(self.inp.to_screen(self.pos[0], self.pos[1]))

		self.reference = in_reference
		Menu.switch = self.id
		self.sub_active = -1
		self.popup_window = None

		# Decide placement: a menu opens down-right from the anchor (or upward for
		# a bottom-anchored menu). If the main column would extend past the window
		# at that natural position, it pops out into its own popup window instead
		# of being flipped/shifted to fit inside. Judged by the main column only:
		# an inline submenu that would overflow flips left / clamps up at render
		# time - sizing for the worst-case submenu here sent menus into a popup
		# even when there was plenty of room for the menu itself.
		gui = self.gui
		win_w, win_h = self.window_size[0], self.window_size[1]
		main_w, main_h = self.popup_size()
		anchor = [int(self.pos[0]), int(self.pos[1])]
		self.popup_bottom_anchor = bottom_anchor

		if bottom_anchor:
			# Opens upward: the anchor marks the bottom edge.
			self.use_popup = (anchor[0] + main_w > win_w) or (anchor[1] - main_h < gui.panelY)
		else:
			self.use_popup = (anchor[0] + main_w > win_w) or (anchor[1] + main_h > win_h)

		if self.use_popup:
			# Popup mode: anchor the popup window at the requested point; the
			# compositor keeps it on-screen, so no in-window repositioning needed.
			self.popup_anchor = anchor
			self.pos = [0, 0]
		else:
			# Inline mode: it fits at the natural position, so draw there as-is
			# (no flip/shift). A bottom-anchored menu draws its column above the
			# anchor.
			self.pos = [anchor[0], anchor[1] - main_h if bottom_anchor else anchor[1]]

		self.spring_loading_timer.set()
		self.can_be_spring_clicked = True
		self.active = True

	def popup_size(self) -> tuple[int, int]:
		"""Pixel size the popup window needs to fit this menu's main column.

		Submenus are drawn in their own window (see submenu_size /
		render_submenu), so they are not included here.
		"""
		gui = self.gui
		w = int(self.w)
		h = 0
		for item in self.items:
			if item is None:
				h += self.break_height
			elif self.test_item_active(item):
				h += self.h

		return max(1, w), max(1, h + round(2 * gui.scale))

	def submenu_size(self) -> tuple[int, int]:
		"""Pixel size of the currently active submenu's own popup window."""
		gui = self.gui
		if not (-1 < self.sub_active < len(self.subs)):
			return 1, 1
		sub_w = 0
		for item in self.items:
			if item is not None and item.is_sub_menu and item.sub_menu_number == self.sub_active:
				sub_w = int(item.sub_menu_width * gui.scale)
				break
		shown = sum(
			1 for s in self.subs[self.sub_active]
			if s.show_test is None or s.show_test(self.reference)
		)
		return max(1, sub_w), max(1, shown * self.h + round(2 * gui.scale))

	def render_submenu(self, ox: int = 0, oy: int = 0) -> None:
		"""Draw the active submenu with its top-left at (ox, oy).

		Mirrors the main item rendering for the active submenu's items. For a
		popup submenu (ox, oy) = (0, 0) (its own window's local origin); for an
		inline submenu they are absolute coordinates in the main window beside
		the parent item. Drawing/hit-testing follow self.render_ddt / self.pointer
		so the same code serves both modes.
		"""
		if not (-1 < self.sub_active < len(self.subs)):
			return

		gui     = self.gui
		ddt     = self.render_ddt
		colours = self.colours

		ytoff = round(self.h * 0.71 - 13 * gui.scale)
		# Snapshot which items are visible ONCE up front. A no_exit toggle fires
		# its func mid-render, which flips its show_test pair; re-evaluating per
		# item would then reveal the pair member later in the same pass, shifting
		# every row below down for one frame. Freezing the list avoids that.
		sub_items = [
			s for s in self.subs[self.sub_active]
			if s.show_test is None or s.show_test(self.reference)
		]
		sub_w = self.submenu_size()[0]

		springing = self.can_be_spring_clicked and self.spring_loading_timer.get() > 0.3

		# Left text inset depends on whether any item in this submenu has an icon.
		xoff = 0
		for item in sub_items:
			if item.icon is not None:
				xoff = 24 * gui.scale
				break

		row = 0
		for item in sub_items:
			y = oy + row * self.h

			if item.render_func is not None:
				fx = item.render_func(self.reference) if item.pass_ref_deco else item.render_func()
			else:
				fx = self.deco()

			ddt.rect_a((ox, y), (sub_w, self.h), fx.bg_colour)
			self.fields.add((ox, y, sub_w, self.h - 1))

			bg = colours.menu_background
			this_select = False
			if coll_point(self.pointer, (ox, y, sub_w, self.h - 1)):
				ddt.rect_a((ox, y), (sub_w, self.h), colours.menu_highlight_background)
				bg = alpha_blend(colours.menu_highlight_background, bg)
				this_select = True

				if item.incrementor:
					pass  # stepper buttons handled after the label; label/row click is inert
				elif not self.is_item_disabled(item):
					# no_exit items act only on a discrete click and keep the menu
					# open (the spring-loaded path would re-fire a toggle every frame
					# while the button is held). Normal items also fire on spring.
					if item.no_exit:
						fire = self.clicked
					else:
						fire = self.clicked or (springing and not self.inp.right_down and not self.inp.mouse_down)
					if fire:
						if item.args is not None:
							item.func(self.reference, item.args)
						elif item.pass_ref:
							item.func(self.reference)
						else:
							item.func()
						if item.no_exit:
							self.clicked = False  # keep the menu (and submenu) open
						else:
							self.close_next_frame = True
						gui.update += 1

			label = fx.text if fx.text is not None else item.title
			if self.is_item_disabled(item):
				fx.text_colour = colours.menu_text_disabled

			self.render_icon(ox + 11 * gui.scale, y + 5 * gui.scale, item.icon, this_select, fx)
			text_x = ox + 10 * gui.scale + xoff
			# Toggle / radio state box
			if item.check_test is not None:
				text_x += self.draw_check_box(item, text_x, y)
			if item.incrementor:
				left_bound = self.draw_incrementor(item, ox, y, bg, ytoff, sub_w)
				label_max_w = max(1, int(left_bound - text_x - 6 * gui.scale))
				# Swallow any click on this row so the menu stays open (label inert).
				if coll_point(self.pointer, (ox, y, sub_w, self.h - 1)):
					self.clicked = False
				ddt.text((text_x, y + ytoff), label, fx.text_colour, self.font, max_w=label_max_w, bg=bg)
			else:
				ddt.text((text_x, y + ytoff), label, fx.text_colour, self.font, bg=bg)
			ddt.rect_a((ox, y), (4 * gui.scale, self.h), colours.menu_tab)

			row += 1

class GallClass:
	def __init__(self, tauon: Tauon, size: int = 250, save_out: bool = True) -> None:
		self.tauon: Tauon = tauon
		self.tls_context: ssl.SSLContext = tauon.tls_context
		self.renderer             = tauon.renderer
		self.ddt: TDraw = tauon.ddt
		self.quickthumbnail: QuickThumbnail = tauon.quickthumbnail
		self.folder_image_offsets: dict[str, int] = tauon.folder_image_offsets
		self.g_cache_directory: Path = tauon.g_cache_directory
		self.gui: GuiVar = tauon.gui
		self.prefs: Prefs = tauon.prefs
		self.search_over: SearchOverlay = tauon.search_over
		self.album_art_gen: AlbumArt = tauon.album_art_gen
		self.size: int = size
		self.gall: dict[tuple[TrackClass, int, int], list[int | BytesIO | None]] = {}
		self.queue:    list[tuple[TrackClass, int, int]] = []
		self.key_list: list[tuple[TrackClass, int, int]] = []
		self.save_out: bool = save_out
		self.i: int = 0
		self.lock: threading.LockType = threading.Lock()
		self.limit: int = 60
		self.frame: int = 0
		self.frame_stamp: dict[tuple[TrackClass, int, int], int] = {}

	def new_frame(self) -> None:
		"""Advance the wanted-stamp epoch. Called once at the start of each render pass.

		render() stamps every key it still wants but doesn't have; worker_render
		drops queued keys whose stamp has gone stale, so a resize or fast scroll
		doesn't leave hundreds of no-longer-visible sizes to thumbnail.
		"""
		self.frame += 1
		if not self.frame % 600 and self.frame_stamp:
			# Stamps for keys removed from the queue elsewhere (halt, cache
			# clear, scroll trim) would otherwise linger forever
			self.frame_stamp = {k: v for k, v in self.frame_stamp.items() if self.frame - v < 4}

	def album_art_column_is_shown(self) -> bool:
		return self.gui.set_mode and any(column[0] == "Album Art" for column in self.gui.pl_st)

	def get_file_source(self, track_object: TrackClass) -> tuple[bool, int] | tuple[tuple[int, str], int]:
		sources = self.album_art_gen.get_sources(track_object)

		if len(sources) == 0:
			return False, 0

		offset = self.album_art_gen.get_offset(track_object.fullpath, sources)
		return sources[offset], offset

	def worker_render(self) -> bool:
		self.lock.acquire()
		# time.sleep(0.1)

		if self.search_over.active:
			while self.quickthumbnail.queue:
				img = self.quickthumbnail.queue.pop(0)
				response = urllib.request.urlopen(img.url, context=self.tls_context)
				source_image = io.BytesIO(response.read())
				img.read_and_thumbnail(source_image, img.size, img.size)
				source_image.close()
				self.gui.update += 1

		while len(self.queue) > 0:
			source_image = None

			if self.gui.halt_image_rendering:
				self.queue.clear()
				self.frame_stamp.clear()
				break

			self.i += 1

			try:
				# key = self.queue[0]
				key = self.queue.pop(0)
			except Exception:
				logging.exception("thumb queue empty")
				break

			# Flush entries no recent frame asked for (still-wanted keys are
			# re-stamped by render() every pass); if dropped in error the next
			# frame that wants the key just re-queues it
			if self.frame - self.frame_stamp.pop(key, self.frame) > 2:
				continue

			if key not in self.gall:
				order = [1, None, None, None]
				self.gall[key] = order
			else:
				order = self.gall[key]

			size = key[1]

			slow_load = False
			cache_load = False

			try:
				if True:
					offset = 0
					parent_folder = key[0].parent_folder_path
					if parent_folder in self.folder_image_offsets:
						offset = self.folder_image_offsets[parent_folder]
					img_name = str(key[2]) + "-" + str(size) + "-" + str(key[0].index) + "-" + str(offset)
					if self.prefs.cache_gallery and (self.g_cache_directory / f"{img_name}.jpg").is_file():
						source_image = (self.g_cache_directory / f"{img_name}.jpg").open("rb")
						# logging.info('load from cache')
						cache_load = True
					else:
						slow_load = True

				if slow_load:
					source, c_offset = self.get_file_source(key[0])

					if source is False:
						order[0] = 0
						self.gall[key] = order
						# del self.queue[0]
						continue

					img_name = str(key[2]) + "-" + str(size) + "-" + str(key[0].index) + "-" + str(c_offset)

					# gall_render_last_timer.set()

					if self.prefs.cache_gallery and (self.g_cache_directory / f"{img_name}.jpg").is_file():
						source_image = (self.g_cache_directory / f"{img_name}.jpg").open("rb")
						logging.info("slow load image")
						cache_load = True

					# elif source[0] == 1:
					#	 #logging.info('tag')
					#	 source_image = io.BytesIO(self.album_art_gen.get_embed(key[0]))
					#
					# elif source[0] == 2:
					#	 try:
					#		 url = tauon.get_network_thumbnail_url(key[0])
					#		 response = urllib.request.urlopen(url)
					#		 source_image = response
					#	 except Exception:
					#		 logging.exception("IMAGE NETWORK LOAD ERROR")
					# else:
					#	 source_image = open(source[1], 'rb')
					source_image = self.album_art_gen.get_source_raw(0, 0, key[0], subsource=source)
				if source_image is None:
					logging.debug(f"Image for {key[0].fullpath} not found")
					continue

				g = io.BytesIO()
				g.seek(0)

				if cache_load:
					g.write(source_image.read())

				else:
					error = False
					try:
						# Process image
						im = Image.open(source_image)
						if im.mode != "RGB":
							im = im.convert("RGB")
						im.thumbnail((size, size), Image.Resampling.LANCZOS)
					except Exception:
						logging.exception("Failed to work with thumbnail")
						im = self.album_art_gen.get_error_img(size)
						error = True

					im.save(g, "BMP")

					if not error and self.save_out and self.prefs.cache_gallery \
					and not (self.g_cache_directory / f"{img_name}.jpg").is_file():
						im.save(str(self.g_cache_directory / f"{img_name}.jpg"), "JPEG", quality=95)

				g.seek(0)

				# source_image.close()

				order = [2, g, None, None]
				self.gall[key] = order

				self.gui.update += 1
				if self.album_art_column_is_shown():
					self.gui.pl_update += 1
				if source_image:
					source_image.close()
					source_image = None
				# del self.queue[0]

				time.sleep(0.001)

			except Exception:
				logging.exception(f"Image load failed on track: {key[0].fullpath}")
				order = [0, None, None, None]
				self.gall[key] = order
				self.gui.update += 1
				# del self.queue[0]

			if size < 150:
				random.shuffle(self.queue)

		if self.i > 0:
			self.i = 0
			return True
		return False

	def render(
			self,
			track: TrackClass,
			location,
			size: int | None = None,
			force_offset: int | None = None,
			max_height: int | None = None) -> bool | None:
		if self.tauon.gallery_load_delay.get() < 0.5:
			return None

		x = round(location[0])
		y = round(location[1])

		# time.sleep(0.1)
		if size is None:
			size = self.size

		size = round(size)

		# offset = self.get_offset(pctl.master_library[index].fullpath, self.get_sources(index))
		if track.parent_folder_path in self.folder_image_offsets:
			offset = self.folder_image_offsets[track.parent_folder_path]
		else:
			offset = 0

		if force_offset is not None:
			offset = force_offset

		key = (track, size, offset)

		if key in self.gall:
			#logging.info("old")

			order = self.gall[key]

			if order[0] == 0:
				# broken
				return False

			if order[0] == 1:
				# not done yet
				return False

			if order[0] == 2:
				# finish processing

				s_image = self.ddt.load_image(order[1])
				c = sdl3.SDL_CreateTextureFromSurface(self.renderer, s_image)
				sdl3.SDL_DestroySurface(s_image)
				tex_w = pointer(c_float(0))
				tex_h = pointer(c_float(0))
				sdl3.SDL_GetTextureSize(c, tex_w, tex_h)
				dst = sdl3.SDL_FRect(x, y)
				dst.w = int(tex_w.contents.value)
				dst.h = int(tex_h.contents.value)


				order[0] = 3
				order[1].close()
				order[1] = None
				order[2] = c
				order[3] = dst
				self.gall[(track, size, offset)] = order

			if order[0] == 3:
				# ready

				order[3].x = x
				order[3].y = y
				order[3].x = int((size - order[3].w) / 2) + order[3].x
				order[3].y = int((size - order[3].h) / 2) + order[3].y

				if max_height is None:
					sdl3.SDL_RenderTexture(self.renderer, order[2], None, order[3])
				else:
					max_height = round(max_height)
					clip_top = y
					clip_bottom = y + max_height
					dst_top = order[3].y
					dst_bottom = order[3].y + order[3].h
					render_top = max(dst_top, clip_top)
					render_bottom = min(dst_bottom, clip_bottom)
					if render_bottom > render_top and order[3].h > 0:
						source_y = ((render_top - dst_top) / order[3].h) * order[3].h
						source_h = ((render_bottom - render_top) / order[3].h) * order[3].h
						source_rect = sdl3.SDL_FRect(0, source_y, order[3].w, source_h)
						dest_rect = sdl3.SDL_FRect(order[3].x, render_top, order[3].w, render_bottom - render_top)
						sdl3.SDL_RenderTexture(self.renderer, order[2], source_rect, dest_rect)

				if (track, size, offset) in self.key_list:
					self.key_list.remove((track, size, offset))
				self.key_list.append((track, size, offset))

				# Remove old images to conserve RAM usage
				if len(self.key_list) > self.limit:
					self.gui.update += 1
					key = self.key_list[0]
					# while key in self.queue:
					#	 self.queue.remove(key)
					if self.gall[key][2] is not None:
						sdl3.SDL_DestroyTexture(self.gall[key][2])
					del self.gall[key]
					del self.key_list[0]

				return True
		else:
			self.frame_stamp[key] = self.frame
			if key not in self.queue:
				self.queue.append(key)
				if self.lock.locked():
					try:
						self.lock.release()
					except RuntimeError as e:
						if str(e) == "release unlocked lock":
							logging.error("RuntimeError: Attempted to release already unlocked lock")  # noqa: TRY400
						else:
							logging.exception("Unknown RuntimeError trying to release lock")
					except Exception:
						logging.exception("Unknown error trying to release lock")

		return False

class ThumbTracks:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon         = tauon
		self.album_art_gen = tauon.album_art_gen

	def pixbuf(self, track: TrackClass) -> GdkPixbuf | None:
		try:
			source, _offset = self.tauon.gall_ren.get_file_source(track)
			if source is False:  # No art
				return None
			source_image = self.album_art_gen.get_source_raw(0, 0, track, subsource=source)
			with Image.open(source_image) as im:
				if im.mode != "RGB":
					im = im.convert("RGB")
				im.thumbnail((512, 512), Image.Resampling.LANCZOS)
				width, height = im.size
				data = im.tobytes()
			source_image.close()
			return GdkPixbuf.Pixbuf.new_from_data(data, GdkPixbuf.Colorspace.RGB, False, 8, width, height, width * 3)
		except Exception:
			logging.exception("Error create pixbuf of album art")
			return None

	def path(self, track: TrackClass) -> str | None:
		source, offset = self.tauon.gall_ren.get_file_source(track)

		if source is False:  # No art
			return None

		source_type, source_location = source
		source_mtime = ""
		if source_type in (0, 1):
			try:
				source_mtime = str(os.path.getmtime(source_location))
			except OSError:
				source_mtime = str(track.modified_time)

		image_name = f"{source_type}:{source_location}:{offset}:{source_mtime}"
		image_name = hashlib.md5(image_name.encode("utf-8", "replace")).hexdigest()  # noqa: S324 - not a security hash

		t_path = self.tauon.e_cache_directory / f"{image_name}.jpg"

		if t_path.is_file():
			return str(t_path)

		source_image = self.album_art_gen.get_source_raw(0, 0, track, subsource=source)
		with Image.open(source_image) as im:
			if im.mode != "RGB":
				im = im.convert("RGB")
			im.thumbnail((1000, 1000), Image.Resampling.LANCZOS)
			im.save(str(t_path), "JPEG")
		source_image.close()

		return str(t_path)

class Tauon:
	"""Root class for everything Tauon"""

	def __init__(self, holder: Holder, bag: Bag, gui: GuiVar) -> None:
		self.use_cc                       = is_module_loaded("opencc")
		self.use_natsort                  = is_module_loaded("natsort")
		self._opencc_local                = threading.local()

		self.bag: Bag                          = bag
		self.log: LogHistoryHandler            = bag.log
		self.mpt: CDLL | None                  = bag.mpt
		self.gme: CDLL | None                  = bag.gme
		self.dev_mode: bool                    = bag.dev_mode
		self.renderer               = bag.renderer
		self.ddt: TDraw                        = TDraw(bag.renderer)
		self.fonts: Fonts                      = bag.fonts
		self.formats: Formats                  = bag.formats
		self.macos: bool                       = bag.macos
		self.mac_close: ColourRGBA             = bag.mac_close
		self.mac_maximize: ColourRGBA          = bag.mac_maximize
		self.mac_minimize: ColourRGBA          = bag.mac_minimize
		self.platform_system: str              = bag.platform_system
		self.primary_stations: list[RadioStation] = bag.primary_stations
		self.wayland: bool                      = bag.wayland
		self.windows: bool                         = bag.windows
		self.dirs: Directories                  = bag.dirs
		self.colours: ColoursClass              = bag.colours
		self.download_directories: list[str]    = bag.download_directories
		self.launch_prefix: str                 = bag.launch_prefix
		self.overlay_texture_texture            = bag.overlay_texture_texture
		self.de_notify_support: bool            = bag.de_notify_support
		self.old_window_position: tuple[int, int]          = bag.old_window_position
		self.mini_mode_restore_size: tuple[int, int] | None = None
		self.mini_mode_wm_forced_floating: bool = False
		self.mini_mode_wm_was_floating: bool = False
		self._wayland_wm_ipc_warned: bool = False
		self.cache_directory: Path              = bag.dirs.cache_directory
		self.config_directory: Path             = bag.dirs.config_directory
		self.user_directory: Path               = bag.dirs.user_directory
		self.install_directory: Path            = bag.dirs.install_directory
		self.music_directory: Path              = bag.dirs.music_directory
		self.locale_directory: Path             = bag.dirs.locale_directory
		self.n_cache_directory: Path            = bag.dirs.n_cache_directory
		self.e_cache_directory: Path            = bag.dirs.e_cache_directory
		self.g_cache_directory: Path            = bag.dirs.g_cache_directory
		self.a_cache_directory: Path            = bag.dirs.a_cache_directory
		self.r_cache_directory: Path            = bag.dirs.r_cache_directory
		self.b_cache_directory: Path            = bag.dirs.b_cache_directory
		self.draw_max_button: bool              = bag.draw_max_button
		self.draw_min_button: bool              = bag.draw_min_button
		self.song_notification: None            = bag.song_notification
		self.tls_context: ssl.SSLContext        = bag.tls_context
		self.folder_image_offsets: dict[str, int] = bag.folder_image_offsets
		self.inp: Input                          = gui.inp
		self.instance_lock: io.TextIOWrapper[io._WrappedBuffer] | None = holder.instance_lock
		self.n_version: str                    = holder.n_version
		self.t_window                     = holder.t_window
		self.t_title: str                         = holder.t_title
		self.t_version: str                       = holder.t_version
		self.t_agent: str                         = holder.t_agent
		self.t_id: str                            = holder.t_id
		self.fs_mode: bool                        = holder.fs_mode
		self.window_default_size: tuple[int, int] = holder.window_default_size
		self.window_title: bytes                  = holder.window_title
		self.logical_size: list[int]              = bag.logical_size
		self.window_size: list[int]               = bag.window_size
		self.draw_border: bool                    = holder.draw_border
		self.corner_mask_textures: list | None    = None
		self.corner_border_textures: list | None  = None
		self.corner_texture_size: tuple[int, int] = (0, 0)
		self.corner_textures_failed: bool         = False
		self.desktop: str | None                  = bag.desktop
		self.pid: int                             = os.getpid()
		# List of encodings to check for with the fix mojibake function
		self.encodings: list[str] = ["cp932", "utf-8", "big5hkscs", "gbk"]  # These seem to be the most common for Japanese
		self.column_names = (
			"Artist",
			"Album Artist",
			"Album",
			"Album Art",
			"Title",
			"Composer",
			"Time",
			"Date",
			"Genre",
			"#",
			"P",
			"Starline",
			"Rating",
			"Comment",
			"Codec",
			"Lyrics",
			"Bitrate",
			"S",
			"Filename",
			"Disc",
			"CUE",
			"ID",
			"=/=",
		)
		self.device: str                       = socket.gethostname()
		self.search_string_cache:     dict[int, str] = {}
		self.search_dia_string_cache: dict[int, str] = {}
		self.search_field_cache:      dict[int, tuple[str, str, str, str, str, str, str, str, str, str, str, str, str]] = {}
		self.search_dia_field_cache:  dict[int, tuple[str, str, str, str, str, str, str, str]] = {}
		self.albums:            list[int] = []
		self.added:             list[int] = []
		self.album_dex:         list[int] = []
		self.album_dex_pl_uuid:       int = -1  # uuid_int of the playlist album_dex was built for
		self.to_scan:           list[int] = []
		self.after_scan: list[TrackClass] = []
		self.quick_import_done: list[str] = []
		self.move_jobs: list[tuple[str, str, bool, str, LoadClass]] = []
		self.move_in_progress:       bool = False
		self.worker2_lock: threading.LockType                 = threading.Lock()
		self.dummy_event:          sdl3.SDL_Event = sdl3.SDL_Event()
		self.temp_dest:            sdl3.SDL_FRect = sdl3.SDL_FRect(0, 0)
		self.text_box_canvas_rect:      sdl3.SDL_FRect = sdl3.SDL_FRect(0, 0, round(2000 * gui.scale), round(40 * gui.scale))
		self.text_box_canvas_hide_rect: sdl3.SDL_FRect = sdl3.SDL_FRect(0, 0, round(2000 * gui.scale), round(40 * gui.scale))
		self.text_box_canvas           = sdl3.SDL_CreateTexture(
			self.renderer, sdl3.SDL_PIXELFORMAT_ARGB8888,
			sdl3.SDL_TEXTUREACCESS_TARGET, round(self.text_box_canvas_rect.w), round(self.text_box_canvas_rect.h))
		self.translate                            = _
		self.strings: Strings                              = Strings()
		self.gui: GuiVar                                  = gui
		self.prefs: Prefs                                = bag.prefs
		self.snap_mode: bool                            = bag.snap_mode
		self.flatpak_mode: bool                         = bag.flatpak_mode
		self.core_use: int                        = 0
		self.dl_use: int                          = 0
		self.latest_db_version: int                    = bag.latest_db_version
		self.g_tc_notify: Notify.Notification | None                          = None
		# Setting various timers
		self.track_box_path_tool_timer: Timer    = Timer()
		self.message_box_min_timer: Timer        = Timer()
		self.cursor_blink_timer: Timer           = Timer()
		self.animate_monitor_timer: Timer        = Timer()
		self.min_render_timer: Timer             = Timer()
		self.vis_rate_timer: Timer               = Timer()
		self.vis_decay_timer: Timer              = Timer()
		self.scroll_timer: Timer                 = Timer()
		self.scroll_timer.set()
		self.scroll_animation_deadline: float    = 0.0
		self.perf_timer: Timer                   = Timer() # Reassigned later
		self.quick_d_timer: Timer                = Timer()
		self.core_timer: Timer                   = Timer() # Reassigned later
		self.sleep_timer: Timer                  = Timer()
		self.gallery_select_animate_timer: Timer = Timer()
		self.gallery_select_animate_timer.force_set(10)
		self.search_clear_timer: Timer           = Timer()
		self.gall_pl_switch_timer: Timer         = Timer()
		self.gall_pl_switch_timer.force_set(999)
		self.d_click_timer: Timer                = Timer()
		self.d_click_timer.force_set(10)
		self.lyrics_check_timer: Timer           = Timer()
		self.scroll_hide_timer: Timer            = Timer(100)
		self.scroll_gallery_hide_timer: Timer    = Timer(100)
		self.get_lfm_wait_timer: Timer           = Timer(10)
		self.lyrics_fetch_timer: Timer           = Timer(10)
		self.gallery_load_delay: Timer           = Timer(10)
		self.queue_add_timer: Timer              = Timer(100)
		self.toast_love_timer: Timer             = Timer(100)
		self.toast_mode_timer: Timer             = Timer(100)
		self.scrobble_warning_timer: Timer       = Timer(1000)
		self.sync_file_timer: Timer              = Timer(1000)
		self.sync_file_update_timer: Timer       = Timer(1000)
		self.restore_ignore_timer: Timer         = Timer()
		self.restore_ignore_timer.force_set(100)
		self.fields: Fields = Fields(self)
		# Create top menu
		self.view_menu: Menu             = Menu(self, 170)
		self.set_menu_hidden: Menu       = Menu(self, 100)
		self.vis_menu: Menu              = Menu(self, 140)
		self.window_menu: Menu           = Menu(self, 140)
		self.x_menu: Menu                = Menu(self, 190, show_icons=True)
		self.set_menu: Menu              = Menu(self, 150)
		self.field_menu: Menu            = Menu(self, 140)
		self.dl_menu: Menu               = Menu(self, 90)

		self.cancel_menu: Menu           = Menu(self, 100)
		self.extra_menu: Menu            = Menu(self, 175, show_icons=True)
		self.stop_menu: Menu             = Menu(self, 175, show_icons=False)
		self.shuffle_menu: Menu          = Menu(self, 120)
		self.repeat_menu: Menu           = Menu(self, 120)
		self.tab_menu: Menu              = Menu(self, 160, show_icons=True)
		self.playlist_menu: Menu         = Menu(self, 130)
		self.showcase_menu: Menu         = Menu(self, 135)
		# Showcase view's own background menu: the lyrics menu plus
		# showcase-specific layout settings (wide mode, visualiser)
		self.showcase_view_menu: Menu    = Menu(self, 160)
		self.queue_menu: Menu            = Menu(self, 150)
		self.radio_entry_menu: Menu      = Menu(self, 125)
		self.center_info_menu: Menu      = Menu(self, 125)
		self.gallery_menu: Menu          = Menu(self, 175, show_icons=True)
		self.artist_info_menu: Menu      = Menu(self, 135)
		self.artist_list_menu: Menu      = Menu(self, 165, show_icons=True)
		self.lightning_menu: Menu        = Menu(self, 165)
		self.lsp_menu: Menu              = Menu(self, 145)
		self.folder_tree_menu: Menu      = Menu(self, 175, show_icons=True)
		self.folder_tree_stem_menu: Menu = Menu(self, 190, show_icons=True)
		self.overflow_menu: Menu         = Menu(self, 175)
		self.radio_context_menu: Menu    = Menu(self, 175)
		self.radio_tab_menu: Menu        = Menu(self, 160, show_icons=True)
		self.mode_menu: Menu             = Menu(self, 175)
		self.track_menu: Menu            = Menu(self, 195, show_icons=True)
		self.picture_menu: Menu          = Menu(self, 175)
		self.milky_menu: Menu            = Menu(self, 175)
		self.selection_menu: Menu        = Menu(self, 200, show_icons=False)
		self.folder_menu: Menu           = Menu(self, 193, show_icons=True)
		self.extra_tab_menu: Menu        = Menu(self, 155, show_icons=True)

		# Lazily created popup windows for context menus (see draw_popup_menus /
		# t_window): the main column and the active submenu each get their own.
		# active_pointer_window is whichever of them the pointer is currently in.
		self.menu_popup = None
		self.submenu_popup = None
		self.active_pointer_window = None

		self.smooth_scroll:                     SmoothScroll = SmoothScroll(tauon=self)
		self.lb:                                ListenBrainz = ListenBrainz(tauon=self)
		self.thread_manager:                   ThreadManager = ThreadManager(tauon=self)
		self.album_mode_art_size:                        int = bag.album_mode_art_size
		self.artist_picture_render:            PictureRender = PictureRender(tauon=self)
		self.artist_preview_render:            PictureRender = PictureRender(tauon=self)
		self.input_sdl:                          GetSDLInput = GetSDLInput(tauon=self)
		self.pctl:                                 PlayerCtl = PlayerCtl(tauon=self)
		self.mini_lyrics_scroll:                   ScrollBox = self.pctl.mini_lyrics_scroll
		self.playlist_panel_scroll:                ScrollBox = self.pctl.playlist_panel_scroll
		self.artist_info_scroll:                   ScrollBox = self.pctl.artist_info_scroll
		self.device_scroll:                        ScrollBox = self.pctl.device_scroll
		self.artist_list_scroll:                   ScrollBox = self.pctl.artist_list_scroll
		self.gallery_scroll:                       ScrollBox = self.pctl.gallery_scroll
		self.tree_view_scroll:                     ScrollBox = self.pctl.tree_view_scroll
		self.radio_view_scroll:                    ScrollBox = self.pctl.radio_view_scroll
		self.artist_info_box:                  ArtistInfoBox = self.pctl.artist_info_box
		self.draw:                                   Drawing = self.pctl.draw
		self.radiobox:                              RadioBox = self.pctl.radiobox
		self.dummy_track:                         TrackClass = self.radiobox.dummy_track
		self.queue_box:                             QueueBox = self.pctl.queue_box
		self.tree_view_box:                         TreeView = self.pctl.tree_view_box
		self.star_store:                           StarStore = self.pctl.star_store
		self.lastfm:                               LastFMapi = self.pctl.lastfm
		self.lfm_scrobbler:                        LastScrob = self.pctl.lfm_scrobbler
		self.artist_list_box:                     ArtistList = self.pctl.artist_list_box
		self.guitar_chords:                     GuitarChords = GuitarChords(tauon=self, mouse_wheel=self.inp.mouse_wheel, mouse_position=self.inp.mouse_position, window_size=self.window_size)
		self.search_over:                      SearchOverlay = SearchOverlay(tauon=self)
		self.stats_gen:                               GStats = GStats(tauon=self)
		self.deco:                                      Deco = Deco(tauon=self)
		self.bottom_bar1:                     BottomBarType1 = BottomBarType1(tauon=self)
		self.bottom_bar_ao1:               BottomBarType_ao1 = BottomBarType_ao1(tauon=self)
		self.top_panel:                             TopPanel = TopPanel(tauon=self)
		self.playlist_box:                       PlaylistBox = PlaylistBox(tauon=self)
		self.radio_view:                           RadioView = RadioView(tauon=self)
		self.view_box:                               ViewBox = ViewBox(tauon=self)
		self.custom:                            CustomLayout = CustomLayout(tauon=self)
		self.dream_room:                           DreamRoom = DreamRoom(tauon=self)
		self.pref_box:                                  Over = Over(tauon=self)
		self.fader:                                    Fader = Fader(tauon=self)
		self.style_overlay:                     StyleOverlay = StyleOverlay(tauon=self)
		self.album_art_gen:                         AlbumArt = self.style_overlay.album_art_gen
		self.tool_tip:                               ToolTip = ToolTip(tauon=self)
		self.tool_tip2:                              ToolTip = ToolTip(tauon=self)
		self.columns_tool_tip:                      ToolTip3 = ToolTip3(tauon=self)
		self.f_store:                          FunctionStore = FunctionStore()
		self.tool_tip2.trigger:                        float = 1.8
		self.undo:                                      Undo = Undo(tauon=self)
		self.rename_files:                          TextBox2 = TextBox2(tauon=self)
		self.rename_track_box:                RenameTrackBox = RenameTrackBox(tauon=self)
		self.edit_artist:                           TextBox2 = TextBox2(tauon=self)
		self.edit_album:                            TextBox2 = TextBox2(tauon=self)
		self.edit_title:                            TextBox2 = TextBox2(tauon=self)
		self.edit_album_artist:                     TextBox2 = TextBox2(tauon=self)
		self.trans_edit_box:                    TransEditBox = TransEditBox(tauon=self)
		self.sub_lyrics_a:                          TextBox2 = TextBox2(tauon=self)
		self.sub_lyrics_b:                          TextBox2 = TextBox2(tauon=self)
		self.sub_lyrics_box:                      SubLyricsBox = SubLyricsBox(tauon=self)
		self.export_playlist_box:                 ExportPlaylistBox = ExportPlaylistBox(tauon=self)
		self.rename_text_area:                    TextBox = TextBox(tauon=self)
		self.rename_playlist_box:                 RenamePlaylistBox = RenamePlaylistBox(tauon=self)
		self.message_box:                         MessageBox = MessageBox(tauon=self)
		self.preset_download_box:                 PresetDownloadBox = PresetDownloadBox(tauon=self)
		self.milk_choose:                       MilkPresetChooser = MilkPresetChooser(tauon=self)
		self.search_text                          = self.search_over.search_text
		self.sync_target:                         TextBox2 = TextBox2(tauon=self)
		self.playlist_folder_box:                 TextBox2 = TextBox2(tauon=self)
		self.edge_playlist2:                      EdgePulse2 = EdgePulse2(tauon=self)
		self.lyric_side_top_pulse:                EdgePulse2 = EdgePulse2(tauon=self)
		self.lyric_side_bottom_pulse:             EdgePulse2 = EdgePulse2(tauon=self)
		self.tab_pulse:                           EdgePulse = EdgePulse(tauon=self)
		self.radio_thumb_gen:                     RadioThumbGen = RadioThumbGen(tauon=self)
		self.dl_mon:                              DLMon = DLMon(tauon=self)
		self.drop_shadow:                         DropShadow = DropShadow(tauon=self)
		self.lyrics_ren_mini:                     LyricsRenMini = LyricsRenMini(tauon=self)
		self.lyrics_ren:                          LyricsRen = LyricsRen(tauon=self)
		self.timed_lyrics_ren:                    TimedLyricsRen = TimedLyricsRen(tauon=self)
		self.synced_to_static_lyrics:             TimedLyricsToStatic = TimedLyricsToStatic()
		self.mini_mode:                           MiniMode = MiniMode(tauon=self)
		self.mini_mode2:                          MiniMode2 = MiniMode2(tauon=self)
		self.mini_mode3:                          MiniMode3 = MiniMode3(tauon=self)
		self.mini_mode_signal:                    MiniModeSignal = MiniModeSignal(tauon=self)
		self.vb:                                  VorbisMonitor = VorbisMonitor(tauon=self)
		self.bottom_playlist2:                    EdgePulse2 = EdgePulse2(tauon=self)
		self.gallery_pulse_top:                   EdgePulse2 = EdgePulse2(tauon=self)
		self.art_box:                             ArtBox = ArtBox(tauon=self)
		self.nagbox:                              NagBox = NagBox(tauon=self)

		if not self.macos and not self.windows:
			self.gnome = Gnome(tauon=self)

		self.text_plex_usr: TextBox2 = TextBox2(tauon=self)
		self.text_plex_pas: TextBox2 = TextBox2(tauon=self)
		self.text_plex_ser: TextBox2 = TextBox2(tauon=self)
		self.text_plex_lib: TextBox2 = TextBox2(tauon=self)
		self.text_plex_2fa: TextBox2 = TextBox2(tauon=self)

		self.text_jelly_usr: TextBox2 = TextBox2(tauon=self)
		self.text_jelly_pas: TextBox2 = TextBox2(tauon=self)
		self.text_jelly_ser: TextBox2 = TextBox2(tauon=self)

		self.text_air_usr: TextBox2 = TextBox2(tauon=self)
		self.text_air_pas: TextBox2 = TextBox2(tauon=self)
		self.text_air_ser: TextBox2 = TextBox2(tauon=self)

		self.text_maloja_url: TextBox2 = TextBox2(tauon=self)
		self.text_maloja_key: TextBox2 = TextBox2(tauon=self)

		self.text_sat_url:      TextBox2 = TextBox2(tauon=self)
		self.text_sat_playlist: TextBox2 = TextBox2(tauon=self)

		self.rename_folder: TextBox2 = TextBox2(tauon=self)
		self.transcode_list:      list[list[int]] = []
		self.transcode_state:                 str = ""
		self.loaderCommand:                   int = LoaderCommand.NONE
		self.loaderCommandReady:             bool = False
		self.cm_clean_db:                    bool = False
		self.worker_save_state:              bool = False
		self.whicher                              = whicher
		self.load_orders:         list[LoadClass] = []
		self.switch_playlist                      = self.pctl.switch_playlist
		self.album_info_cache: dict[int, tuple[bool, list[int], bool]] = {}
		self.album_info_cache_key: tuple[int, TrackClass | None, int] = (-1, None, -1)
		self.console:                    DConsole = bag.console
		self.TrackClass                           = TrackClass
		self.quickthumbnail:       QuickThumbnail = QuickThumbnail(tauon=self)
		self.gall_ren:                  GallClass = GallClass(tauon=self, size=self.album_mode_art_size)
		self.thumb_tracks:            ThumbTracks = ThumbTracks(tauon=self)
		self.chunker:                     Chunker = Chunker()
		self.stream_proxy:              StreamEnc = StreamEnc(self)
		self.level_train:       list[list[float]] = []
		self.radio_server: ThreadedHTTPServer | None = None
		self.listen_alongers:    dict[str, Timer] = {}
		self.encode_folder_name                   = encode_folder_name
		self.encode_track_name                    = encode_track_name
		self.lrclib_uploads:           list[dict] = []
		self.todo:               list[TrackClass] = []
		self.heart_colours:        ColourGenCache = ColourGenCache(0.7, 0.7)
		#self.power_tag_colours:    ColourGenCache = ColourGenCache(0.5, 0.8)

		self.tray_lock = threading.Lock()
		self.tray_releases = 0

		self.play_lock = None
		self.update_play_lock: Callable[[], None] | None = None
		self.sleep_lock = None
		self.shutdown_lock = None
		self.quick_close: bool = False
		self.pl_to_id = self.pctl.pl_to_id
		self.id_to_pl = self.pctl.id_to_pl

		self.copied_track: int | None = None
		self.aud:                        CDLL = ctypes.cdll.LoadLibrary(str(get_phazor_path(self.pctl)))
		self.stream_feeder = None  # set by player4; exposes network download stats
		logging.debug(f"Loaded Phazor path at: {get_phazor_path(self.pctl)}")
		self.player4_state:       PlayerState = PlayerState.STOPPED
		self.cachement:              Cachement = Cachement(self)
		self.milky:                      Milky = Milky(self)

		#self.recorded_songs = []

		self.chrome_mode: bool = False
		self.web_running: bool = False
		self.web_thread = None
		self.remote_limited: bool = True

		self.MenuItem = MenuItem

		self.chrome: Chrome | None = None
		self.chrome_menu: Menu | None = None

		self.tidal: Tidal       = Tidal(self)
		self.plex: PlexService  = PlexService(self)
		self.jellyfin: Jellyfin = Jellyfin(self)
		self.tau: TauService    = TauService(self)
		self.album_star_store: AlbumStarStore = AlbumStarStore(self)
		self.subsonic: SubsonicService = self.album_star_store.subsonic

		self.playlist_autoscan = False
		self.dropped_playlist = -1

		self.now_searching: Literal["off", "searching", "errored", "success"] = "off"

		self.requested_raise: bool = False
		self.requested_tray: bool = False
		self.requested_tray_destruct: bool = False
		self.sdl_tray: sdl3.LP_SDL_Tray | None = None
		self.sdl_tray_state: PlayingState = PlayingState.STOPPED
		self.sdl_tray_launched: bool = False
		self.sdl_tray_text: str = ""
		self._tray_quit_cb = None
		self._tray_open_cb = None
		self._tray_playpause_cb = None
		self._tray_next_cb = None
		self._tray_prev_cb = None

	def tray_quit_callback(self, userdata, entry) -> None:
		"""Called when the 'Quit' tray entry is clicked."""
		logging.info("Exit via tray")
		self.exit("Exit received from app indicator")

	def tray_open_callback(self, userdata, entry) -> None:
		"""Open Tauon main UI."""
		# Adjust to whatever your real function is
		self.request_raise()


	def tray_playpause_callback(self, userdata, entry) -> None:
		"""Toggle play/pause."""
		self.pctl.play_pause()


	def tray_next_callback(self, userdata, entry) -> None:
		"""Next track."""
		self.pctl.advance()


	def tray_prev_callback(self, userdata, entry) -> None:
		"""Previous track."""
		self.pctl.back()

	def load_sdl_icon(self, path: Path) -> sdl3.LP_SDL_Surface:
		"""Load an image file into an SDL_Surface suitable for a tray icon."""
		surf = sdl3.IMG_Load(str(path).encode("utf-8"))
		if not surf:
			raise RuntimeError(f"IMG_Load failed: {sdl3.SDL_GetError().decode()}")
		return surf


	def update_tray_icon(self, name: str) -> None:
		"""Name like \"tray-indicator-stop\""""
		surf = self.load_sdl_icon(Path(self.get_tray_icon(name)))
		sdl3.SDL_SetTrayIcon(self.sdl_tray, surf)


	def sdl_set_tray_tooltip (self, tooltip: str) -> None:
		#logging.debug(f"Setting tray tooltip to '{tooltip}'")
		sdl3.SDL_SetTrayTooltip(self.sdl_tray, tooltip.encode("utf-8"))

	# TODO(Martin): Get this working with native SDL scroll implementation when available
	#def sdl_tray_scroll(self, indicator: AppIndicator3.Indicator, steps: int, direction: int) -> None:
	#	if direction == Gdk.ScrollDirection.UP:
	#		self.tauon.pctl.player_volume += 4
	#		self.tauon.pctl.player_volume = min(self.tauon.pctl.player_volume, 100)
	#		self.tauon.pctl.set_volume()
	#	if direction == Gdk.ScrollDirection.DOWN:
	#		if self.tauon.pctl.player_volume > 4:
	#			self.tauon.pctl.player_volume -= 4
	#		else:
	#			self.tauon.pctl.player_volume = 0
	#		self.tauon.pctl.set_volume()
	#	self.tauon.gui.update += 1


	def show_tray(self) -> None:
		self.requested_tray = True

		# TODO(Martin): https://github.com/libsdl-org/SDL/pull/14582#issuecomment-3627318392
		#if not self.sdl_tray_launched:
		#	try:
		#		self.init_sdl_tray()
		#	except Exception:
		#		logging.exception("Failed to start tray")
		#		self.show_message(_("Failed to start tray"), mode="error")
		#else:
		#	self.indicator.set_status(1)


	def hide_tray(self) -> None:
		self.requested_tray_destruct = True
		# TODO(Martin): https://github.com/libsdl-org/SDL/pull/14582#issuecomment-3627318392
		#if self.sdl_tray_launched:
		#	self.indicator.set_status(0)


	def tray_icon_play(self) -> None:
		if self.sdl_tray_launched:
			self.update_tray_icon("tray-indicator-play")
			# TODO(Martin): https://github.com/libsdl-org/SDL/pull/14582#issuecomment-3627318392
			#self.indicator.set_icon_full(self.tauon.get_tray_icon("tray-indicator-play"), "playing")


	def tray_icon_pause(self) -> None:
		if self.sdl_tray_launched:
			self.update_tray_icon("tray-indicator-pause")
			# TODO(Martin): https://github.com/libsdl-org/SDL/pull/14582#issuecomment-3627318392
			#self.indicator.set_icon_full(self.tauon.get_tray_icon("tray-indicator-pause"), "paused")


	def tray_icon_stop(self) -> None:
		if self.sdl_tray_launched:
			self.update_tray_icon("tray-indicator-default")
			# TODO(Martin): https://github.com/libsdl-org/SDL/pull/14582#issuecomment-3627318392
			#self.indicator.set_icon_full(self.tauon.get_tray_icon("tray-indicator-default"), "default")


	def init_sdl_tray(self) -> None:
		icon_surface = self.load_sdl_icon(Path(self.get_tray_icon("tray-indicator-default")))
		# Create tray (no icon, just tooltip)
		self.sdl_tray = sdl3.SDL_CreateTray(icon_surface, b"Tauon Music Box")
		if not self.sdl_tray:
			raise RuntimeError(f"SDL_CreateTray failed: {sdl3.SDL_GetError().decode()}")
		# Create menu
		menu = sdl3.SDL_CreateTrayMenu(self.sdl_tray)
		if not menu:
			raise RuntimeError(f"SDL_CreateTrayMenu failed: {sdl3.SDL_GetError().decode()}")

		# --- Open Tauon Music Box ---
		open_entry = sdl3.SDL_InsertTrayEntryAt(
			menu,
			-1,
			b"Open Tauon Music Box",
			sdl3.SDL_TRAYENTRY_BUTTON,
		)
		if not open_entry:
			raise RuntimeError(f"SDL_InsertTrayEntryAt(Open) failed: {sdl3.SDL_GetError().decode()}")

		# --- Separator ---
		sep1 = sdl3.SDL_InsertTrayEntryAt(
			menu,
			-1,
			None,  # NULL label => separator
			0,     # flags ignored for separators
		)
		if not sep1:
			raise RuntimeError(f"SDL_InsertTrayEntryAt(sep1) failed: {sdl3.SDL_GetError().decode()}")

		# --- Play/Pause ---
		playpause_entry = sdl3.SDL_InsertTrayEntryAt(
			menu,
			-1,
			b"Play/Pause",
			sdl3.SDL_TRAYENTRY_BUTTON,
		)
		if not playpause_entry:
			raise RuntimeError(f"SDL_InsertTrayEntryAt(Play/Pause) failed: {sdl3.SDL_GetError().decode()}")

		# --- Next Track ---
		next_entry = sdl3.SDL_InsertTrayEntryAt(
			menu,
			-1,
			b"Next Track",
			sdl3.SDL_TRAYENTRY_BUTTON,
		)
		if not next_entry:
			raise RuntimeError(f"SDL_InsertTrayEntryAt(Next) failed: {sdl3.SDL_GetError().decode()}")

		# --- Previous Track ---
		prev_entry = sdl3.SDL_InsertTrayEntryAt(
			menu,
			-1,
			b"Previous Track",
			sdl3.SDL_TRAYENTRY_BUTTON,
		)
		if not prev_entry:
			raise RuntimeError(f"SDL_InsertTrayEntryAt(Previous) failed: {sdl3.SDL_GetError().decode()}")

		# --- Separator ---
		sep2 = sdl3.SDL_InsertTrayEntryAt(
			menu,
			-1,
			None,  # separator
			0,
		)
		if not sep2:
			raise RuntimeError(f"SDL_InsertTrayEntryAt(sep2) failed: {sdl3.SDL_GetError().decode()}")

		# --- Quit ---
		quit_entry = sdl3.SDL_InsertTrayEntryAt(
			menu,
			-1,
			b"Quit",
			sdl3.SDL_TRAYENTRY_BUTTON,
		)
		if not quit_entry:
			raise RuntimeError(f"SDL_InsertTrayEntryAt(Quit) failed: {sdl3.SDL_GetError().decode()}")

		self._tray_open_cb =      sdl3.SDL_TrayCallback(self.tray_open_callback)
		self._tray_playpause_cb = sdl3.SDL_TrayCallback(self.tray_playpause_callback)
		self._tray_next_cb =      sdl3.SDL_TrayCallback(self.tray_next_callback)
		self._tray_quit_cb =      sdl3.SDL_TrayCallback(self.tray_quit_callback)
		self._tray_prev_cb =      sdl3.SDL_TrayCallback(self.tray_prev_callback)

		sdl3.SDL_SetTrayEntryCallback(open_entry, self._tray_open_cb, None)
		sdl3.SDL_SetTrayEntryCallback(playpause_entry, self._tray_playpause_cb, None)
		sdl3.SDL_SetTrayEntryCallback(next_entry, self._tray_next_cb, None)
		sdl3.SDL_SetTrayEntryCallback(quit_entry, self._tray_quit_cb, None)
		sdl3.SDL_SetTrayEntryCallback(prev_entry, self._tray_prev_cb, None)

		self.sdl_tray_launched = True
		self.gui.tray_active = True

	def destroy_sdl_tray(self) -> None:
		if self.sdl_tray is None:
			logging.warning("Attempted to destroy non-existent tray")
			return
		sdl3.SDL_DestroyTray(self.sdl_tray)
		self.sdl_tray = None
		self.sdl_tray_launched = False
		self.gui.tray_active = False

	def update_sdl_tray(self) -> None:
		#if self.tray_releases <= 0:
		#	self.tray_lock.acquire()
		#self.tray_releases -= 1

		if self.pctl.playing_state in (PlayingState.PLAYING, PlayingState.URL_STREAM):
			if self.sdl_tray_state not in (PlayingState.PLAYING, PlayingState.URL_STREAM):
				self.sdl_tray_state = self.pctl.playing_state
				self.tray_icon_play()
		elif self.pctl.playing_state == PlayingState.PAUSED:
			if self.sdl_tray_state != PlayingState.PAUSED:
				self.sdl_tray_state = PlayingState.PAUSED
				self.tray_icon_pause()
		elif self.sdl_tray_state != PlayingState.STOPPED:
			self.sdl_tray_state = PlayingState.STOPPED
			self.tray_icon_stop()

		text = ""
		if self.prefs.tray_show_title:
			tr = self.pctl.playing_object()
			if tr and tr.title and tr.artist:
				text = tr.artist + " - " + tr.title
			elif tr and tr.title:
				text = tr.title
			elif self.pctl.playing_state == PlayingState.URL_STREAM:
				# Stream metadata that couldn't be split into artist/title, else the station name
				if self.pctl.tag_meta:
					text = self.pctl.tag_meta
				elif self.radiobox.loaded_station:
					text = self.radiobox.loaded_station.title
			elif tr and tr.filename:
				text = tr.filename

			if self.pctl.playing_state == PlayingState.STOPPED:
				text = ""

		if self.sdl_tray_launched and text != self.sdl_tray_text:
			if text:
				self.sdl_set_tray_tooltip(text)
			else:
				self.sdl_set_tray_tooltip(self.t_title)
			self.sdl_tray_text = text

	def coll(self, r: list[int]) -> bool:
		return r[0] < self.inp.mouse_position[0] <= r[0] + r[2] and r[1] <= self.inp.mouse_position[1] <= r[1] + r[3]

	def draw_ab_repeat_markers(self, seek_x: float, seek_y: float, seek_w: float, seek_h: float) -> None:
		if self.pctl.playing_length <= 0 or seek_w <= 0:
			return

		marker_w = max(1, round(2 * self.gui.scale))
		marker_h = max(round(seek_h + (8 * self.gui.scale)), marker_w + 2)
		marker_y = round(seek_y - ((marker_h - seek_h) / 2))
		seek_start = round(seek_x)
		seek_end = round(seek_x + seek_w)
		if seek_end - seek_start <= marker_w:
			return

		def draw_marker(timestamp: float, colour: ColourRGBA) -> None:
			if timestamp < 0:
				return
			timestamp = min(timestamp, self.pctl.playing_length)
			marker_x = round(seek_x + seek_w * (timestamp / self.pctl.playing_length))
			marker_x = min(max(marker_x - marker_w // 2, seek_start), seek_end - marker_w)
			self.ddt.rect((marker_x, marker_y, marker_w, marker_h), colour)

		draw_marker(self.pctl.ab_repeat_a, ColourRGBA(0, 255, 0, 255))
		draw_marker(self.pctl.ab_repeat_b, ColourRGBA(0, 255, 255, 255))

	def scan_ffprobe(self, nt: TrackClass) -> None:
		startupinfo = None
		if self.windows:
			startupinfo = subprocess.STARTUPINFO()
			startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
		try:
			result = subprocess.run(
				[self.get_ffprobe(), "-v", "error", "-show_entries", "format=duration", "-of",
				"default=noprint_wrappers=1:nokey=1", nt.fullpath], stdout=subprocess.PIPE, startupinfo=startupinfo, check=True)
			nt.length = float(result.stdout.decode())
		except Exception:
			logging.exception("FFPROBE couldn't supply a duration")
		try:
			result = subprocess.run(
				[self.get_ffprobe(), "-v", "error", "-show_entries", "format_tags=title", "-of",
				"default=noprint_wrappers=1:nokey=1", nt.fullpath], stdout=subprocess.PIPE, startupinfo=startupinfo, check=True)
			nt.title = str(result.stdout.decode())
		except Exception:
			logging.exception("FFPROBE couldn't supply a title")
		try:
			result = subprocess.run(
				[self.get_ffprobe(), "-v", "error", "-show_entries", "format_tags=artist", "-of",
				"default=noprint_wrappers=1:nokey=1", nt.fullpath], stdout=subprocess.PIPE, startupinfo=startupinfo, check=True)
			nt.artist = str(result.stdout.decode())
		except Exception:
			logging.exception("FFPROBE couldn't supply a artist")
		try:
			result = subprocess.run(
				[self.get_ffprobe(), "-v", "error", "-show_entries", "format_tags=album", "-of",
				"default=noprint_wrappers=1:nokey=1", nt.fullpath], stdout=subprocess.PIPE, startupinfo=startupinfo, check=True)
			nt.album = str(result.stdout.decode())
		except Exception:
			logging.exception("FFPROBE couldn't supply a album")
		try:
			result = subprocess.run(
				[self.get_ffprobe(), "-v", "error", "-show_entries", "format_tags=date", "-of",
				"default=noprint_wrappers=1:nokey=1", nt.fullpath], stdout=subprocess.PIPE, startupinfo=startupinfo, check=True)
			nt.date = str(result.stdout.decode())
		except Exception:
			logging.exception("FFPROBE couldn't supply a date")
		try:
			result = subprocess.run(
				[self.get_ffprobe(), "-v", "error", "-show_entries", "format_tags=track", "-of",
				"default=noprint_wrappers=1:nokey=1", nt.fullpath], stdout=subprocess.PIPE, startupinfo=startupinfo, check=True)
			nt.track_number = str(result.stdout.decode())
		except Exception:
			logging.exception("FFPROBE couldn't supply a track")

	def hit_callback(self, win, point, data):
		gui          = self.gui
		inp          = self.inp
		windows         = self.windows
		prefs        = self.prefs
		macos        = self.macos
		logical_size = self.logical_size
		window_size  = self.window_size

		x = point.contents.x / logical_size[0] * window_size[0]
		y = point.contents.y / logical_size[0] * window_size[0]

		# Special layout modes
		if gui.mode == GuiMode.MINI:
			if inp.key_shift_down or inp.key_shiftr_down:
				return sdl3.SDL_HITTEST_NORMAL

			# if prefs.mini_mode_mode == MiniModeMode.SLATE:
			#     return sdl3.SDL_HITTEST_NORMAL

			if prefs.mini_mode_mode in (MiniModeMode.TAB, MiniModeMode.SLATE) and x > window_size[1] - 5 * gui.scale and y > window_size[1] - 12 * gui.scale:
				return sdl3.SDL_HITTEST_NORMAL

			if y < gui.window_control_hit_area_h and x > window_size[
				0] - gui.window_control_hit_area_w:
				return sdl3.SDL_HITTEST_NORMAL

			if prefs.mini_mode_mode == MiniModeMode.SIGNAL:
				if prefs.left_window_control:
					if x < gui.window_control_hit_area_w and y < gui.window_control_hit_area_h:
						return sdl3.SDL_HITTEST_NORMAL

				for field, _callback in self.fields.field_array:
					if coll_point((x, y), field):
						return sdl3.SDL_HITTEST_NORMAL

				frame = round(14 * gui.scale)
				box_y = round(14 * gui.scale)
				box_side = min(window_size[0] - frame * 2, window_size[1] - round(30 * gui.scale))
				box_rect = (frame, box_y, window_size[0] - frame * 2, box_side)
				graph_rect = (
					box_rect[0] + round(12 * gui.scale),
					box_rect[1] + round(28 * gui.scale),
					box_rect[2] - round(24 * gui.scale),
					box_rect[3] - round(54 * gui.scale),
				)
				baseline = graph_rect[1] + graph_rect[3] - round(16 * gui.scale)
				playlist = self.pctl.default_playlist
				if playlist:
					tracklist_focus = self.mini_mode_signal.focus_position
					if not self.mini_mode_signal.focus_ready:
						tracklist_focus = float(self.mini_mode_signal._current_playlist_position())
					current_index = self.mini_mode_signal._current_playlist_position()
					anchor_x = box_rect[0] + round(box_rect[2] * 0.32) + round(75 * gui.scale)
					anchor_y = box_rect[1] + round(box_rect[3] * 0.24)
					x_step = round(18 * gui.scale)
					y_step = round(16 * gui.scale)
					max_delta = 7
					start = max(0, int(math.floor(tracklist_focus)) - max_delta)
					end = min(len(playlist), int(math.ceil(tracklist_focus)) + max_delta + 1)
					for i in range(start, end):
						rel = i - tracklist_focus
						if abs(rel) > max_delta:
							continue
						tx = anchor_x + round(rel * x_step)
						ty = anchor_y + round(rel * y_step)
						if ty < graph_rect[1] - round(20 * gui.scale) or ty > baseline - round(4 * gui.scale):
							continue

						track_object = self.pctl.get_track(playlist[i])
						title = self.mini_mode_signal._track_title(track_object)
						font = 413 if i == current_index else 211
						tw = self.ddt.get_text_w(title, font)
						th = self.ddt.get_text_w(title, font, height=True)
						if tw is None:
							tw = round(120 * gui.scale)
						if th is None:
							th = round(14 * gui.scale)
						if tx > graph_rect[0] + graph_rect[2] or tx + round(tw) < graph_rect[0]:
							continue
						track_hit_rect = (
							round(tx) - round(4 * gui.scale),
							round(ty) - round(2 * gui.scale),
							round(tw) + round(8 * gui.scale),
							round(th) + round(4 * gui.scale),
						)
						track_drag_gap = round(6 * gui.scale)
						track_drag_clear_rect = (
							track_hit_rect[0] - track_drag_gap,
							track_hit_rect[1] - track_drag_gap,
							track_hit_rect[2] + track_drag_gap * 2,
							track_hit_rect[3] + track_drag_gap * 2,
						)
						if coll_point((x, y), track_drag_clear_rect):
							return sdl3.SDL_HITTEST_NORMAL

				art_size = round(127 * gui.scale)
				art_rect = (
					box_rect[0] + box_rect[2] - art_size - round(18 * gui.scale),
					box_rect[1] + round(34 * gui.scale),
					art_size,
					art_size,
				)
				if coll_point((x, y), art_rect):
					return sdl3.SDL_HITTEST_NORMAL

				return sdl3.SDL_HITTEST_DRAGGABLE

			# Square modes
			y1 = window_size[0]
			# if prefs.mini_mode_mode == MiniModeMode.SLATE:
			#     y1 = window_size[1]
			y0 = 0
			if macos:
				y0 = round(35 * gui.scale)
			if window_size[0] == window_size[1]:
				y1 = window_size[1] - 79 * gui.scale
			if y0 < y < y1 and not self.search_over.active:
				return sdl3.SDL_HITTEST_DRAGGABLE
			return sdl3.SDL_HITTEST_NORMAL

		# Standard player mode
		if not gui.maximized:
			if y < 0 and x > window_size[0]:
				return sdl3.SDL_HITTEST_RESIZE_TOPRIGHT

			if y < 0 and x < 1:
				return sdl3.SDL_HITTEST_RESIZE_TOPLEFT

			# if draw_border and y < 3 * gui.scale and x < window_size[0] - 40 * gui.scale and not gui.maximized:
			#     return sdl3.SDL_HITTEST_RESIZE_TOP

		if y < gui.panelY:
			if gui.top_bar_mode2:
				if y < gui.panelY - gui.panelY2:
					if prefs.left_window_control and x < 100 * gui.scale:
						return sdl3.SDL_HITTEST_NORMAL

					if x > window_size[0] - 100 * gui.scale and y < 30 * gui.scale:
						return sdl3.SDL_HITTEST_NORMAL
					return sdl3.SDL_HITTEST_DRAGGABLE
				if self.top_panel.drag_zone_start_x > x or self.tab_menu.active:
					return sdl3.SDL_HITTEST_NORMAL
				return sdl3.SDL_HITTEST_DRAGGABLE

			if self.top_panel.drag_zone_start_x < x < window_size[0] - (gui.offset_extra + 5):
				if self.tab_menu.active or inp.mouse_up or inp.mouse_down:  # mouse up/down is workaround for Wayland
					return sdl3.SDL_HITTEST_NORMAL

				if (prefs.left_window_control and x > window_size[0] - (100 * gui.scale) and (macos or windows)) \
				or (not prefs.left_window_control and x > window_size[0] - (160 * gui.scale) and (macos or windows)):
					return sdl3.SDL_HITTEST_NORMAL
				return sdl3.SDL_HITTEST_DRAGGABLE

		if not gui.maximized:
			if x > window_size[0] - 20 * gui.scale and y > window_size[1] - 20 * gui.scale:
				return sdl3.SDL_HITTEST_RESIZE_BOTTOMRIGHT
			if x < 5 and y > window_size[1] - 5:
				return sdl3.SDL_HITTEST_RESIZE_BOTTOMLEFT
			if y > window_size[1] - 5 * gui.scale:
				return sdl3.SDL_HITTEST_RESIZE_BOTTOM

			if x > window_size[0] - 3 * gui.scale and y > 20 * gui.scale:
				return sdl3.SDL_HITTEST_RESIZE_RIGHT
			if x < 5 * gui.scale and y > 10 * gui.scale:
				return sdl3.SDL_HITTEST_RESIZE_LEFT
			return sdl3.SDL_HITTEST_NORMAL
		return sdl3.SDL_HITTEST_NORMAL

	def draw_window_tools(self) -> None:
		gui         = self.gui
		inp         = self.inp
		colours     = self.colours
		window_size = self.window_size
		ddt         = self.ddt
		prefs       = self.prefs

		# rect = (window_size[0] - 55 * gui.scale, window_size[1] - 35 * gui.scale, 53 * gui.scale, 33 * gui.scale)
		# self.fields.add(rect)
		# prefs.left_window_control = not inp.key_shift_down
		macstyle = gui.macstyle

		bg_off = colours.window_buttons_bg
		bg_on = colours.window_buttons_bg_over
		fg_off = colours.window_button_icon_off
		fg_on = colours.window_buttons_icon_over
		x_on = colours.window_button_x_on
		x_off = colours.window_button_x_off

		h = round(28 * gui.scale)
		y = round(1 * gui.scale)
		if macstyle:
			y = round(9 * gui.scale)

		x_width = round(26 * gui.scale)
		ma_width = round(33 * gui.scale)
		mi_width = round(35 * gui.scale)
		re_width = round(30 * gui.scale)
		last_width = 0

		xx = 0
		left = prefs.left_window_control
		right = not left
		focused = window_is_focused(self.t_window)

		# Close
		if right:
			xx = window_size[0] - x_width
			xx -= round(2 * gui.scale)

		if macstyle:
			xx = window_size[0] - 27 * gui.scale
			if left:
				xx = round(4 * gui.scale)
			rect = (xx + 5, y - 1, 14 * gui.scale, 14 * gui.scale)
			self.fields.add(rect)
			colour = self.mac_close
			if not focused:
				colour = ColourRGBA(86, 85, 86, 255)
			elif self.coll(rect):
				colour = rgb_add_hls(colour, 0, 0.11, 0.05)
			self.gui.mac_circle.render(xx + 6 * gui.scale, y, colour)
			if self.coll(rect) and not gui.mouse_unknown and coll_point(inp.last_click_location, rect):
				self.do_exit_button()
		else:
			rect = (xx, y, x_width, h)
			last_width = x_width
			ddt.rect((rect[0], rect[1], rect[2], rect[3]), bg_off)
			self.fields.add(rect)
			if self.coll(rect) and not gui.mouse_unknown:
				ddt.rect((rect[0], rect[1], rect[2], rect[3]), bg_on)
				self.top_panel.exit_button.render(rect[0] + 8 * gui.scale, rect[1] + 8 * gui.scale, x_on)
				if coll_point(inp.last_click_location, rect):
					self.do_exit_button()
			else:
				self.top_panel.exit_button.render(rect[0] + 8 * gui.scale, rect[1] + 8 * gui.scale, x_off)

		# Macstyle restore
		if gui.mode == GuiMode.MINI and macstyle:
			if right:
				xx -= round(20 * gui.scale)
			if left:
				xx += round(20 * gui.scale)
			rect = (xx + 5, y - 1, 14 * gui.scale, 14 * gui.scale)

			self.fields.add(rect)
			colour = ColourRGBA(160, 55, 225, 255)
			if not focused:
				colour = ColourRGBA(86, 85, 86, 255)
			elif self.coll(rect):
				colour = rgb_add_hls(colour, 0, 0.1, 0.04)
			self.gui.mac_circle.render(xx + 6 * gui.scale, y, colour)
			if self.coll(rect) and not gui.mouse_unknown:
				if (inp.mouse_up or inp.ab_click) and coll_point(inp.last_click_location, rect):
					self.restore_full_mode()
					gui.update += 2

		# maximize

		if self.draw_max_button and gui.mode != GuiMode.MINI:
			if macstyle:
				if right:
					xx -= round(20 * gui.scale)
				if left:
					xx += round(20 * gui.scale)
				rect = (xx + 5, y - 1, 14 * gui.scale, 14 * gui.scale)

				self.fields.add(rect)
				colour = self.mac_maximize
				if not focused:
					colour = ColourRGBA(86, 85, 86, 255)
				elif self.coll(rect):
					colour = rgb_add_hls(colour, 0, 0.11, 0.05)
				self.gui.mac_circle.render(xx + 6 * gui.scale, y, colour)
				if self.coll(rect) and not gui.mouse_unknown:
					if (inp.mouse_up or inp.ab_click) and coll_point(inp.last_click_location, rect):
						self.do_minimize_button()

			else:
				if right:
					xx -= ma_width
				if left:
					xx += last_width
				rect = (xx, y, ma_width, h)
				last_width = ma_width
				ddt.rect_a((rect[0], rect[1]), (rect[2], rect[3]), bg_off)
				self.fields.add(rect)
				if self.coll(rect):
					ddt.rect_a((rect[0], rect[1]), (rect[2], rect[3]), bg_on)
					self.top_panel.maximize_button.render(rect[0] + 10 * gui.scale, rect[1] + 10 * gui.scale, fg_on)
					if (inp.mouse_up or inp.ab_click) and coll_point(inp.last_click_location, rect):
						self.do_maximize_button()
				else:
					self.top_panel.maximize_button.render(rect[0] + 10 * gui.scale, rect[1] + 10 * gui.scale, fg_off)

		# minimize

		if self.draw_min_button:
			# x = window_size[0] - round(65 * gui.scale)
			# if draw_max_button and not gui.mode == GuiMode.MINI:
			#	 x -= round(34 * gui.scale)
			if macstyle:
				if right:
					xx -= round(20 * gui.scale)
				if left:
					xx += round(20 * gui.scale)
				rect = (xx + 5, y - 1, 14 * gui.scale, 14 * gui.scale)

				self.fields.add(rect)
				colour = self.mac_minimize
				if not focused:
					colour = ColourRGBA(86, 85, 86, 255)
				elif self.coll(rect):
					colour = rgb_add_hls(colour, 0, 0.1, 0.04)
				self.gui.mac_circle.render(xx + 6 * gui.scale, y, colour)
				if self.coll(rect) and not gui.mouse_unknown:
					if (inp.mouse_up or inp.ab_click) and coll_point(inp.last_click_location, rect):
						self.do_maximize_button()
			else:
				if right:
					xx -= mi_width
				if left:
					xx += last_width

				rect = (xx, y, mi_width, h)
				last_width = mi_width
				ddt.rect_a((rect[0], rect[1]), (rect[2], rect[3]), bg_off)
				self.fields.add(rect)
				if self.coll(rect):
					ddt.rect_a((rect[0], rect[1]), (rect[2], rect[3]), bg_on)
					ddt.rect_a((rect[0] + 11 * gui.scale, rect[1] + 16 * gui.scale), (14 * gui.scale, 3 * gui.scale), fg_on)
					if (inp.mouse_up or inp.ab_click) and coll_point(inp.last_click_location, rect):
						self.do_minimize_button()
				else:
					ddt.rect_a(
						(rect[0] + 11 * gui.scale, rect[1] + 16 * gui.scale), (14 * gui.scale, 3 * gui.scale), fg_off)

		# restore
		if gui.mode == GuiMode.MINI:
			# bg_off = [0, 0, 0, 50]
			# bg_on = [255, 255, 255, 10]
			# fg_off =(255, 255, 255, 40)
			# fg_on = (255, 255, 255, 60)
			if macstyle:
				pass
			else:
				if right:
					xx -= re_width
				if left:
					xx += last_width

				rect = (xx, y, re_width, h)
				ddt.rect_a((rect[0], rect[1]), (rect[2], rect[3]), bg_off)
				self.fields.add(rect)
				if self.coll(rect):
					ddt.rect_a((rect[0], rect[1]), (rect[2], rect[3]), bg_on)
					self.top_panel.restore_button.render(rect[0] + 8 * gui.scale, rect[1] + 9 * gui.scale, fg_on)
					if (inp.mouse_click or inp.ab_click) and coll_point(inp.click_location, rect):
						self.restore_full_mode()
						gui.update += 2
				else:
					self.top_panel.restore_button.render(rect[0] + 8 * gui.scale, rect[1] + 9 * gui.scale, fg_off)

	def draw_window_border(self) -> None:
		ddt         = self.ddt
		colours     = self.colours
		gui         = self.gui
		corner_icon = self.gui.corner_icon
		window_size = self.window_size

		# The grip triangle would clash with a rounded corner, the resize
		# hit-zone below still works without it
		if not self.corner_round_radius():
			corner_icon.render(window_size[0] - corner_icon.w, window_size[1] - corner_icon.h, colours.corner_icon)

		corner_rect = (window_size[0] - 20 * gui.scale, window_size[1] - 20 * gui.scale, 20, 20)
		self.fields.add(corner_rect)

		right_rect = (window_size[0] - 3 * gui.scale, 20 * gui.scale, 10, window_size[1] - 40 * gui.scale)
		self.fields.add(right_rect)

		# top_rect = (20 * gui.scale, 0, window_size[0] - 40 * gui.scale, 2 * gui.scale)
		# self.fields.add(top_rect)

		left_rect = (0, 10 * gui.scale, 4 * gui.scale, window_size[1] - 50 * gui.scale)
		self.fields.add(left_rect)

		bottom_rect = (20 * gui.scale, window_size[1] - 4, window_size[0] - 40 * gui.scale, 7 * gui.scale)
		self.fields.add(bottom_rect)

		if self.coll(corner_rect):
			gui.cursor_want = 4
		elif self.coll(right_rect):
			gui.cursor_want = 8
		# elif self.coll(top_rect):
		#	 gui.cursor_want = 9
		elif self.coll(left_rect):
			gui.cursor_want = 10
		elif self.coll(bottom_rect):
			gui.cursor_want = 11

		colour = colours.window_frame

		# When corners are rounded, stop the straight edges short so the
		# corner arc textures can continue the contour
		inset = self.corner_round_radius()

		ddt.rect((inset, 0, window_size[0] - inset * 2, 1 * gui.scale), colour)
		ddt.rect((0, inset, 1 * gui.scale, window_size[1] - inset * 2), colour)
		ddt.rect((inset, window_size[1] - 1 * gui.scale, window_size[0] - inset * 2, 1 * gui.scale), colour)
		ddt.rect((window_size[0] - 1 * gui.scale, inset, 1 * gui.scale, window_size[1] - inset * 2), colour)

	def corner_round_radius(self) -> int:
		"""The window corner radius in pixels, 0 when rounding is inactive"""
		if not self.prefs.rounded_corners or not self.draw_border or self.corner_textures_failed:
			return 0
		gui = self.gui
		if gui.mode == GuiMode.MINI or gui.fullscreen or gui.maximized:
			return 0
		return max(2, round(self.prefs.corner_radius * gui.scale))

	def destroy_corner_textures(self) -> None:
		for textures in (self.corner_mask_textures, self.corner_border_textures):
			if textures:
				for texture in textures:
					sdl3.SDL_DestroyTexture(texture)
		self.corner_mask_textures = None
		self.corner_border_textures = None
		self.corner_texture_size = (0, 0)

	def _build_corner_textures(self, radius: int, thickness: int) -> bool:
		"""Generate anti-aliased corner cutout masks and border arcs for each window corner

		The mask textures erase the window corners by multiplying destination
		alpha, the border textures draw the window frame arc over the contour.
		"""
		self.destroy_corner_textures()

		# Draw supersampled then downsample for smooth anti-aliased edges
		ss = 8
		size = radius * ss

		cutout = Image.new("L", (size, size), 0)
		draw = ImageDraw.Draw(cutout)
		draw.ellipse((0, 0, size * 2, size * 2), fill=255)
		cutout = cutout.resize((radius, radius), Image.Resampling.LANCZOS)

		arc = Image.new("L", (size, size), 0)
		draw = ImageDraw.Draw(arc)
		draw.ellipse((0, 0, size * 2, size * 2), outline=255, width=thickness * ss)
		arc = arc.resize((radius, radius), Image.Resampling.LANCZOS)

		# dst = dst * srcA, keeps pixels inside the corner circle and erases outside
		mask_blend = sdl3.SDL_ComposeCustomBlendMode(
			sdl3.SDL_BLENDFACTOR_ZERO,
			sdl3.SDL_BLENDFACTOR_SRC_ALPHA,
			sdl3.SDL_BLENDOPERATION_ADD,
			sdl3.SDL_BLENDFACTOR_ZERO,
			sdl3.SDL_BLENDFACTOR_SRC_ALPHA,
			sdl3.SDL_BLENDOPERATION_ADD,
		)

		# Top-left, top-right, bottom-left, bottom-right
		transposes = (None, Image.Transpose.FLIP_LEFT_RIGHT, Image.Transpose.FLIP_TOP_BOTTOM, Image.Transpose.ROTATE_180)

		self.corner_mask_textures = []
		self.corner_border_textures = []
		for alpha_image, textures, blend in (
			(cutout, self.corner_mask_textures, mask_blend),
			(arc, self.corner_border_textures, sdl3.SDL_BLENDMODE_BLEND),
		):
			rgba = Image.new("RGBA", (radius, radius), (255, 255, 255, 0))
			rgba.putalpha(alpha_image)
			for transpose in transposes:
				corner = rgba if transpose is None else rgba.transpose(transpose)
				g = io.BytesIO()
				corner.save(g, "PNG")
				g.seek(0)
				surface = self.ddt.load_image(g)
				texture = sdl3.SDL_CreateTextureFromSurface(self.renderer, surface)
				sdl3.SDL_DestroySurface(surface)
				if not texture or not sdl3.SDL_SetTextureBlendMode(texture, blend):
					logging.warning(f"Failed to create corner texture: {sdl3.SDL_GetError()}")
					if texture:
						sdl3.SDL_DestroyTexture(texture)
					self.destroy_corner_textures()
					self.corner_textures_failed = True
					return False
				textures.append(texture)

		self.corner_texture_size = (radius, thickness)
		return True

	def render_rounded_corners(self) -> None:
		"""Cut out the window corners with anti-aliased alpha masks, then draw the frame arcs

		Called on the backbuffer just before present. Per-frame cost is eight
		small texture copies, the textures themselves are cached.
		"""
		radius = self.corner_round_radius()
		if not radius:
			return

		thickness = max(1, round(1 * self.gui.scale))
		if self.corner_mask_textures is None or self.corner_texture_size != (radius, thickness):
			if not self._build_corner_textures(radius, thickness):
				return

		w = self.window_size[0]
		h = self.window_size[1]
		positions = ((0, 0), (w - radius, 0), (0, h - radius), (w - radius, h - radius))
		rect = sdl3.SDL_FRect(0.0, 0.0, float(radius), float(radius))

		for texture, (x, y) in zip(self.corner_mask_textures, positions):
			rect.x = float(x)
			rect.y = float(y)
			sdl3.SDL_RenderTexture(self.renderer, texture, None, rect)

		frame = self.colours.window_frame
		for texture, (x, y) in zip(self.corner_border_textures, positions):
			sdl3.SDL_SetTextureColorMod(texture, frame.r, frame.g, frame.b)
			sdl3.SDL_SetTextureAlphaMod(texture, frame.a)
			rect.x = float(x)
			rect.y = float(y)
			sdl3.SDL_RenderTexture(self.renderer, texture, None, rect)

	def prime_fonts(self) -> None:
		standard_font = self.prefs.linux_font
		# if self.windows:
		#	 standard_font = self.prefs.linux_font + ", Sans"  # The CJK ones dont appear to be working
		self.ddt.prime_font(standard_font, 8, 9)
		self.ddt.prime_font(standard_font, 8, 10)
		self.ddt.prime_font(standard_font, 8.5, 11)
		self.ddt.prime_font(standard_font, 8.7, 11.5)
		self.ddt.prime_font(standard_font, 9, 12)
		self.ddt.prime_font(standard_font, 10, 13)
		self.ddt.prime_font(standard_font, 10, 14)
		self.ddt.prime_font(standard_font, 10.2, 14.5)
		self.ddt.prime_font(standard_font, 11, 15)
		self.ddt.prime_font(standard_font, 12, 16)
		self.ddt.prime_font(standard_font, 12, 17)
		self.ddt.prime_font(standard_font, 12, 18)
		self.ddt.prime_font(standard_font, 13, 19)
		self.ddt.prime_font(standard_font, 14, 20)
		self.ddt.prime_font(standard_font, 24, 30)

		self.ddt.prime_font(standard_font, 9, 412)
		self.ddt.prime_font(standard_font, 10, 413)

		standard_font = self.prefs.linux_font_semibold
		# if self.windows:
		#	 standard_font = self.prefs.linux_font_semibold + ", Noto Sans Med, Sans" #, Noto Sans CJK JP Medium, Noto Sans CJK Medium, Sans"

		self.ddt.prime_font(standard_font, 8, 309)
		self.ddt.prime_font(standard_font, 8, 310)
		self.ddt.prime_font(standard_font, 8.5, 311)
		self.ddt.prime_font(standard_font, 9, 312)
		self.ddt.prime_font(standard_font, 10, 313)
		self.ddt.prime_font(standard_font, 10.5, 314)
		self.ddt.prime_font(standard_font, 11, 315)
		self.ddt.prime_font(standard_font, 12, 316)
		self.ddt.prime_font(standard_font, 12, 317)
		self.ddt.prime_font(standard_font, 12, 318)
		self.ddt.prime_font(standard_font, 13, 319)
		self.ddt.prime_font(standard_font, 24, 330)

		standard_font = self.prefs.linux_font_bold
		# if self.windows:
		#	 standard_font = self.prefs.linux_font_bold + ", Noto Sans, Sans Bold"

		self.ddt.prime_font(standard_font, 6, 209)
		self.ddt.prime_font(standard_font, 7, 210)
		self.ddt.prime_font(standard_font, 8, 211)
		self.ddt.prime_font(standard_font, 9, 212)
		self.ddt.prime_font(standard_font, 10, 213)
		self.ddt.prime_font(standard_font, 11, 214)
		self.ddt.prime_font(standard_font, 12, 215)
		self.ddt.prime_font(standard_font, 13, 216)
		self.ddt.prime_font(standard_font, 14, 217)
		self.ddt.prime_font(standard_font, 17, 218)
		self.ddt.prime_font(standard_font, 19, 219)
		self.ddt.prime_font(standard_font, 20, 220)
		self.ddt.prime_font(standard_font, 25, 228)

		standard_font = self.prefs.linux_font_condensed
		# if self.windows:
		#	 standard_font = "Noto Sans ExtCond, Sans"
		self.ddt.prime_font(standard_font, 10, 413)
		self.ddt.prime_font(standard_font, 11, 414)
		self.ddt.prime_font(standard_font, 12, 415)
		self.ddt.prime_font(standard_font, 13, 416)

		standard_font = self.prefs.linux_font_condensed_bold  # "Noto Sans, ExtraCondensed Bold"
		# if self.windows:
		#	 standard_font = "Noto Sans ExtCond, Sans Bold"
		# self.ddt.prime_font(standard_font, 9, 512)
		self.ddt.prime_font(standard_font, 10, 513)
		self.ddt.prime_font(standard_font, 11, 514)
		self.ddt.prime_font(standard_font, 12, 515)
		self.ddt.prime_font(standard_font, 13, 516)

	def get_real_time(self) -> float:
		offset = self.pctl.decode_time - (self.prefs.sync_lyrics_time_offset / 1000)
		if self.prefs.backend == Backend.PHAZOR:
			offset -= (self.prefs.device_buffer - 120) / 1000
		elif self.prefs.backend == Backend.GSTREAMER:
			offset += 0.1
		return max(0, offset)

	def draw_internal_link(self, x: int, y: int, text: str, colour: ColourRGBA, font: int) -> bool:
		tweak = font
		while tweak > 100:
			tweak -= 100

		if self.gui.scale == 2:
			tweak *= 2
			tweak += 4
		if self.gui.scale == 1.25:
			tweak = round(tweak * 1.25)
			tweak += 1

		sp = self.ddt.text((x, y), text, colour, font)

		rect = [x - 5 * self.gui.scale, y - 2 * self.gui.scale, sp + 11 * self.gui.scale, 23 * self.gui.scale]
		self.fields.add(rect)

		if self.coll(rect):
			if not self.inp.mouse_click:
				self.gui.cursor_want = 3
			self.ddt.line(x, y + tweak + 2, x + sp, y + tweak + 2, alpha_mod(colour, 180))
			if self.inp.mouse_click:
				return True
		return False

	def pixel_to_logical(self, x: int) -> int:
		return round((x / self.window_size[0]) * self.logical_size[0])

	def img_slide_update_gall(self, value: int, pause: bool = True) -> None:
		self.gui.halt_image_rendering = True

		self.album_mode_art_size = value

		self.clear_img_cache(False)
		if pause:
			self.gallery_load_delay.set()
			self.gui.frame_callback_list.append(TestTimer(0.6))
		self.gui.halt_image_rendering = False

		# Update sizes
		self.gall_ren.size = self.album_mode_art_size

		if self.album_mode_art_size > 150:
			self.prefs.thin_gallery_borders = False

	def fix_encoding(self, index: int, mode: int, enc: str) -> None:
		todo: list[int] = []
		# TODO(Martin): What's the point of this? It was global before but is only used here
		enc_field = "All"

		if mode == 1:
			todo = [index]
		elif mode == 0:
			for b in range(len(self.pctl.default_playlist)):
				if self.pctl.master_library[self.pctl.default_playlist[b]].parent_folder_name == self.pctl.master_library[
					index].parent_folder_name:
					todo.append(self.pctl.default_playlist[b])

		for q in range(len(todo)):
			# key = self.pctl.master_library[todo[q]].title + self.pctl.master_library[todo[q]].filename
			old_star = self.star_store.full_get(todo[q])
			if old_star is not None:
				self.star_store.remove(todo[q])

			if enc_field in ("All", "Artist"):
				line = self.pctl.master_library[todo[q]].artist
				line = line.encode("Latin-1", "ignore")
				line = line.decode(enc, "ignore")
				self.pctl.master_library[todo[q]].artist = line

			if enc_field in ("All", "Album"):
				line = self.pctl.master_library[todo[q]].album
				line = line.encode("Latin-1", "ignore")
				line = line.decode(enc, "ignore")
				self.pctl.master_library[todo[q]].album = line

			if enc_field in ("All", "Title"):
				line = self.pctl.master_library[todo[q]].title
				line = line.encode("Latin-1", "ignore")
				line = line.decode(enc, "ignore")
				self.pctl.master_library[todo[q]].title = line

			if old_star is not None:
				self.star_store.insert(todo[q], old_star)

			# if key in self.pctl.star_library:
			#	 newkey = self.pctl.master_library[todo[q]].title + self.pctl.master_library[todo[q]].filename
			#	 if newkey not in self.pctl.star_library:
			#		 self.pctl.star_library[newkey] = copy.deepcopy(self.pctl.star_library[key])
			#		 # del self.pctl.star_library[key]

	def transfer_tracks(self, index: int, mode: int, to: int) -> None:
		todo: list[int] = []

		if mode == 0:
			todo = [index]
		elif mode == 1:
			for b in range(len(self.pctl.default_playlist)):
				if self.pctl.master_library[self.pctl.default_playlist[b]].parent_folder_name == self.pctl.master_library[
					index].parent_folder_name:
					todo.append(self.pctl.default_playlist[b])
		elif mode == 2:
			todo = self.pctl.default_playlist

		self.pctl.multi_playlist[to].playlist_ids += todo

	def add_stations(self, stations: list[RadioStation], name: str) -> None:
		if len(stations) == 1:
			for i, playlist in enumerate(self.pctl.radio_playlists):
				if playlist.name == "Default":
					playlist.stations.insert(0, stations[0])
					playlist.scroll = 0
					self.pctl.radio_playlist_viewing = i
					break
			else:
				self.pctl.radio_playlists.append(RadioPlaylist(uid=uid_gen(), name="Default", stations=stations, scroll=0))
				self.pctl.radio_playlist_viewing = len(self.pctl.radio_playlists) - 1
		else:
			self.pctl.radio_playlists.append(RadioPlaylist(uid=uid_gen(), name=name, stations=stations, scroll=0))
			self.pctl.radio_playlist_viewing = len(self.pctl.radio_playlists) - 1
		if not self.gui.radio_view:
			self.enter_radio_view()

	def parse_m3u(self, m3u_path: str) -> tuple[list[int], list[RadioStation]]:
		"""Read specified .m3u[8] playlist file, return list of track IDs/stations"""
		playlist: list[int] = []
		stations: list[RadioStation] = []

		titles:        dict[str, TrackClass] = {}
		location_dict: dict[str, TrackClass] = {}
		pl_dir = Path(m3u_path).parent
		path = Path(m3u_path)
		try:
			with path.open(encoding="utf-8") as file:
				lines = file.readlines()
		except UnicodeDecodeError as e:
			self.show_message(_("Error importing M3U playlist"), _("Error trying to parse playlist as UTF-8:") + f" {e}", mode="warning")
			logging.error(f"Error trying to parse playlist as UTF-8: {e}")
			return [], []
		except Exception as e:
			self.show_message(_("Exception importing M3U playlist"), _("Unknown exception trying to parse playlist") + f" {e}", mode="warning")
			logging.exception("Unknown exception trying to parse playlist")
			return [], []

		# parse data lines - either song files or radio links
		found_imported = 0
		found_file = 0
		found_title = 0
		not_found = 0
		for i, line in enumerate(lines):
			line = line.strip("\r\n").strip()
			if not line.startswith("#"):  # line.startswith("http"):

				# Get title if present
				line_title = ""
				if i > 0:
					bline = lines[i - 1]
					if "," in bline and bline.startswith("#EXTINF:"):
						line_title = bline.split(",", 1)[1].strip("\r\n").strip()

				if line.startswith("http"):
					radio: RadioStation = RadioStation(
						stream_url=line,
						title=line_title or os.path.splitext(os.path.basename(path))[0].strip())
					stations.append(radio)

					if self.gui.auto_play_import:
						self.gui.auto_play_import = False
						self.radiobox.start(radio)
				else:
					line = uri_parse(line)
					# Fix up relative filepaths
					if not Path(line).is_absolute():
						line = Path(pl_dir / Path(line) ).resolve()
					else:
						line = Path(line).resolve()
					line = str(line)

					# Cache database file paths for quick lookup
					if not location_dict:
						for key, value in self.pctl.master_library.items():
							if value.fullpath:
								location_dict[value.fullpath] = value
							if value.title:
								titles[value.artist + " - " + value.title] = value

					# Is file path already imported?
					# logging.info(line)
					if line in location_dict:
						playlist.append(location_dict[line].index)
						found_imported += 1
					# Or... does the file exist? Then import it
					elif os.path.isfile(line):
						nt = TrackClass()
						nt.index = self.pctl.master_count
						set_path(nt, line)
						nt = self.tag_scan(nt)
						self.pctl.master_library[self.pctl.master_count] = nt
						playlist.append(self.pctl.master_count)
						self.pctl.master_count += 1
						found_file += 1
					# Last resort, guess based on title
					elif line_title in titles:
						playlist.append(titles[line_title].index)
						found_title += 1
					else:
						log_line = line_title or line
						logging.info(f"track \"{log_line}\" not found")
						not_found += 1
		logging.info(f"playlist imported with {found_imported} tracks already in library, {found_file} found from filepath, {found_title} from title and {not_found} not found")
		return playlist, stations

	def load_m3u(self, m3u_path: str) -> None:
		"""Import an m3u file and create a new Tauon playlist for it"""
		path = Path(m3u_path)
		name = path.stem
		if not path.is_file():
			return

		playlist, stations = self.parse_m3u(path)

		# & then add it to the list
		if playlist:
			filesize = path.stat().st_size
			final_playlist = self.pl_gen(title=name, playlist_ids=playlist, playlist_file=str(path), file_size=filesize, export_type="m3u", auto_import=True)
			logging.info(f"Imported m3u file as {final_playlist.title}")
			self.pctl.multi_playlist.append(
				final_playlist)
		if stations:
			self.add_stations(stations, name)
		if not playlist and not stations:
			return

		self.gui.update = 1

	def read_pls(self, lines: list[str], path: str, followed: bool = False) -> None:
		ids:         list[str] = []
		urls:   dict[str, str] = {}
		titles: dict[str, str] = {}

		for line in lines:
			line = line.strip("\r\n")
			if "=" in line and line.startswith("File") and "http" in line:
				# Get number
				n = line.split("=")[0][4:]
				if n.isdigit():
					if n not in ids:
						ids.append(n)
					urls[n] = line.split("=", 1)[1].strip()

			if "=" in line and line.startswith("Title"):
				# Get number
				n = line.split("=")[0][5:]
				if n.isdigit():
					if n not in ids:
						ids.append(n)
					titles[n] = line.split("=", 1)[1].strip()

		stations: list[RadioStation] = []
		for id in ids:
			if id in urls:
				radio = RadioStation(
					stream_url=urls[id],
					title=titles[id] if id in titles else os.path.splitext(os.path.basename(path))[0],
					#scroll=0, # TODO(Martin): This was here wrong as scrolling is meant to be for RadioPlaylist?
					)

				if ".pls" in radio.stream_url:
					if not followed:
						try:
							logging.info("Download .pls")
							response = requests.get(radio.stream_url, stream=True, timeout=15)
							if int(response.headers["Content-Length"]) < 2000:
								self.read_pls(response.content.decode().splitlines(), path, followed=True)
						except Exception:
							logging.exception("Failed to retrieve .pls")
				else:
					stations.append(radio)
					if self.gui.auto_play_import:
						self.gui.auto_play_import = False
						self.radiobox.start(radio)
		if stations:
			self.add_stations(stations, os.path.basename(path))

	def load_pls(self, path: str) -> None:
		if os.path.isfile(path):
			with open(path) as f:
				lines = f.readlines()
				self.read_pls(lines, path)

	def parse_xspf(self, path: str) -> tuple[list[int], list[RadioStation], str]:
		"""Read specified .xspf playlist file, return lists of track IDs & stations plus playlist name if stored"""
		try:
			parser = ET.XMLParser(encoding="utf-8")
			e = ET.parse(path, parser).getroot()

			a: list[dict[str, str | None]] = []
			b: dict[str, str | None] = {}
			info = ""
			name = ""
			pl_dir = Path(path).parent

			for top in e:

				if top.tag.endswith("info"):
					info = top.text
				if top.tag.endswith("title"):
					name = top.text
				if top.tag.endswith("trackList"):
					for track in top:
						if track.tag.endswith("track"):
							for field in track:
								# logging.info(field.tag)
								# logging.info(field.text)
								if "title" in field.tag and field.text:
									b["title"] = field.text
								if "location" in field.tag and field.text:
									loc = field.text
									loc = str(urllib.parse.unquote(loc))
									parsed_loc = urllib.parse.urlparse(loc)

									# Preserve remote stream URLs; only normalize filesystem paths.
									if parsed_loc.scheme and parsed_loc.scheme != "file":
										pass
									else:
										if parsed_loc.scheme == "file":
											try:
												loc = str(Path.from_uri(loc))
											except Exception:
												logging.exception("Unknown error getting Path from URI")

										if not Path(loc).is_absolute():
											loc = str(Path(pl_dir / Path(loc)).resolve())
										else:
											loc = str(Path(loc).resolve())

									b["location"] = loc
								if "creator" in field.tag and field.text:
									b["artist"] = field.text
								if "album" in field.tag and field.text:
									b["album"] = field.text
								if "duration" in field.tag and field.text:
									b["duration"] = field.text

							b["info"] = info
							b["name"] = name
							a.append(copy.deepcopy(b))
							b = {}

		except Exception:
			logging.exception("Error importing/parsing XSPF playlist")
			self.show_message(_("Error importing XSPF playlist."), _("Sorry about that."), mode="warning")
			raise

		# Extract internet streams first
		stations: list[RadioStation] = []
		for i in reversed(range(len(a))):
			item = a[i]
			if item["location"].startswith("http"):
				radio = RadioStation(
					stream_url=item["location"],
					title=item["name"])
			#	radio.scroll = 0 # TODO(Martin): This was here wrong as scrolling is meant to be for RadioPlaylist?
				if item["info"].startswith("http"):
					radio.website_url = item["info"]

				stations.append(radio)

				if self.gui.auto_play_import:
					self.gui.auto_play_import = False
					self.radiobox.start(radio)

				del a[i]
		playlist: list[int] = []
		missing = 0

		if len(a) > 5000:
			self.gui.to_got = "xspfl"

		# Generate location dict
		location_dict: dict[str, int] = {}
		base_names:    dict[str, int] = {}
		r_base_names:  dict[int, str] = {}
		titles:        dict[str, int] = {}
		for key, value in self.pctl.master_library.items():
			if value.fullpath:
				location_dict[value.fullpath] = key
			if value.filename:
				base_names[value.filename] = 0
				r_base_names[key] = value.filename
			if value.title:
				titles[value.title] = 0

		for track in a:
			found = False

			# Check if we already have a track with full file path in database
			if not found and "location" in track:
				location = track["location"]
				if location in location_dict:
					playlist.append(location_dict[location])
					if not os.path.isfile(location):
						missing += 1
					found = True

				if found is True:
					continue

			# Then check for title, artist and filename match
			if not found and "location" in track and "duration" in track and "title" in track and "artist" in track:
				base = os.path.basename(track["location"])
				if base in base_names:
					for index, bn in r_base_names.items():
						va = self.pctl.master_library[index]
						if va.artist == track["artist"] and va.title == track["title"] and \
								os.path.isfile(va.fullpath) and \
								va.filename == base:
							playlist.append(index)
							if not os.path.isfile(va.fullpath):
								missing += 1
							found = True
							break
					if found is True:
						continue

			# Then check for just title and artist match
			if not found and "title" in track and "artist" in track and track["title"] in titles:
				for key, value in self.pctl.master_library.items():
					if value.artist == track["artist"] and value.title == track["title"] and os.path.isfile(value.fullpath):
						playlist.append(key)
						if not os.path.isfile(value.fullpath):
							missing += 1
						found = True
						break
				if found is True:
					continue

			if "location" in track or "title" in track:
				nt = TrackClass()
				nt.index = self.pctl.master_count
				nt.found = False

				if "location" in track:
					location = track["location"]
					set_path(nt, location)
					if os.path.isfile(location):
						nt.found = True
				elif "album" in track:
					nt.parent_folder_name = track["album"]
				if "artist" in track:
					nt.artist = track["artist"]
				if "title" in track:
					nt.title = track["title"]
				if "duration" in track:
					nt.length = int(float(track["duration"]) / 1000)
				if "album" in track:
					nt.album = track["album"]
				nt.is_cue = False
				if nt.found:
					nt = self.tag_scan(nt)

				self.pctl.master_library[self.pctl.master_count] = nt
				playlist.append(self.pctl.master_count)
				self.pctl.master_count += 1
				if nt.found:
					continue

			missing += 1
			logging.error("-- Failed to locate track")
			if "location" in track:
				logging.error(f"-- -- Expected path: {track['location']}")
			if "title" in track:
				logging.error(f"-- -- Title: {track['title']}")
			if "artist" in track:
				logging.error(f"-- -- Artist: {track['artist']}")
			if "album" in track:
				logging.error(f"-- -- Album: {track['album']}")

		if missing > 0:
			self.show_message(
				_("Failed to locate {N} out of {T} tracks.")
				.format(N=str(missing), T=str(len(a))))

		return playlist, stations, name


	def load_xspf(self, xspf_path: str) -> None:
		# self.log("Importing XSPF playlist: " + path, title=True)

		if not Path(xspf_path).is_file():
			return

		playlist, stations, name = self.parse_xspf(xspf_path)
		path = Path(xspf_path)
		if not name:
			name = path.stem

		#logging.info(playlist)
		if playlist:
			filesize = path.stat().st_size
			final_playlist = self.pl_gen(title=name, playlist_ids=playlist, playlist_file=str(path), file_size=filesize, export_type="xspf", auto_import=True)
			logging.info(f"Imported xspf file as {final_playlist.title}")
			self.pctl.multi_playlist.append(
				final_playlist)
		if stations:
			self.add_stations(stations, name)
		if not stations and not playlist:
			return
		self.gui.update = 1


	def ex_tool_tip(self, x: int, y: float, text1_width: int, text: str, font: int) -> None:
		text2_width = self.ddt.get_text_w(text, font)
		if text2_width == text1_width:
			return

		y -= 10 * self.gui.scale

		w = self.ddt.get_text_w(text, 312) + 24 * self.gui.scale
		h = 24 * self.gui.scale

		x -= int(w / 2)

		border = 1 * self.gui.scale
		self.ddt.rect((x - border, y - border, w + border * 2, h + border * 2), self.colours.grey(60))
		self.ddt.rect((x, y, w, h), self.colours.menu_background)
		p = self.ddt.text((x + int(w / 2), y + 3 * self.gui.scale, 2), text, self.colours.menu_text, 312, bg=self.colours.menu_background)

	def menu_standard_or_grey(self, enabled: bool) -> Decorator:
		line_colour = self.colours.menu_text if enabled else self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, None)

	def enable_artist_list(self) -> None:
		if self.prefs.left_panel_mode != "artist list":
			self.gui.last_left_panel_mode = self.prefs.left_panel_mode
		self.prefs.left_panel_mode = "artist list"
		self.gui.lsp = True
		self.gui.update_layout = True

	def enable_playlist_list(self) -> None:
		if self.prefs.left_panel_mode != "playlist":
			self.gui.last_left_panel_mode = self.prefs.left_panel_mode
		self.prefs.left_panel_mode = "playlist"
		self.gui.lsp = True
		self.gui.update_layout = True

	def enable_queue_panel(self) -> None:
		if self.prefs.left_panel_mode != "queue":
			self.gui.last_left_panel_mode = self.prefs.left_panel_mode
		self.prefs.left_panel_mode = "queue"
		self.gui.lsp = True
		self.gui.update_layout = True

	def enable_folder_list(self) -> None:
		if self.prefs.left_panel_mode != "folder view":
			self.gui.last_left_panel_mode = self.prefs.left_panel_mode
		self.prefs.left_panel_mode = "folder view"
		self.gui.lsp = True
		self.gui.update_layout = True

	def lsp_menu_test_queue(self) -> bool:
		if not self.gui.lsp:
			return False
		return self.prefs.left_panel_mode == "queue"

	def lsp_menu_test_playlist(self) -> bool:
		if not self.gui.lsp:
			return False
		return self.prefs.left_panel_mode == "playlist"

	def lsp_menu_test_tree(self) -> bool:
		if not self.gui.lsp:
			return False
		return self.prefs.left_panel_mode == "folder view"

	def lsp_menu_test_artist(self) -> bool:
		if not self.gui.lsp:
			return False
		return self.prefs.left_panel_mode == "artist list"

	def toggle_left_last(self) -> None:
		self.gui.lsp = True
		t = self.prefs.left_panel_mode
		if t != self.gui.last_left_panel_mode:
			self.prefs.left_panel_mode = self.gui.last_left_panel_mode
			self.gui.last_left_panel_mode = t

	def toggle_repeat(self) -> None:
		self.gui.update += 1
		self.pctl.repeat_mode ^= True
		if self.pctl.mpris is not None:
			self.pctl.mpris.update_loop()

	def menu_repeat_off(self) -> None:
		self.pctl.repeat_mode = False
		self.pctl.album_repeat_mode = False
		if self.pctl.mpris is not None:
			self.pctl.mpris.update_loop()

	def menu_set_repeat(self) -> None:
		self.pctl.repeat_mode = True
		self.pctl.album_repeat_mode = False
		if self.pctl.mpris is not None:
			self.pctl.mpris.update_loop()

	def menu_album_repeat(self) -> None:
		self.pctl.repeat_mode = True
		self.pctl.album_repeat_mode = True
		if self.pctl.mpris is not None:
			self.pctl.mpris.update_loop()

	def menu_ab_repeat_deco(self) -> Decorator:
		text = _("A/B Repeat")
		if self.pctl.ab_repeat_a >= 0:
			text = _("Set B Marker")
		if self.pctl.ab_repeat_b >= 0:
			text = _("Clear A/B")
		return Decorator(self.colours.menu_text, self.colours.menu_background, text)

	def menu_ab_repeat(self) -> None:
		if self.pctl.playing_state == PlayingState.STOPPED or self.pctl.playing_length <= 0:
			return

		marker_time = min(max(self.pctl.playing_time, 0), self.pctl.playing_length)

		if self.pctl.ab_repeat_a < 0:
			self.pctl.ab_repeat_a = marker_time
			self.pctl.ab_repeat_b = -1.0
		elif self.pctl.ab_repeat_b < 0:
			if marker_time <= self.pctl.ab_repeat_a:
				marker_time = min(self.pctl.playing_length, self.pctl.ab_repeat_a + 0.1)
				if marker_time <= self.pctl.ab_repeat_a:
					return
			self.pctl.ab_repeat_b = marker_time
		else:
			self.pctl.clear_ab_repeat(update_gui=False)

		self.gui.update += 1

	def toggle_random(self) -> None:
		self.gui.update += 1
		self.pctl.random_mode ^= True
		if self.pctl.mpris is not None:
			self.pctl.mpris.update_shuffle()

	def toggle_random_on(self) -> None:
		self.pctl.random_mode = True
		if self.pctl.mpris is not None:
			self.pctl.mpris.update_shuffle()

	def toggle_random_off(self) -> None:
		self.pctl.random_mode = False
		if self.pctl.mpris is not None:
			self.pctl.mpris.update_shuffle()

	def menu_shuffle_off(self) -> None:
		self.pctl.random_mode = False
		self.pctl.album_shuffle_mode = False
		if self.pctl.mpris is not None:
			self.pctl.mpris.update_shuffle()

	def menu_set_random(self) -> None:
		self.pctl.random_mode = True
		self.pctl.album_shuffle_mode = False
		if self.pctl.mpris is not None:
			self.pctl.mpris.update_shuffle()

	def menu_album_random(self) -> None:
		self.pctl.random_mode = True
		self.pctl.album_shuffle_mode = True
		if self.pctl.mpris is not None:
			self.pctl.mpris.update_shuffle()

	def toggle_shuffle_layout(self, albums: bool = False) -> None:
		self.prefs.shuffle_lock ^= True
		if self.prefs.shuffle_lock:

			self.gui.shuffle_was_showcase = self.gui.showcase_mode
			self.gui.shuffle_was_random = self.pctl.random_mode
			self.gui.shuffle_was_repeat = self.pctl.repeat_mode

			if not self.gui.combo_mode:
				self.view_box.lyrics(hit=True)
			self.pctl.random_mode = True
			self.pctl.repeat_mode = False
			if albums:
				self.prefs.album_shuffle_lock_mode = True
			if self.pctl.playing_state == PlayingState.STOPPED and self.pctl.track_queue:
				self.pctl.advance()
		else:
			self.pctl.random_mode = self.gui.shuffle_was_random
			self.pctl.repeat_mode = self.gui.shuffle_was_repeat
			self.prefs.album_shuffle_lock_mode = False
			if not self.gui.shuffle_was_showcase:
				self.exit_combo()

	def toggle_shuffle_layout_albums(self) -> None:
		self.toggle_shuffle_layout(albums=True)

	def toggle_shuffle_layout_deco(self) -> Decorator:
		if not self.prefs.shuffle_lock:
			return Decorator(self.colours.menu_text, self.colours.menu_background, _("Shuffle Lockdown"))
		return Decorator(self.colours.menu_text, self.colours.menu_background, _("Exit Shuffle Lockdown"))

	def exit_shuffle_layout(self, _: int) -> bool:
		return self.prefs.shuffle_lock

	def bio_set_large(self) -> None:
		# if self.window_size[0] >= round(1000 * self.gui.scale):
		# self.gui.artist_panel_height = 320 * self.gui.scale
		self.prefs.bio_large = True
		if self.gui.artist_info_panel:
			self.artist_info_box.get_data(self.artist_info_box.artist_on)

	def bio_set_small(self) -> None:
		# self.gui.artist_panel_height = 200 * self.gui.scale
		self.prefs.bio_large = False
		self.update_layout_do()
		if self.gui.artist_info_panel:
			self.artist_info_box.get_data(self.artist_info_box.artist_on)

	def artist_info_panel_close(self) -> None:
		self.gui.artist_info_panel ^= True
		self.gui.update_layout = True

	def toggle_bio_size_deco(self) -> Decorator:
		line = _("Make Large Size")
		if self.prefs.bio_large:
			line = _("Make Compact Size")
		return Decorator(self.colours.menu_text, self.colours.menu_background, line)

	def toggle_bio_size(self) -> None:
		if self.prefs.bio_large:
			self.prefs.bio_large = False
			self.update_layout_do()
			# bio_set_small()
		else:
			self.prefs.bio_large = True
			self.update_layout_do()
			# bio_set_large()
		# self.gui.update_layout = True

	def flush_artist_bio(self, artist: str) -> None:
		if os.path.isfile(os.path.join(self.a_cache_directory, artist + "-lfm.txt")):
			os.remove(os.path.join(self.a_cache_directory, artist + "-lfm.txt"))
		self.artist_info_box.text = ""
		self.artist_info_box.artist_on = None

	def clear_artist_cache(self) -> None:
		if os.path.isdir(self.a_cache_directory):
			for filename in os.listdir(self.a_cache_directory):
				if filename.endswith(("-lfm.txt", "-lfm.png", "-lfm.jpg", "-lfm.webp", "-ftv-full.jpg", "-dcg.jpg")):
					os.remove(os.path.join(self.a_cache_directory, filename))
		artist_pictures_dir = self.user_directory / "artist-pictures"
		if artist_pictures_dir.is_dir():
			for ext in ("png", "jpg", "webp"):
				for f in artist_pictures_dir.glob(f"*.{ext}"):
					f.unlink()
		self.artist_info_box.text = ""
		self.artist_info_box.artist_on = None
		self.artist_info_box.lock = False
		self.artist_info_box.status = ""

	def test_artist_dl(self, _: int) -> bool:
		return not self.prefs.auto_dl_artist_data

	def show_in_playlist(self) -> None:
		if self.prefs.album_mode and self.window_size[0] < 750 * self.gui.scale:
			self.toggle_album_mode()

		self.pctl.playlist_view_position = self.pctl.selected_in_playlist
		logging.debug("Position changed by show in playlist")
		self.gui.shift_selection.clear()
		self.gui.shift_selection.append(self.pctl.selected_in_playlist)
		self.pctl.render_playlist()

	def open_folder_stem(self, path: str) -> None:
		if self.windows:
			line = r'explorer /select,"{}"'.format(path.replace("/", "\\"))
			subprocess.Popen(line)
		else:
			line = path
			line += "/"
			if self.macos:
				subprocess.Popen(["open", line])
			else:
				subprocess.Popen(["xdg-open", line])

	def open_folder_disable_test(self, index: int) -> bool:
		track = self.pctl.master_library[index]
		return track.is_network and not os.path.isdir(track.parent_folder_path)

	def menu_open_folder_disable_test(self, ref: MenuTrackRef) -> bool:
		return self.open_folder_disable_test(ref.track_id)

	def open_folder(self, index: int) -> None:
		track = self.pctl.master_library[index]
		if self.open_folder_disable_test(index):
			self.show_message(_("Can't open folder of a network track."))
			return

		if self.windows:
			line = r'explorer /select,"{}"'.format(track.fullpath.replace("/", "\\"))
			subprocess.Popen(line)
		else:
			line = track.parent_folder_path
			line += "/"
			if self.macos:
				line = track.fullpath
				subprocess.Popen(["open", "-R", line])
			else:
				subprocess.Popen(["xdg-open", line])

	def menu_open_folder(self, ref: MenuTrackRef) -> None:
		self.open_folder(ref.track_id)

	def tag_to_new_playlist(self, tag_item) -> None:
		logging.critical(type(tag_item))
		self.path_stem_to_playlist(tag_item.path, tag_item.name)

	def folder_to_new_playlist_by_track_id(self, track_ref: MenuTrackRef) -> None:
		track = self.pctl.get_track(track_ref.track_id)
		self.path_stem_to_playlist(track.parent_folder_path, track.parent_folder_name)

	def stem_to_new_playlist(self, path: str) -> None:
		self.path_stem_to_playlist(path, os.path.basename(path))

	def move_playing_folder_to_tree_stem(self, path: str) -> None:
		self.move_playing_folder_to_stem(path, pl_id=self.tree_view_box.get_pl_id())

	def move_playing_folder_to_stem(self, path: str, pl_id: int | None = None) -> None:
		if not pl_id:
			pl_id = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].uuid_int

		track = self.pctl.playing_object()

		if not track or self.pctl.playing_state == PlayingState.STOPPED:
			self.show_message(_("No item is currently playing"))
			return

		move_folder = track.parent_folder_path

		# Stop playing track if its in the current folder
		if self.pctl.playing_state != PlayingState.STOPPED and move_folder in self.pctl.playing_object().parent_folder_path:
			self.pctl.stop(True)

		target_base = path

		# Determine name for artist folder
		artist = track.artist
		if track.album_artist:
			artist = track.album_artist

		# Make filename friendly
		artist = filename_safe(artist)
		if not artist:
			artist = "unknown artist"

		# Sanity checks
		if track.is_network:
			self.show_message(_("This track is a networked track."), mode="error")
			return

		if not os.path.isdir(move_folder):
			self.show_message(_("The source folder does not exist."), mode="error")
			return

		if not os.path.isdir(target_base):
			self.show_message(_("The destination folder does not exist."), mode="error")
			return

		if os.path.normpath(target_base) == os.path.normpath(move_folder):
			self.show_message(_("The destination and source folders are the same."), mode="error")
			return

		if len(target_base) < 4:
			self.show_message(_("Safety interrupt! The source path seems oddly short."), target_base, mode="error")
			return

		protect = ("", "Documents", "Music", "Desktop", "Downloads")
		for fo in protect:
			if move_folder.strip("\\/") == os.path.join(os.path.expanduser("~"), fo).strip("\\/"):
				self.show_message(
					_("Better not do anything to that folder!"), os.path.join(os.path.expanduser("~"), fo),
					mode="warning")
				return

		if directory_size(move_folder) > 3000000000:
			self.show_message(_("Folder size safety limit reached! (3GB)"), move_folder, mode="warning")
			return

		# Use target folder if it already is an artist folder
		if os.path.basename(target_base).lower() == artist.lower():
			artist_folder = target_base

		# Make artist folder if it does not exist
		else:
			artist_folder = os.path.join(target_base, artist)
			if not os.path.exists(artist_folder):
				os.makedirs(artist_folder)

		# Remove all tracks with the old paths
		for pl in self.pctl.multi_playlist:
			for i in reversed(range(len(pl.playlist_ids))):
				if self.pctl.get_track(pl.playlist_ids[i]).parent_folder_path == track.parent_folder_path:
					del pl.playlist_ids[i]

		# Find insert location
		pl = self.pctl.multi_playlist[self.pctl.id_to_pl(pl_id)].playlist_ids

		#matches = []
		insert = 0

		for i, item in enumerate(pl):
			if self.pctl.get_track(item).fullpath.startswith(target_base):
				insert = i

		for i, item in enumerate(pl):
			if self.pctl.get_track(item).fullpath.startswith(artist_folder):
				insert = i

		logging.info(f"The folder to be moved is: {move_folder}")
		load_order = LoadClass()
		load_order.target = os.path.join(artist_folder, track.parent_folder_name)
		load_order.playlist = pl_id
		load_order.playlist_position = insert

		logging.info(artist_folder)
		logging.info(os.path.join(artist_folder, track.parent_folder_name))
		self.move_jobs.append(
			(move_folder, os.path.join(artist_folder, track.parent_folder_name), True,
			track.parent_folder_name, load_order))
		self.thread_manager.ready("worker")

	def move_playing_folder_to_tag(self, tag_item) -> None:
		self.move_playing_folder_to_stem(tag_item.path)

	def re_import4(self, track_ref: MenuTrackRef) -> None:
		load_order = LoadClass()
		load_order.playlist_position = track_ref.position
		load_order.replace_stem = True
		load_order.target = self.pctl.get_track(track_ref.track_id).parent_folder_path
		load_order.notify = True
		load_order.playlist = track_ref.playlist_id
		self.load_orders.append(copy.deepcopy(load_order))
		self.show_message(_("Rescanning folder..."), self.pctl.get_track(track_ref.track_id).parent_folder_path, mode="info")

	def re_import3(self, stem) -> None:
		p = None
		for i, id in enumerate(self.pctl.default_playlist):
			if self.pctl.get_track(id).fullpath.startswith(stem + "/"):
				p = i
				break

		load_order = LoadClass()

		if p is not None:
			load_order.playlist_position = p

		load_order.replace_stem = True
		load_order.target = stem
		load_order.notify = True
		load_order.playlist = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].uuid_int
		self.load_orders.append(copy.deepcopy(load_order))
		self.show_message(_("Rescanning folder..."), stem, mode="info")

	def collapse_tree_deco(self) -> Decorator:
		pl_id = self.tree_view_box.get_pl_id()

		if self.tree_view_box.opens.get(pl_id):
			return Decorator(self.colours.menu_text, self.colours.menu_background, None)
		return Decorator(self.colours.menu_text_disabled, self.colours.menu_background, None)

	def collapse_tree(self) -> None:
		self.tree_view_box.collapse_all()

	def lock_folder_tree(self) -> None:
		if self.tree_view_box.lock_pl:
			self.tree_view_box.lock_pl = None
		else:
			self.tree_view_box.lock_pl = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].uuid_int

	def lock_folder_tree_deco(self) -> Decorator:
		if self.tree_view_box.lock_pl:
			return Decorator(self.colours.menu_text, self.colours.menu_background, _("Unlock Panel"))
		return Decorator(self.colours.menu_text, self.colours.menu_background, _("Lock Panel"))

	def finish_current(self) -> None:
		playing_object = self.pctl.playing_object()
		if playing_object is None:
			self.show_message("")
			return

		if not self.pctl.force_queue:
			self.pctl.force_queue.insert(
				0, queue_item_gen(
					playing_object.index,
					self.pctl.playlist_playing_position,
					self.pctl.pl_to_id(self.pctl.active_playlist_playing), QueueType.ALBUM, 1))

	def add_album_to_queue(self, track_id: int, position: int, playlist_id: int) -> None:
		partway = 0
		playing_object = self.pctl.playing_object()
		if not self.pctl.force_queue and playing_object is not None:
			if self.pctl.get_track(track_id).parent_folder_path == playing_object.parent_folder_path:
				partway = 1

		queue_object = queue_item_gen(track_id, position, playlist_id, QueueType.ALBUM, partway)
		self.pctl.force_queue.append(queue_object)
		self.queue_timer_set(queue_object=queue_object)
		if self.prefs.stop_end_queue:
			self.pctl.stop_mode = StopMode.OFF

	def menu_add_album_to_queue(self, ref: MenuTrackRef) -> None:
		self.add_album_to_queue(ref.track_id, ref.position, ref.playlist_id)

	def add_album_to_queue_fc(self, ref: MenuTrackRef) -> None:
		playing_object = self.pctl.playing_object()
		if playing_object is None:
			self.show_message("")
			return

		queue_item = None

		if not self.pctl.force_queue:
			queue_item = queue_item_gen(
				playing_object.index, self.pctl.playlist_playing_position, self.pctl.pl_to_id(self.pctl.active_playlist_playing), QueueType.ALBUM, 1)
			self.pctl.force_queue.insert(0, queue_item)
			self.add_album_to_queue(ref.track_id, ref.position, ref.playlist_id)
			return

		if self.pctl.force_queue[0].album_stage == 1:
			queue_item = queue_item_gen(ref.track_id, ref.position, ref.playlist_id, QueueType.ALBUM, 0)
			self.pctl.force_queue.insert(1, queue_item)
		else:
			p = self.pctl.get_track(ref.track_id).parent_folder_path
			p = ""
			if self.pctl.playing_ready():
				p = self.pctl.playing_object().parent_folder_path

			# TODO(Taiko): fixme for network tracks
			for i, item in enumerate(self.pctl.force_queue):
				if p != self.pctl.get_track(item.track_id).parent_folder_path:
					queue_item = queue_item_gen(
						ref.track_id,
						ref.position,
						ref.playlist_id, QueueType.ALBUM, 0)
					self.pctl.force_queue.insert(i, queue_item)
					break
			else:
				queue_item = queue_item_gen(
					ref.track_id, ref.position, ref.playlist_id, QueueType.ALBUM, 0)
				self.pctl.force_queue.insert(len(self.pctl.force_queue), queue_item)
		if queue_item:
			self.queue_timer_set(queue_object=queue_item)
		if self.prefs.stop_end_queue:
			self.pctl.stop_mode = StopMode.OFF

	def cancel_import(self) -> None:
		if self.transcode_list:
			del self.transcode_list[1:]
			self.gui.tc_cancel = True
		if self.pctl.loading_in_progress:
			self.gui.im_cancel = True
		if self.gui.sync_progress:
			self.gui.stop_sync = True
			self.gui.sync_progress = _("Aborting Sync")

	def toggle_lyrics_show(self, _) -> bool:
		return not self.gui.combo_mode

	def toggle_side_art_deco(self) -> Decorator:
		colour = self.colours.menu_text
		line = _("Hide Metadata Panel") if self.prefs.show_side_lyrics_art_panel else _("Show Metadata Panel")

		if self.gui.combo_mode:
			colour = self.colours.menu_text_disabled
		return Decorator(colour, self.colours.menu_background, line)

	def toggle_lyrics_panel_position_deco(self) -> Decorator:
		colour = self.colours.menu_text
		line = _("Panel Below Lyrics") if self.prefs.lyric_metadata_panel_top else _("Panel Above Lyrics")

		if self.gui.combo_mode or not self.prefs.show_side_lyrics_art_panel:
			colour = self.colours.menu_text_disabled
		return Decorator(colour, self.colours.menu_background, line)

	def toggle_lyrics_panel_position(self) -> None:
		self.prefs.lyric_metadata_panel_top ^= True

	def lyrics_in_side_show(self, _track_object: TrackClass) -> bool:
		return not (self.gui.combo_mode or not self.prefs.show_lyrics_side)

	def toggle_side_art(self) -> None:
		self.prefs.show_side_lyrics_art_panel ^= True

	def toggle_milky_deco(self, _track_object: TrackClass) -> Decorator:
		text = _("Enable Milkdrop Visualiser")
		if self.prefs.milk:
			text = _("Disable Milkdrop Visualiser")
		return Decorator(self.colours.menu_text, self.colours.menu_background, text)

	def toggle_showcase_wide_art_deco(self, _track_object: TrackClass) -> Decorator:
		text = _("Disable Wide Mode") if self.prefs.showcase_wide_art else _("Enable Wide Mode")
		return Decorator(self.colours.menu_text, self.colours.menu_background, text)

	def toggle_showcase_wide_art(self, _track_object: TrackClass) -> None:
		self.prefs.showcase_wide_art ^= True
		self.gui.update = 2

	def showcase_mode_show_test(self, _track_object: TrackClass) -> bool:
		return self.gui.showcase_mode

	def toggle_lyrics_check(self) -> bool:
		if self.gui.combo_mode:
			return self.prefs.show_lyrics_showcase
		return self.prefs.show_lyrics_side

	def toggle_lyrics_deco(self, track_object: TrackClass) -> Decorator:
		text = _("Show Lyrics")
		if not track_object or (track_object.lyrics == "" and not self.timed_lyrics_ren.generate(track_object)):
			text = _("Prefer Lyrics Display")
		return Decorator(self.colours.menu_text, self.colours.menu_background, text)

	def toggle_milky(self, _track_object: TrackClass) -> None:
		if not self.prefs.milk:
			self.milky.projectm.rescan_presets()
			if not self.milky.projectm.presets:
				def download_presets() -> None:
					self.preset_download_box.start(
						title=_("Downloading Milkdrop presets"),
						status=_("Starting download..."),
						cancel_detail=_("Milkdrop will still be enabled."),
					)
					def dl() -> None:
						url = "https://github.com/Taiko2k/Tauon/releases/download/Extras/creamingsodav1.zip"
						assert url
						target_directory = self.user_directory / "presets"
						target_directory.mkdir(parents=True, exist_ok=True)
						logging.info(
							"Preset download starting: url=%s user_directory=%s target_directory=%s dirs=%s",
							url,
							self.user_directory,
							target_directory,
							[str(path) for path in self.milky.projectm.dirs],
						)

						def count_installed_presets() -> int:
							try:
								return sum(1 for path in target_directory.rglob("*.milk") if path.is_file())
							except Exception:
								logging.exception("Failed to count installed Milkdrop presets in %s", target_directory)
								return -1

						def enable_milkdrop(status: str, detail: str = "", toast_text: str | None = None) -> None:
							count_before = len(self.milky.projectm.presets)
							self.prefs.milk = True
							try:
								self.milky.projectm.rescan_presets()
							except Exception:
								logging.exception("Preset rescan failed after download")
								if not detail:
									detail = _("Restart Tauon if presets are not visible yet.")
							logging.info(
								"Preset download enable complete: status=%s detail=%s prefs.milk=%s scanned_before=%s scanned_after=%s installed_count=%s",
								status,
								detail,
								self.prefs.milk,
								count_before,
								len(self.milky.projectm.presets),
								count_installed_presets(),
							)
							self.preset_download_box.complete(status=status, detail=detail, toast_text=toast_text)

						try:
							with io.BytesIO() as buffer:
								with requests.get(url, stream=True, timeout=1800) as response:
									response.raise_for_status()  # fail if download failed
									total_bytes = int(response.headers.get("Content-Length", 0))
									downloaded = 0
									last_logged_mb = -1
									logging.info(
										"Preset download response: status=%s content_length=%s final_url=%s",
										response.status_code,
										total_bytes,
										response.url,
									)

									for data in response.iter_content(chunk_size=65536):
										if self.preset_download_box.cancel_requested:
											logging.info("Preset download cancelled during download after %s bytes", downloaded)
											enable_milkdrop(
												status=_("Preset download cancelled"),
												detail=_("Milkdrop visualiser enabled."),
											)
											return
										if not data:
											continue

										buffer.write(data)
										downloaded += len(data)
										if total_bytes:
											progress = min(0.95, downloaded / total_bytes * 0.95)
											total_mb = total_bytes / 1000 / 1000
											detail = f"{downloaded / 1000 / 1000:.1f} / {total_mb:.1f} MB"
										else:
											progress = None
											detail = f"{downloaded / 1000 / 1000:.1f} MB"
										current_mb = int(downloaded / 1000 / 1000)
										if current_mb != last_logged_mb:
											last_logged_mb = current_mb
											logging.info(
												"Preset download progress: downloaded=%s total=%s detail=%s",
												downloaded,
												total_bytes,
												detail,
											)
										self.preset_download_box.update(progress, _("Downloading preset pack..."), detail)

								buffer.seek(0)
								self.preset_download_box.update(0.95, _("Extracting presets..."))
								logging.info("Preset download complete: bytes=%s target=%s", buffer.getbuffer().nbytes, target_directory)
								with zipfile.ZipFile(buffer) as z:
									members = z.infolist()
									total_members = len(members) or 1
									target_root = target_directory.resolve()
									extracted = 0
									skipped = 0
									logging.info("Preset package opened: members=%s target_root=%s", len(members), target_root)
									for index, member in enumerate(members):
										if self.preset_download_box.cancel_requested:
											logging.info("Preset download cancelled during extraction at index=%s extracted=%s skipped=%s", index, extracted, skipped)
											enable_milkdrop(
												status=_("Preset download cancelled"),
												detail=_("Milkdrop visualiser enabled."),
											)
											return

										member_path = (target_directory / member.filename).resolve()
										if target_root not in (member_path, *member_path.parents):
											raise ValueError(_("Preset package contains an unsafe path"))
										try:
											z.extract(member, target_directory)
											extracted += 1
										except FileNotFoundError:
											if self.windows and len(str(member_path)) >= 240:
												skipped += 1
												logging.warning(
													"Skipping preset package member with too-long Windows path: index=%s name=%r target_length=%s target=%s",
													index,
													member.filename,
													len(str(member_path)),
													member_path,
													exc_info=True,
												)
											else:
												logging.exception(
													"Preset package member extraction failed: index=%s name=%r target_length=%s target=%s",
													index,
													member.filename,
													len(str(member_path)),
													member_path,
												)
												raise
										progress = 0.95 + 0.05 * ((index + 1) / total_members)
										self.preset_download_box.update(progress, _("Extracting presets..."), member.filename)
									logging.info(
										"Preset extraction complete: extracted=%s skipped=%s installed_count=%s target=%s",
										extracted,
										skipped,
										count_installed_presets(),
										target_directory,
									)

							enable_milkdrop(
								status=_("Presets ready"),
								detail=_("Milkdrop visualiser enabled."),
								toast_text=_("Download completed"),
							)
						except Exception as e:
							logging.exception("Preset download failed")
							self.preset_download_box.fail(_("Download failed"), f"{type(e).__name__}: {e}")

					shooter(dl)
				def skip_presets() -> None:
					self.prefs.milk = True
				self.gui.message_box_confirm_callback = download_presets
				self.gui.message_box_no_callback = skip_presets
				self.gui.message_box_confirm_reference = []
				self.show_message(_(
					"No presets loaded. Download preset pack? (~5MB)"),
					mode="confirm")
				return

		self.prefs.milk ^= True

	def toggle_milky_settings(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.milk

		if not self.prefs.milk and not milky_ready:
			self.show_message(_("Not supported on this platform"), mode="error")
			return None

		self.toggle_milky(self.pctl.playing_object())
		return None

	def toggle_milky_auto(self, _track_object: TrackClass) -> None:
		self.milky.projectm.auto_frames = 0
		self.milky.projectm.timer.set()
		self.prefs.auto_milk ^= True

	def toggle_milk_cut_out(self, _track_object: TrackClass) -> None:
		self.prefs.milk_cut_out ^= True
		self.gui.update += 1

	def milk_preset_is_favorite(self) -> bool:
		preset = self.milky.projectm.loaded_preset
		return preset is not None and str(preset) in self.prefs.milk_favorite_presets

	def toggle_milk_preset_favorite(self, _track_object: TrackClass) -> None:
		preset = self.milky.projectm.loaded_preset
		if preset is None:
			return
		key = str(preset)
		if key in self.prefs.milk_favorite_presets:
			self.prefs.milk_favorite_presets.remove(key)
		else:
			self.prefs.milk_favorite_presets.append(key)

	def open_milk_preset_chooser(self, _track_object: TrackClass) -> None:
		self.milk_choose.activate()

	def open_preset_folder(self, _track_object: TrackClass) -> None:
		target = self.user_directory / "presets"
		if not target.exists():
			target.mkdir()
		self.open_file_browser_at(target)

	def open_file_browser_at(self, path) -> None:
		if self.windows:
			line = r'explorer /select,"{}"'.format(str(path).replace("/", "\\"))
			subprocess.Popen(line)
		else:
			line = str(path)
			if self.macos:
				subprocess.Popen(["open", "-R", line])
			else:
				line += "/"
				subprocess.Popen(["xdg-open", line])


	def toggle_lyrics(self, track_object: TrackClass) -> None:
		if not track_object:
			return

		if self.gui.combo_mode:
			self.prefs.show_lyrics_showcase ^= True
			if self.prefs.show_lyrics_showcase and track_object.lyrics == "" and self.timed_lyrics_ren.generate(track_object):
				self.prefs.prefer_synced_lyrics = True
			# if self.prefs.show_lyrics_showcase and track_object.lyrics == "":
			#	 self.show_message("No lyrics for this track")
		else:
			# Handling for alt panel layout
			# if self.prefs.side_panel_layout == 1 and self.prefs.show_side_art:
			#	 #self.prefs.show_side_art = False
			#	 self.prefs.show_lyrics_side = True
			#	 return

			self.prefs.show_lyrics_side ^= True
			if self.prefs.show_lyrics_side and track_object.lyrics == "" and self.timed_lyrics_ren.generate(track_object):
				self.prefs.prefer_synced_lyrics = True
			# if self.prefs.show_lyrics_side and track_object.lyrics == "":
			#	 self.show_message("No lyrics for this track")

	def get_lyric_fire(self, track_object: TrackClass, silent: bool = False) -> str | None:

		def clear_search_cache(track: TrackClass) -> None:
			# clear cached search data (user can search by lyrics immediately)
			self.search_string_cache.pop(track.index, None)
			self.search_dia_string_cache.pop(track.index, None)
			self.search_field_cache.pop(track.index, None)
			self.search_dia_field_cache.pop(track.index, None)

		self.lyrics_ren.lyrics_position = 0

		if not self.prefs.lyrics_enables:
			if not silent:
				self.show_message(
					_("There are no lyric sources enabled."),
					_("See 'lyrics settings' under 'functions' tab in settings."), mode="info")
			self.now_searching = "errored"
			return None

		t = self.lyrics_fetch_timer.get()
		logging.info(f"Lyric rate limit timer is: {t!s} / -60")
		if t < -40:
			logging.info("Lets try again later")
			if not silent:
				self.show_message(_("Let's be polite and try later."))

				if t < -65:
					self.show_message(_("Stop requesting lyrics AAAAAA."), mode="error")

			# If the user keeps pressing, lets mess with them haha
			self.lyrics_fetch_timer.force_set(t - 5)
			self.now_searching = "errored"
			return "later"

		if t > 0:
			self.lyrics_fetch_timer.set()
			t = 0

		self.lyrics_fetch_timer.force_set(t - 10)

		if not silent:
			self.show_message(_("Searching..."))

		s_artist = track_object.artist
		s_title = track_object.title

		if s_artist in self.prefs.lyrics_subs:
			s_artist = self.prefs.lyrics_subs[s_artist]
		if s_title in self.prefs.lyrics_subs:
			s_title = self.prefs.lyrics_subs[s_title]

		logging.info(f"Searching for lyrics: {s_artist} - {s_title}")

		found = False
		for name in self.prefs.lyrics_enables:

			if name in lyric_sources:
				func = lyric_sources[name]

				try:
					lyrics, synced = func(s_artist, s_title, user_agent=self.t_agent)
					if lyrics or synced:
						if lyrics:
							logging.info(f"Found lyrics from {name}")
							track_object.lyrics = lyrics
							clear_search_cache(track_object)
							self.gui.lyrics_editor_update_now[0] = True
							if not self.gui.timed_lyrics_edit_view and self.prefs.save_lyrics_changes_to_files:
								self.write_lyrics(track_object)
						if synced:
							logging.info("Found synced lyrics")
							track_object.synced = synced
							clear_search_cache(track_object)
							self.gui.lyrics_editor_update_now[1] = True
							# TODO (Flynn): SYLT
							if not self.gui.timed_lyrics_edit_view:
								if self.prefs.save_synced_to_lrc:
									self.write_lyrics(track_object, True, synced_target="lrc")
								if self.prefs.save_lyrics_changes_to_files:
									self.write_lyrics(track_object, True, synced_target="tags")
						found = True
						break
				except Exception:
					logging.exception("Failed to find lyrics")

				if not found:
					logging.error(f"Could not find lyrics from source {name}")

		if not found:
			self.now_searching = "errored"
			if not silent:
				self.show_message(_("No lyrics for this track were found"))
		else:
			self.gui.message_box = False
			if not self.gui.showcase_mode:
				self.prefs.show_lyrics_side = True
			self.gui.update += 1
			self.lyrics_ren.lyrics_position = 0
			self.timed_lyrics_ren.index = -1
			self.pctl.notify_database_changed()
			self.now_searching = "success"
		return None

	def get_lyric_wiki(self, track_object: TrackClass) -> None:
		if track_object.artist == "" or track_object.title == "":
			self.show_message(_("Insufficient metadata to get lyrics"), mode="warning")
			return

		self.now_searching = "searching"
		shoot_dl = threading.Thread(target=self.get_lyric_fire, args=([track_object]))
		shoot_dl.daemon = True
		shoot_dl.start()

		logging.info("..Done")

	def get_lyric_wiki_silent(self, track_object: TrackClass) -> None:
		logging.info("Searching for lyrics...")

		if track_object.artist == "" or track_object.title == "":
			return

		shoot_dl = threading.Thread(target=self.get_lyric_fire, args=([track_object, True]))
		shoot_dl.daemon = True
		shoot_dl.start()

		logging.info("..Done")

	def get_bio(self, track_object: TrackClass) -> None:
		if track_object.artist:
			self.lastfm.get_bio(track_object.artist)

	def search_lyrics_deco(self, track_object: TrackClass) -> Decorator:
		line_colour = self.colours.menu_text if not track_object.lyrics else self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, None)

	def toggle_synced_lyrics(self, tr: TrackClass) -> None:
		self.prefs.prefer_synced_lyrics ^= True

	def toggle_synced_lyrics_deco(self, track: TrackClass) -> Decorator:
		text = None
		return Decorator(self.colours.menu_text, self.colours.menu_background, text)

	def paste_lyrics(self, track_object: TrackClass) -> None:
		if sdl3.SDL_HasClipboardText():
			clip = sdl3.SDL_GetClipboardText()
			#logging.info(clip)
			track_object.lyrics = clip.decode("utf-8")
			if self.prefs.save_lyrics_changes_to_files:
				self.write_lyrics(track_object)
			self.lyrics_ren_mini.to_reload = True
		else:
			logging.warning("NO TEXT TO PASTE")

	def copy_lyrics(self, track_object: TrackClass) -> None:
		copy_to_clipboard(track_object.lyrics)

	def clear_lyrics(self, track_object: TrackClass) -> None:
		track_object.lyrics = ""
		if self.prefs.save_lyrics_changes_to_files:
			self.write_lyrics(track_object)
		self.lyrics_ren_mini.to_reload = True

	def track_has_synced_lyrics(self, track_object: TrackClass) -> bool:
		return find_synced_lyric_data(track_object) is not None

	def clear_synced_lyrics_disable_test(self, track_object: TrackClass) -> bool:
		return not self.track_has_synced_lyrics(track_object)

	def clear_synced_lyrics(self, track_object: TrackClass) -> None:
		had_embedded_synced = find_synced_lyric_data(track_object, just_check=True)
		lrc_path: Path | None = None
		had_cached_synced = bool(track_object.synced)
		deleted_lrc = False
		cleared_tags = False
		kept_lrc = False

		if not track_object.is_network:
			direc = Path(track_object.parent_folder_path)
			name = Path(track_object.filename).stem
			if direc.is_dir():
				lrc_path = next(
					(
						f for f in direc.iterdir()
						if f.is_file()
						and f.stem == name
						and f.suffix.lower() == ".lrc"
					),
					None,
				)

		track_object.synced = ""

		if had_embedded_synced:
			track_object.lyrics = ""
			if self.prefs.save_lyrics_changes_to_files:
				self.write_lyrics(track_object)
				cleared_tags = True

		if lrc_path and lrc_path.is_file():
			if self.prefs.save_synced_to_lrc:
				try:
					lrc_path.unlink()
					deleted_lrc = True
				except Exception:
					logging.exception("Failed to delete synced lyrics file")
					self.show_message(
						_("Could not clear synced lyrics"),
						_("The .lrc file could not be deleted."),
						mode="error",
					)
					return
			else:
				kept_lrc = True

		self.lyrics_ren_mini.to_reload = True
		if deleted_lrc and cleared_tags:
			self.show_message(_("Cleared synced lyrics"), _("Removed from .lrc and tags"), mode="done")
		elif deleted_lrc:
			self.show_message(_("Cleared synced lyrics"), _("Removed from .lrc"), mode="done")
		elif cleared_tags:
			self.show_message(_("Cleared synced lyrics"), _("Removed from tags"), mode="done")
		elif kept_lrc:
			self.show_message(
				_("Cleared synced lyrics in Tauon only"),
				_("The .lrc file was kept because \"Save Synced to .lrc\" is off."),
				mode="done",
			)
		elif had_embedded_synced or had_cached_synced:
			self.show_message(_("Cleared synced lyrics in Tauon only"), mode="done")
		else:
			self.show_message(_("No synced lyrics were cleared"), mode="info")

	def split_lyrics(self, track_object: TrackClass) -> None:
		if track_object.lyrics:
			track_object.lyrics = track_object.lyrics.replace(". ", ". \n")
			if self.prefs.save_lyrics_changes_to_files:
				self.write_lyrics(track_object)
			self.lyrics_ren_mini.to_reload = True

	def paste_lyrics_deco(self) -> Decorator:
		line_colour = self.colours.menu_text if sdl3.SDL_HasClipboardText() else self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, None)

	def chord_lyrics_paste_show_test(self, _) -> bool:
		return self.gui.combo_mode and self.prefs.guitar_chords

	def copy_lyrics_deco(self, track_object: TrackClass) -> Decorator:
		line_colour = self.colours.menu_text if track_object.lyrics else self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, None)

	def clear_lyrics_deco(self, track_object: TrackClass) -> Decorator:
		line_colour = self.colours.menu_text if track_object.lyrics else self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, None)

	def edit_lyrics_deco(self, _track_object: TrackClass) -> Decorator:
		line_colour = self.colours.menu_text# if track_object.lyrics else self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, None)


	def write_lyrics(
		self,
		track: TrackClass,
		synced: bool = False,
		loud: bool = False,
		synced_target: Literal["auto", "lrc", "tags"] = "auto",
	) -> bool:
		lyrics = track.synced if synced else track.lyrics
		if track.is_network or not track.fullpath:
			logging.warning(f"Cannot write lyrics to network track {track.artist} - {track.title}")
			return False
		write_synced_to_lrc = synced and (
			synced_target == "lrc" or (synced_target == "auto" and self.prefs.save_synced_to_lrc)
		)
		if write_synced_to_lrc:
			with open( Path(track.fullpath).with_suffix(".lrc"), "w", encoding="utf-8") as lrc:
				lrc.write( lyrics )
				logging.info(f"Edited the LRC file for {track.artist} - {track.title}")
				return True
		# fully stop and resume track to prevent severe bug when simultaneously reading and modifying
		stop = track.index == self.pctl.track_queue[self.pctl.queue_step]
		resume = stop and self.pctl.playing_state == PlayingState.PLAYING
		try:
			if track.file_ext == "MP3":
				audio = mutagen.id3.ID3(track.fullpath)
				if audio.getall("USLT"):
					audio.getall("USLT")[0].text = lyrics
					audio.getall("USLT")[0].encoding = 3
				else:
					audio.add( mutagen.id3.USLT( encoding=3,text=lyrics ) )
			elif track.file_ext == "FLAC":
				audio = mutagen.flac.FLAC(track.fullpath)
				audio["SYNCEDLYRICS" if synced else "UNSYNCEDLYRICS"] = lyrics
			elif track.file_ext == "OPUS":
				audio = mutagen.oggopus.OggOpus(track.fullpath)
				audio["SYNCEDLYRICS" if synced else "UNSYNCEDLYRICS"] = lyrics
			elif track.file_ext == "OGG":
				audio = mutagen.oggvorbis.OggVorbis(track.fullpath)
				audio["SYNCEDLYRICS" if synced else "UNSYNCEDLYRICS"] = lyrics
			elif track.file_ext in ("APE","WV","TTA"):
				audio = mutagen.apev2.APEv2(track.fullpath)
				audio["Lyrics"] = lyrics
			elif track.file_ext in ("MP4","M4A","M4B","M4P"):
				audio = mutagen.mp4.MP4(track.fullpath)
				audio["\xa9lyr"] = lyrics
			else:
				if loud:
					self.show_message(
						_("Could not write lyrics to file"),
						_("We don't know how to write lyrics to the filetype: ") + track.file_ext,
						_("(We still saved to the internal database)"),
						mode="error"
					)
				return False

			if stop:
				self.pctl.jump_time = self.pctl.decode_time
				self.pctl.stop(block=True)
			try:
				lyrics.encode('latin1')
				audio.save()
			except UnicodeEncodeError:
				audio.save(v1=0)
			logging.info(f"Edited lyrics in the file for {track.artist} - {track.title}")
			if resume:
				self.pctl.play()

		except Exception:
			logging.exception("Could not write lyrics to file")
			if loud:
				self.show_message(
					_("Could not write lyrics to file"),
					_("File doesn't exist or is not accessible"),
					_("(We still saved to the internal database)"),
					mode="error"
				)
			return False
		self.lyrics_ren_mini.to_reload = True
		return True
		# TODO: add more formats


	def show_sub_search(self, track_object: TrackClass) -> None:
		self.sub_lyrics_box.activate(track_object)

	def save_embed_img_disable_test(self, track_object: TrackClass) -> bool:
		return track_object.is_network

	def menu_save_embed_img_disable_test(self, ref: MenuTrackRef) -> bool:
		return self.save_embed_img_disable_test(self.pctl.get_track(ref.track_id))

	def save_embed_img(self, track_object: TrackClass) -> None:
		filepath = track_object.fullpath
		folder = track_object.parent_folder_path
		ext = track_object.file_ext

		if self.save_embed_img_disable_test(track_object):
			self.show_message(_("Saving network images not implemented"))
			return

		try:
			pic = self.album_art_gen.get_embed(track_object)

			if not pic:
				self.show_message(_("Image save error."), _("No embedded album art found in file."), mode="warning")
				return

			source_image = io.BytesIO(pic)
			im = Image.open(source_image)

			source_image.close()

			ext = "." + im.format.lower()
			if im.format == "JPEG":
				ext = ".jpg"

			target = os.path.join(folder, "embed-" + str(im.height) + "px-" + str(track_object.index) + ext)

			if len(pic) > 30:
				with open(target, "wb") as w:
					w.write(pic)

			self.open_folder(track_object.index)

		except Exception:
			logging.exception("Unknown error trying to save an image")
			self.show_message(_("Image save error."), _("A mysterious error occurred"), mode="error")

	def menu_save_embed_img(self, ref: MenuTrackRef) -> None:
		self.save_embed_img(self.pctl.get_track(ref.track_id))

	def open_image_deco(self, track_object: TrackClass)-> Decorator:
		info = self.album_art_gen.get_info(track_object)

		if info is None:
			return Decorator(self.colours.menu_text_disabled, self.colours.menu_background, None)

		line_colour = self.colours.menu_text
		return Decorator(line_colour, self.colours.menu_background, None)

	def menu_open_image_deco(self, ref: MenuTrackRef) -> Decorator:
		return self.open_image_deco(self.pctl.get_track(ref.track_id))

	def open_image_disable_test(self, track_object: TrackClass) -> bool:
		return track_object.is_network

	def menu_open_image_disable_test(self, ref: MenuTrackRef) -> bool:
		return self.open_image_disable_test(self.pctl.get_track(ref.track_id))

	def open_image(self, track_object: TrackClass) -> None:
		self.album_art_gen.open_external(track_object)

	def menu_open_image(self, ref: MenuTrackRef) -> None:
		self.open_image(self.pctl.get_track(ref.track_id))

	def extract_image_deco(self, track_object: TrackClass) -> Decorator:
		info = self.album_art_gen.get_info(track_object)

		if info is None:
			return Decorator(self.colours.menu_text_disabled, self.colours.menu_background, None)

		line_colour = self.colours.menu_text if info[0] == 1 else self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, None)

	def menu_extract_image_deco(self, ref: MenuTrackRef) -> Decorator:
		return self.extract_image_deco(self.pctl.get_track(ref.track_id))

	def cycle_image_deco(self, track_object: TrackClass) -> Decorator:
		info = self.album_art_gen.get_info(track_object)

		if self.pctl.playing_state != PlayingState.STOPPED and (info is not None and info[1] > 1):
			line_colour = self.colours.menu_text
		else:
			line_colour = self.colours.menu_text_disabled

		return Decorator(line_colour, self.colours.menu_background, None)

	def cycle_image_gal_deco(self, ref: MenuTrackRef) -> Decorator:
		info = self.album_art_gen.get_info(self.pctl.get_track(ref.track_id))

		line_colour = self.colours.menu_text if info is not None and info[1] > 1 else self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, None)

	def cycle_offset(self, track_object: TrackClass) -> None:
		self.album_art_gen.cycle_offset(track_object)

	def menu_cycle_offset(self, ref: MenuTrackRef) -> None:
		self.cycle_offset(self.pctl.get_track(ref.track_id))

	def cycle_offset_back(self, ref: MenuTrackRef) -> None:
		self.album_art_gen.cycle_offset_reverse(self.pctl.get_track(ref.track_id))

	def dl_art_deco(self, track_object: TrackClass) -> Decorator:
		if not track_object.album or not track_object.artist:
			return Decorator(self.colours.menu_text_disabled, self.colours.menu_background, None)
		return Decorator(self.colours.menu_text, self.colours.menu_background, None)

	def menu_dl_art_deco(self, ref: MenuTrackRef) -> Decorator:
		return self.dl_art_deco(self.pctl.get_track(ref.track_id))

	def download_art1(self, tr: TrackClass) -> None:
		if tr.is_network:
			self.show_message(_("Cannot download art for network tracks."))
			return

		# Determine noise of folder ----------------
		siblings: list[TrackClass] = []
		parent = tr.parent_folder_path

		for pl in self.pctl.multi_playlist:
			for ti in pl.playlist_ids:
				tr = self.pctl.get_track(ti)
				if tr.parent_folder_path == parent:
					siblings.append(tr)

		album_tags: list[str] | set[str] = []
		date_tags:  list[str] | set[str] = []

		for tr in siblings:
			album_tags.append(tr.album)
			date_tags.append(tr.date)

		album_tags = set(album_tags)
		date_tags = set(date_tags)

		if len(album_tags) > 2 or len(date_tags) > 2:
			self.show_message(_("It doesn't look like this folder belongs to a single album, sorry"))
			return

		# -------------------------------------------

		if not os.path.isdir(tr.parent_folder_path):
			self.show_message(_("Directory missing."))
			return

		try:
			self.show_message(_("Looking up MusicBrainz ID..."))

			if tr.musicbrainz_releasegroupid is None or tr.musicbrainz_artistids is None or not tr.musicbrainz_artistids:

				logging.info("MusicBrainz ID lookup...")

				artist = tr.album_artist
				if not tr.album:
					return
				if not artist:
					artist = tr.artist

				s = musicbrainzngs.search_release_groups(tr.album, artist=artist, limit=1)

				album_id = s["release-group-list"][0]["id"]
				artist_id = s["release-group-list"][0]["artist-credit"][0]["artist"]["id"]

				logging.info(f"Found release group ID: {album_id}")
				logging.info(f"Found artist ID: {artist_id}")
			else:
				album_id = tr.musicbrainz_releasegroupid
				artist_id = tr.musicbrainz_artistids[0]

				logging.info(f"Using tagged release group ID: {album_id}")
				logging.info(f"Using tagged artist ID: {artist_id}")

			if self.prefs.enable_fanart_cover:
				try:
					self.show_message(_("Searching fanart.tv for cover art..."))

					r = requests.get("https://webservice.fanart.tv/v3/music/albums/" \
						+ artist_id + "?api_key=" + self.prefs.fatvap, timeout=(4, 10))

					artlink = r.json()["albums"][album_id]["albumcover"][0]["url"]
					id = r.json()["albums"][album_id]["albumcover"][0]["id"]

					response = urllib.request.urlopen(artlink, context=self.tls_context)
					info = response.info()

					t = io.BytesIO()
					t.seek(0)
					t.write(response.read())
					t.seek(0, 2)
					buffer_size = t.tell()
					t.seek(0)

					if info.get_content_maintype() == "image" and buffer_size > 1000:
						if info.get_content_subtype() == "jpeg":
							filepath = os.path.join(tr.parent_folder_path, "cover-" + id + ".jpg")
						elif info.get_content_subtype() == "png":
							filepath = os.path.join(tr.parent_folder_path, "cover-" + id + ".png")
						else:
							self.show_message(_("Could not detect downloaded filetype."), mode="error")
							return

						with open(filepath, "wb") as f:
							f.write(t.read())

						self.show_message(_("Cover art downloaded from fanart.tv"), mode="done")
						# self.clear_img_cache()
						for track_id in self.pctl.default_playlist:
							if tr.parent_folder_path == self.pctl.get_track(track_id).parent_folder_path:
								self.clear_track_image_cache(self.pctl.get_track(track_id))
						return
				except Exception:
					logging.exception("Failed to get from fanart.tv")

			self.show_message(_("Searching MusicBrainz for cover art..."))
			t = io.BytesIO(musicbrainzngs.get_release_group_image_front(album_id, size=None))
			buffer_size = 0
			t.seek(0, 2)
			buffer_size = t.tell()
			t.seek(0)
			if buffer_size > 1000:
				filepath = os.path.join(tr.parent_folder_path, album_id + ".jpg")
				f = open(filepath, "wb")
				f.write(t.read())
				f.close()

				self.show_message(_("Cover art downloaded from MusicBrainz"), mode="done")
				# self.clear_img_cache()
				self.clear_track_image_cache(tr)

				for track_id in self.pctl.default_playlist:
					if tr.parent_folder_path == self.pctl.get_track(track_id).parent_folder_path:
						self.clear_track_image_cache(self.pctl.get_track(track_id))

				return

		except Exception:
			logging.exception("Matching cover art or ID could not be found.")
			self.show_message(_("Matching cover art or ID could not be found."))

	def download_art1_fire_disable_test(self, track_object: TrackClass) -> bool:
		return track_object.is_network

	def menu_download_art1_fire_disable_test(self, ref: MenuTrackRef) -> bool:
		return self.download_art1_fire_disable_test(self.pctl.get_track(ref.track_id))

	def download_art1_fire(self, track_object: TrackClass) -> None:
		shoot_dl = threading.Thread(target=self.download_art1, args=[track_object])
		shoot_dl.daemon = True
		shoot_dl.start()

	def menu_download_art1_fire(self, ref: MenuTrackRef) -> None:
		self.download_art1_fire(self.pctl.get_track(ref.track_id))

	def remove_embed_picture(self, track_object: TrackClass, dry: bool = True) -> int | None:
		"""Return amount of removed objects or None"""
		index = track_object.index

		if self.inp.key_shift_down or self.inp.key_shiftr_down:
			tracks = [index]
			if track_object.is_cue or track_object.is_network:
				self.show_message(_("Error - No handling for this kind of track"), mode="warning")
				return None
		else:
			tracks: list[int] = []
			original_parent_folder = track_object.parent_folder_name
			for k in self.pctl.default_playlist:
				tr = self.pctl.get_track(k)
				if original_parent_folder == tr.parent_folder_name:
					tracks.append(k)

		removed = 0
		if not dry:
			pr = self.pctl.stop(True)
		try:
			for item in tracks:
				tr =self. pctl.get_track(item)

				if tr.is_cue:
					continue

				if tr.is_network:
					continue

				if dry:
					removed += 1
				else:
					if tr.file_ext == "MP3":
						try:
							tag = mutagen.id3.ID3(tr.fullpath)
							tag.delall("APIC")
							tag.save(padding=no_padding)
							removed += 1
						except Exception:
							logging.exception("No MP3 APIC found")

					if tr.file_ext == "M4A":
						try:
							tag = mutagen.mp4.MP4(tr.fullpath)
							del tag.tags["covr"]
							tag.save(padding=no_padding)
							removed += 1
						except Exception:
							logging.exception("No m4A covr tag found")

					if tr.file_ext in ("OGA", "OPUS", "OGG"):
						self.show_message(_("Removing vorbis image not implemented"))
						# try:
						#	 tag = mutagen.File(tr.fullpath).tags
						#	 logging.info(tag)
						#	 removed += 1
						# except Exception:
						#	 logging.exception("Failed to manipulate tags")

					if tr.file_ext == "FLAC":
						try:
							tag = mutagen.flac.FLAC(tr.fullpath)
							tag.clear_pictures()
							tag.save(padding=no_padding)
							removed += 1
						except Exception:
							logging.exception("Failed to save tags on FLAC")

					self.clear_track_image_cache(tr)

		except Exception:
			logging.exception("Image remove error")
			self.show_message(_("Image remove error"), mode="error")
			return None

		if dry:
			return removed

		if removed == 0:
			self.show_message(_("Image removal failed."), mode="error")
			return None
		if removed == 1:
			self.show_message(_("Deleted embedded picture from file"), mode="done")
		else:
			self.show_message(_("{N} files processed").local(N=removed), mode="done")
		if pr == 1:
			self.pctl.revert()
		return None

	def delete_file_image(self, track_object: TrackClass) -> None:
		try:
			showc = self.album_art_gen.get_info(track_object)
			if showc is not None and showc[0] == 0:
				source = self.album_art_gen.get_sources(track_object)[showc[2]][1]
				os.remove(source)
				# self.clear_img_cache()
				self.clear_track_image_cache(track_object)
				logging.info(f"Deleted file: {source}")
		except Exception:
			logging.exception("Failed to delete file")
			self.show_message(_("Something went wrong"), mode="error")

	def delete_track_image_deco(self, track_object: TrackClass) -> Decorator:
		info = self.album_art_gen.get_info(track_object)

		text = _("Delete Image File")
		line_colour = self.colours.menu_text

		if info is None or track_object.is_network:
			return Decorator(self.colours.menu_text_disabled, self.colours.menu_background, None)

		if info and info[0] == 0:
			text = _("Delete Image File")

		elif info and info[0] == 1:
			if self.pctl.playing_state != PlayingState.STOPPED and track_object.file_ext in ("MP3", "FLAC", "M4A"):
				line_colour = self.colours.menu_text
			else:
				line_colour = self.colours.menu_text_disabled

			text = _("Delete Embedded | Folder")
			if self.inp.key_shift_down or self.inp.key_shiftr_down:
				text = _("Delete Embedded | Track")
		return Decorator(line_colour, self.colours.menu_background, text)

	def menu_delete_track_image_deco(self, ref: MenuTrackRef) -> Decorator:
		return self.delete_track_image_deco(self.pctl.get_track(ref.track_id))

	def delete_track_image(self, track_object: TrackClass) -> None:
		if track_object.is_network:
			return
		info = self.album_art_gen.get_info(track_object)
		if info and info[0] == 0:
			self.delete_file_image(track_object)
		elif info and info[0] == 1:
			n = self.remove_embed_picture(track_object, dry=True)
			self.gui.message_box_confirm_callback = self.remove_embed_picture
			self.gui.message_box_no_callback = None
			self.gui.message_box_confirm_reference = (track_object, False)
			self.show_message(_("This will erase any embedded image in {N} files. Are you sure?").format(N=n), mode="confirm")

	def menu_delete_track_image(self, ref: MenuTrackRef) -> None:
		self.delete_track_image(self.pctl.get_track(ref.track_id))

	def search_image_deco(self, track_object: TrackClass) -> Decorator:
		if track_object.artist and track_object.album:
			line_colour = self.colours.menu_text
		else:
			line_colour = self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, None)

	def append_here(self) -> None:
		self.pctl.default_playlist += self.pctl.cargo

	def paste_deco(self) -> Decorator:
		active = False
		line = None
		if len(self.pctl.cargo) > 0:
			active = True
		elif sdl3.SDL_HasClipboardText():
			text = copy_from_clipboard()
			if text.startswith("/") or "file://" in text:
				active = True

		line_colour = self.colours.menu_text if active else self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, line)

	def lightning_move_test(self, _: int) -> bool:
		return self.gui.lightning_copy and self.prefs.show_transfer

	# def copy_deco(self) -> Decorator:
	#	 line = "Copy"
	#	 if self.inp.key_shift_down:
	#		 line = "Copy" #Folder From Library"
	#	 else:
	#		 line = "Copy"
	#	 return Decorator(self.colours.menu_text, self.colours.menu_background, line)

	def export_m3u(self, pl: int, pl_file: Path | None = None, relative: bool = False) -> int | Path:
		"""Exports an m3u file from a Playlist dictionary in multi_playlist to a playlist file denoted by pl_file.
		pl_file is normalized by run_export; you should not call this function directly if you are uncertain.
		"""
		if len(self.pctl.multi_playlist[pl].playlist_ids) < 1:
			self.show_message(_("There are no tracks in this playlist. Nothing to export"))
			return 1
		if relative:
			for num in set( self.pctl.multi_playlist[pl].playlist_ids ):
				track = self.pctl.master_library[num]
				if not track.is_network:
					try:
						Path( track.fullpath ).relative_to( pl_file.parent, walk_up=True )
					except Exception:
						logging.exception("Unknown exception, probably tried to use relative paths")
						self.show_message(
							_("Cannot use relative paths"),
							_("One or more tracks are stored on a separate drive from the playlist file."),
							mode="error",
						)
						return 1

		with pl_file.open(mode = "w", encoding="utf-8") as f:
			f.write("#EXTM3U")
			for number in self.pctl.multi_playlist[pl].playlist_ids:
				track = self.pctl.master_library[number]
				title = track.artist
				if title:
					title += " - "
				title += track.title

				if not track.is_network:
					f.write("\n#EXTINF:")
					f.write(str(round(track.length)))
					if title:
						f.write(f",{title}")
					path = Path( track.fullpath )
					if relative:
						path = path.relative_to( pl_file.parent, walk_up=True)
					path = str( path )
					f.write(f"\n{path}")

		return pl_file


	def export_xspf(self, pl: int, pl_file: Path | None = None, relative: bool = False) -> int | Path:
		"""Exports an xspf file from a Playlist dictionary in multi_playlist to a playlist file denoted by pl_file.
		pl_file is normalized by run_export; you should not call this function directly if you are uncertain.
		"""
		if len(self.pctl.multi_playlist[pl].playlist_ids) < 1:
			self.show_message(_("There are no tracks in this playlist. Nothing to export"))
			return 1
		if relative:
			for num in set( self.pctl.multi_playlist[pl].playlist_ids ):
				track = self.pctl.master_library[num]
				if not track.is_network:
					try:
						Path( track.fullpath ).relative_to( pl_file.parent, walk_up=True )
					except Exception:
						logging.exception("Unknown exception, probably tried to use relative paths")
						self.show_message(
							_("Cannot use relative paths"),
							_("One or more tracks are stored on a separate drive from the playlist file."),
							mode="error",
						)
						return 1

		xspf_root = ET.Element("playlist", version="1", xmlns="http://xspf.org/ns/0/")
		xspf_tracklist_tag = ET.SubElement(xspf_root, "trackList")

		for number in self.pctl.multi_playlist[pl].playlist_ids:
			track = self.pctl.master_library[number]
			try:
				path = track.fullpath
			except Exception:
				logging.exception("Unknown exception getting track fullpath")
				continue
			if relative:
				path = Path( track.fullpath ).relative_to( pl_file.parent, walk_up=True )

			xspf_track_tag = ET.SubElement(xspf_tracklist_tag, "track")
			if track.title:
				ET.SubElement(xspf_track_tag, "title").text = track.title
			if track.is_cue is False and track.fullpath:
				ET.SubElement(xspf_track_tag, "location").text = urllib.parse.quote(str(path))
			if track.artist:
				ET.SubElement(xspf_track_tag, "creator").text = track.artist
			if track.album:
				ET.SubElement(xspf_track_tag, "album").text = track.album
			if track.track_number:
				ET.SubElement(xspf_track_tag, "trackNum").text = str(track.track_number)

			ET.SubElement(xspf_track_tag, "duration").text = str(int(track.length * 1000))

		xspf_tree = ET.ElementTree(xspf_root)
		ET.indent(xspf_tree, space="  ", level=0)

		xspf_tree.write( str(pl_file) , encoding="UTF-8", xml_declaration=True)

		return pl_file

	def reload(self) -> None:
		if self.prefs.album_mode:
			self.reload_albums(quiet=True)

		# self.tree_view_box.clear_all()
		# elif self.gui.combo_mode:
		#	 self.reload_albums(quiet=True)
		#	 self.combo_pl_render.prep()

	def clear_playlist(self, index: int) -> None:
		if self.pl_is_locked(index):
			self.show_message(_("Playlist is locked to prevent accidental erasure"))
			return

		self.pctl.multi_playlist[index].last_folder.clear()  # clear import folder list

		if not self.pctl.multi_playlist[index].playlist_ids:
			logging.info("Playlist is already empty")
			return

		li: list[tuple[int, int]] = []
		for i, ref in enumerate(self.pctl.multi_playlist[index].playlist_ids):
			li.append((i, ref))

		self.undo.bk_tracks(index, list(reversed(li)))

		del self.pctl.multi_playlist[index].playlist_ids[:]
		if self.pctl.active_playlist_viewing == index:
			self.pctl.default_playlist = self.pctl.multi_playlist[index].playlist_ids
			self.reload()

		# self.pctl.playlist_playing = 0
		self.pctl.multi_playlist[index].position = 0
		if index == self.pctl.active_playlist_viewing:
			self.pctl.playlist_view_position = 0

		self.gui.pl_update = 1

	def clear_current_playlist(self) -> None:
		self.clear_playlist(self.pctl.active_playlist_viewing)

	def import_folder_to_current_playlist(self, path: str) -> None:
		pl = self.pctl.active_playlist_viewing
		if self.pl_is_locked(pl):
			self.show_message(_("Playlist is locked"))
			return

		target = path.replace("\\", "/")
		if not os.path.isdir(target):
			self.show_message(_("Folder not found"), mode="warning")
			return

		load_order = LoadClass()
		load_order.target = target
		load_order.playlist = self.pctl.multi_playlist[pl].uuid_int

		self.quick_import_done.append(load_order.target)
		self.pctl.multi_playlist[pl].last_folder.append(load_order.target)
		reduce_paths(self.pctl.multi_playlist[pl].last_folder)

		self.load_orders.append(copy.deepcopy(load_order))
		self.gui.update += 1

	def convert_playlist(self, pl: int, get_list: bool = False) -> list[list[int]] | None:
		if not self.test_ffmpeg():
			return None

		paths: list[str] = []
		folders: list[list[int]] = []

		for track in self.pctl.multi_playlist[pl].playlist_ids:
			if self.pctl.master_library[track].parent_folder_path not in paths:
				paths.append(self.pctl.master_library[track].parent_folder_path)

		for path in paths:
			folder: list[int] = []
			for track in self.pctl.multi_playlist[pl].playlist_ids:
				if self.pctl.master_library[track].parent_folder_path == path:
					folder.append(track)
					if self.prefs.transcode_codec == "flac" and self.pctl.master_library[track].file_ext.lower() in (
						"mp3", "opus",
						"m4a", "mp4",
						"ogg", "aac"):
						self.show_message(_("This includes the conversion of a lossy codec to a lossless one!"))

			folders.append(folder)

		if get_list:
			return folders

		self.transcode_list.extend(folders)
		return None

	def get_folder_tracks_local(self, pl_in: int) -> list[int]:
		selection: list[int] = []
		parent = os.path.normpath(self.pctl.master_library[self.pctl.default_playlist[pl_in]].parent_folder_path)
		while pl_in < len(self.pctl.default_playlist) and parent == os.path.normpath(
				self.pctl.master_library[self.pctl.default_playlist[pl_in]].parent_folder_path):
			selection.append(pl_in)
			pl_in += 1
		return selection

	def test_pl_tab_locked(self, pl: int) -> bool:
		if self.gui.radio_view:
			return False
		return self.pctl.multi_playlist[pl].locked

	def rescan_tags(self, pl: int) -> None:
		for track in self.pctl.multi_playlist[pl].playlist_ids:
			if self.pctl.master_library[track].is_cue is False:
				self.to_scan.append(track)
		self.thread_manager.ready("worker")

	def append_playlist(self, index: int) -> None:
		self.pctl.multi_playlist[index].playlist_ids += self.pctl.cargo

		self.gui.pl_update = 1
		self.reload()

	#def sort_track_numbers_album_only(self, pl: int, custom_list: list[int] | None = None):
	#	current_folder = ""
	#	albums = []
	#	playlist = self.pctl.multi_playlist[pl].playlist_ids if custom_list is None else custom_list
	#
	#	for i in range(len(playlist)):
	#		if i == 0:
	#			albums.append(i)
	#			current_folder = self.pctl.master_library[playlist[i]].album
	#		elif self.pctl.master_library[playlist[i]].album != current_folder:
	#			current_folder = self.pctl.master_library[playlist[i]].album
	#			albums.append(i)
	#
	#	i = 0
	#	while i < len(albums) - 1:
	#		playlist[albums[i]:albums[i + 1]] = sorted(playlist[albums[i]:albums[i + 1]], key=self.pctl.index_key)
	#		i += 1
	#	if len(albums) > 0:
	#		playlist[albums[i]:] = sorted(playlist[albums[i]:], key=self.pctl.index_key)
	#
	#	gui.pl_update += 1

	def append_current_playing(self, index: int) -> None:
		if self.pctl.playing_state != PlayingState.STOPPED and len(self.pctl.track_queue) > 0:
			self.pctl.multi_playlist[index].playlist_ids.append(self.pctl.track_queue[self.pctl.queue_step])
			self.gui.pl_update = 1

	def export_stats(self, pl: int) -> None:
		playlist_time = 0
		play_time = 0
		total_size = 0
		tracks_in_playlist = len(self.pctl.multi_playlist[pl].playlist_ids)

		seen_files = {}
		seen_types = {}

		mp3_bitrates = {}
		ogg_bitrates = {}
		m4a_bitrates = {}

		are_cue = 0

		for index in self.pctl.multi_playlist[pl].playlist_ids:
			track = self.pctl.get_track(index)

			playlist_time += int(track.length)
			play_time += self.star_store.get(index)

			if track.is_cue:
				are_cue += 1

			if track.file_ext == "MP3":
				mp3_bitrates[track.bitrate] = mp3_bitrates.get(track.bitrate, 0) + 1
			if track.file_ext in ("OGG", "OGA"):
				ogg_bitrates[track.bitrate] = ogg_bitrates.get(track.bitrate, 0) + 1
			if track.file_ext == "M4A":
				m4a_bitrates[track.bitrate] = m4a_bitrates.get(track.bitrate, 0) + 1

			file_type = track.file_ext
			if file_type == "OGA":
				file_type = "OGG"
			seen_types[file_type] = seen_types.get(file_type, 0) + 1

			if track.fullpath and not track.is_network and track.fullpath not in seen_files:
				size = track.size
				if not size and os.path.isfile(track.fullpath):
					size = os.path.getsize(track.fullpath)
				seen_files[track.fullpath] = size

		total_size = sum(seen_files.values())

		self.stats_gen.update(pl)
		line = _("Playlist:") + "\n" + self.pctl.multi_playlist[pl].title + "\n\n"
		line += _("Generated:") + "\n" + time.strftime("%c") + "\n\n"
		line += _("Tracks in playlist:") + "\n" + str(tracks_in_playlist)
		line += "\n\n"
		line += _("Repeats in playlist:") + "\n"
		unique = len(set(self.pctl.multi_playlist[pl].playlist_ids))
		line += str(tracks_in_playlist - unique)
		line += "\n\n"
		line += _("Total local size:") + "\n" + get_filesize_string(total_size) + "\n\n"
		line += _("Playlist duration:") + "\n" + str(datetime.timedelta(seconds=int(playlist_time))) + "\n\n"
		line += _("Total playtime:") + "\n" + str(datetime.timedelta(seconds=int(play_time))) + "\n\n"

		line += _("Track types:") + "\n"
		if tracks_in_playlist:
			types = sorted(seen_types, key=seen_types.get, reverse=True)
			for track_type in types:
				perc = round((seen_types.get(track_type) / tracks_in_playlist) * 100, 1)
				if perc < 0.1:
					perc = "<0.1"
				if track_type == "SPOT":
					track_type = "SPOTIFY"
				if track_type == "SUB":
					track_type = "AIRSONIC"
				line += f"{track_type} ({perc}%); "
		line = line.rstrip("; ")
		line += "\n\n"

		if tracks_in_playlist:
			line += _("Percent of tracks are CUE type:") + "\n"
			perc = are_cue / tracks_in_playlist
			if perc == 0:
				perc = 0
			perc = "<0.01" if 0 < perc < 0.01 else round(perc, 2)

			line += str(perc) + "%"
			line += "\n\n"

		if tracks_in_playlist and mp3_bitrates:
			line += _("MP3 bitrates (kbps):") + "\n"
			rates = sorted(mp3_bitrates, key=mp3_bitrates.get, reverse=True)
			others = 0
			for rate in rates:
				perc = round((mp3_bitrates.get(rate) / sum(mp3_bitrates.values())) * 100, 1)
				if perc < 1:
					others += perc
				else:
					line += f"{rate} ({perc}%); "

			if others:
				others = round(others, 1)
				if others < 0.1:
					others = "<0.1"
				line += _("Others") + f"({others}%);"
			line = line.rstrip("; ")
			line += "\n\n"

		if tracks_in_playlist and ogg_bitrates:
			line += _("OGG bitrates (kbps):") + "\n"
			rates = sorted(ogg_bitrates, key=ogg_bitrates.get, reverse=True)
			others = 0
			for rate in rates:
				perc = round((ogg_bitrates.get(rate) / sum(ogg_bitrates.values())) * 100, 1)
				if perc < 1:
					others += perc
				else:
					line += f"{rate} ({perc}%); "

			if others:
				others = round(others, 1)
				if others < 0.1:
					others = "<0.1"
				line += _("Others") + f"({others}%);"
			line = line.rstrip("; ")
			line += "\n\n"

		# if tracks_in_playlist and m4a_bitrates:
		#	 line += "M4A bitrates (kbps):\n"
		#	 rates = sorted(m4a_bitrates, key=m4a_bitrates.get, reverse=True)
		#	 others = 0
		#	 for rate in rates:
		#		 perc = round((m4a_bitrates.get(rate) / sum(m4a_bitrates.values())) * 100, 1)
		#		 if perc < 1:
		#			 others += perc
		#		 else:
		#			 line += f"{rate} ({perc}%); "
		#
		#	 if others:
		#		 others = round(others, 1)
		#		 if others < 0.1:
		#			 others = "<0.1"
		#		 line += f"Others ({others}%);"
		#
		#	 line = line.rstrip("; ")
		#	 line += "\n\n"

		line += "\n" + f"-------------- {_('Top Artists')} --------------------" + "\n\n"

		ls = self.stats_gen.artist_list
		for i, item in enumerate(ls[:50]):
			line += str(i + 1) + ".\t" + self.stt2(item[1]) + "\t" + item[0] + "\n"

		line += "\n\n" + f"-------------- {_('Top Albums')} --------------------" + "\n\n"
		ls = self.stats_gen.album_list
		for i, item in enumerate(ls[:50]):
			line += str(i + 1) + ".\t" + self.stt2(item[1]) + "\t" + item[0] + "\n"
		line += "\n\n" + f"-------------- {_('Top Genres')} --------------------" + "\n\n"
		ls = self.stats_gen.genre_list
		for i, item in enumerate(ls[:50]):
			line += str(i + 1) + ".\t" + self.stt2(item[1]) + "\t" + item[0] + "\n"

		line = line.encode("utf-8")
		xport = (self.user_directory / "stats.txt").open("wb")
		xport.write(line)
		xport.close()
		target = str(self.user_directory / "stats.txt")
		if self.windows:
			os.startfile(target)
		elif self.macos:
			subprocess.call(["open", target])
		else:
			subprocess.call(["xdg-open", target])

	def imported_sort(self, pl: int) -> None:
		if self.pl_is_locked(pl):
			self.show_message(_("Playlist is locked"))
			return

		og = self.pctl.multi_playlist[pl].playlist_ids
		og.sort(key=lambda x: self.pctl.get_track(x).index)

		self.reload_albums()
		self.tree_view_box.clear_target_pl(pl)

	def imported_sort_folders(self, pl: int) -> None:
		if self.pl_is_locked(pl):
			self.show_message(_("Playlist is locked"))
			return

		og = self.pctl.multi_playlist[pl].playlist_ids
		og.sort(key=lambda x: self.pctl.get_track(x).index)

		first_occurrences = {}
		for i, x in enumerate(og):
			b = self.pctl.get_track(x).parent_folder_path
			if b not in first_occurrences:
				first_occurrences[b] = i

		og.sort(key=lambda x: first_occurrences[self.pctl.get_track(x).parent_folder_path])

		self.reload_albums()
		self.tree_view_box.clear_target_pl(pl)

	def standard_sort(self, pl: int) -> None:
		if self.pl_is_locked(pl):
			self.show_message(_("Playlist is locked"))
			return

		self.sort_path_pl(pl)
		self.sort_track_2(pl)
		self.reload_albums()
		self.tree_view_box.clear_target_pl(pl)

	def year_sort(self, pl: int, custom_list: list[int] | None = None) -> list[int] | None:
		playlist = self.pctl.multi_playlist[pl].playlist_ids if custom_list is None else custom_list
		plt: list[tuple[list[int], str, str]] = []
		pl2: list[int] = []
		artist = ""
		album_artist = ""

		p = 0
		while p < len(playlist):
			track = self.get_object(playlist[p])

			if track.artist != artist:
				if (album_artist and track.album_artist and album_artist == track.album_artist) or (len(artist) > 5 and artist.lower() in track.parent_folder_name.lower()):
					pass
				else:
					artist = track.artist
					pl2 += year_s(plt)
					plt = []

			if track.album_artist:
				album_artist = track.album_artist

			if p > len(playlist) - 1:
				break

			album: list[int] = []
			on = self.get_object(playlist[p]).parent_folder_path
			album.append(playlist[p])
			t = 1

			while t + p < len(playlist) and self.get_object(playlist[p + t]).parent_folder_path == on:
				album.append(playlist[p + t])
				t += 1

			date = self.get_object(playlist[p]).date

			# If date is xx-xx-yyyy format, just grab the year from the end
			# so that the M and D don't interfere with the sorter
			if len(date) > 4 and date[-4:].isnumeric():
				date = date[-4:]

			# If we don't have a date, see if we can grab one from the folder name
			# following the format: (XXXX)
			if date == "":
				pfn = self.get_object(playlist[p]).parent_folder_name
				if len(pfn) > 6 and pfn[-1] == ")" and pfn[-6] == "(":
					date = pfn[-5:-1]
			plt.append((album, date, artist + " " + self.get_object(playlist[p]).album))
			p += len(album)
			#logging.info(album)

		if plt:
			pl2 += year_s(plt)
			plt = []

		if custom_list is not None:
			return pl2

		# We can't just assign the playlist because it may disconnect the 'pointer' pctl.default_playlist
		self.pctl.multi_playlist[pl].playlist_ids[:] = pl2[:]
		self.reload_albums()
		self.tree_view_box.clear_target_pl(pl)
		return None

	def gen_unique_pl_title(self, base: str, extra: str = "", start: int = 1) -> str:
		ex = start
		title = base
		while ex < 100:
			for playlist in self.pctl.multi_playlist:
				if playlist.title == title:
					ex += 1
					title = base + " (" + extra.rstrip(" ") + ")" if ex == 1 else base + " (" + extra + str(ex) + ")"
					break
			else:
				break
		return title

	def append_deco(self) -> Decorator:
		line_colour = self.colours.menu_text if self.pctl.playing_state != PlayingState.STOPPED else self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, None)

	def rescan_deco(self, pl: int) -> Decorator:
		if self.pctl.multi_playlist[pl].last_folder:
			line_colour = self.colours.menu_text
		else:
			line_colour = self.colours.menu_text_disabled

		# base = os.path.basename(self.pctl.multi_playlist[pl].last_folder)
		return Decorator(line_colour, self.colours.menu_background, None)

	def regenerate_deco(self, pl: int) -> Decorator:
		id = self.pctl.pl_to_id(pl)
		value = self.pctl.gen_codes.get(id)

		line_colour = self.colours.menu_text if value else self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, None)

	def auto_sync_thread(self, pl: int) -> None:
		if self.prefs.transcode_inplace:
			self.show_message(_("Cannot sync when in transcode inplace mode"))
			return

		# Find target path
		self.gui.sync_progress = "Starting Sync..."
		self.gui.update += 1

		path = Path(self.sync_target.text.strip().rstrip("/").rstrip("\\").replace("\n", "").replace("\r", ""))
		logging.debug(f"sync_path: {path}")
		if not path:
			self.show_message(_("No target folder selected"))
			self.gui.sync_progress = ""
			self.gui.stop_sync = False
			self.gui.update += 1
			return
		if not path.is_dir():
			self.show_message(_("Target folder could not be found"))
			self.gui.sync_progress = ""
			self.gui.stop_sync = False
			self.gui.update += 1
			return

		self.prefs.sync_target = str(path)

		# Get list of folder names on device
		logging.info("Getting folder list from device...")
		d_folder_names = [p.name for p in path.iterdir()]
		logging.info("Got list")

		# Get list of folders we want
		folders = self.convert_playlist(pl, get_list=True)
		folder_names: list[str] = []
		folder_dict: dict[str, list[int]] = {}

		if self.gui.stop_sync:
			self.gui.sync_progress = ""
			self.gui.stop_sync = False
			self.gui.update += 1

		# Find the folder names the transcode function would name them
		for folder in folders:
			name = encode_folder_name(self.pctl.get_track(folder[0]))
			for item in folder:
				if self.pctl.get_track(item).album != self.pctl.get_track(folder[0]).album:
					name = os.path.basename(self.pctl.get_track(folder[0]).parent_folder_path)
					break
			folder_names.append(name)
			folder_dict[name] = folder

		# ------
		# Find deletes
		if self.prefs.sync_deletes:
			for d_folder in d_folder_names:
				d_folder = d_folder.name
				if self.gui.stop_sync:
					break
				if d_folder not in folder_names:
					self.gui.sync_progress = _("Deleting folders...")
					self.gui.update += 1
					logging.warning(f"DELETING: {d_folder}")
					shutil.rmtree(path / d_folder)

		# -------
		# Find todos
		todos: list[str] = []
		for folder in folder_names:
			if folder not in d_folder_names:
				todos.append(folder)
				logging.info(f"Want to add: {folder}")
			else:
				logging.error(f"Already exists: {folder}")

		self.gui.update += 1
		# -----
		# Prepare and copy
		for i, item in enumerate(todos):
			self.gui.sync_progress = _("Copying files to device")
			if self.gui.stop_sync:
				break

			free_space = shutil.disk_usage(path)[2] / 8 / 100000000  # in GB
			if free_space < 0.6:
				self.show_message(_("Sync aborted! Low disk space on target device"), mode="warning")
				break

			if self.prefs.bypass_transcode or (self.prefs.smart_bypass and 0 < self.pctl.get_track(folder_dict[item][0]).bitrate <= 128):
				logging.info("Smart bypass...")

				source_parent = Path(self.pctl.get_track(folder_dict[item][0]).parent_folder_path)
				if source_parent.exists():
					if (path / item).exists():
						self.show_message(
							_("Sync warning"), _("Multiple folders to sync have the same name. Skipping."), mode="warning")
						continue

					(path / item).mkdir()
					encode_done = source_parent
				else:
					self.show_message(_("One or more folders is missing"))
					continue
			else:
				encode_done = self.prefs.encoder_output / item
				# TODO(Martin): We should make sure that the length of the source and target matches or is greater, not just that the dir exists and is not empty!
				if not encode_done.exists() or not any(encode_done.iterdir()):
					logging.info("Need to transcode")
					remain = len(todos) - i
					if remain > 1:
						self.gui.sync_progress = _("{N} Folders Remaining").format(N=str(remain))
					else:
						self.gui.sync_progress = _("{N} Folder Remaining").format(N=str(remain))
					self.transcode_list.append(folder_dict[item])
					self.thread_manager.ready("worker")
					while self.transcode_list:
						time.sleep(1)
					if self.gui.stop_sync:
						break
				else:
					logging.warning("A transcode is already done")

				if encode_done.exists():
					if (path / item).exists():
						self.show_message(
							_("Sync warning"), _("Multiple folders to sync have the same name. Skipping."), mode="warning")
						continue

					(path / item).mkdir()

			for file in encode_done.iterdir():
				file = file.name
				logging.info(f"Copy file {file} to {path / item}…")
				# self.gui.sync_progress += "."
				self.gui.update += 1

				if (encode_done / file).is_file():
					size = os.path.getsize(encode_done / file)
					self.sync_file_timer.set()
					try:
						shutil.copyfile(encode_done / file, path / item / file)
					except OSError as e:
						if str(e).startswith("[Errno 22] Invalid argument: "):
							sanitized_file = re.sub(r'[<>:"/\\|?*]', "_", file)
							if sanitized_file == file:
								logging.exception("Unknown OSError trying to copy file, maybe FS does not support the name?")
							else:
								shutil.copyfile(encode_done / file, path / item / sanitized_file)
								logging.warning(f"Had to rename {file} to {sanitized_file} on the output! Probably a FS limitation!")
						else:
							logging.exception("Unknown OSError trying to copy file")
					except Exception:
						logging.exception("Unknown error trying to copy file")

				if self.gui.sync_speed == 0 or (self.sync_file_update_timer.get() > 1 and not file.endswith(".jpg")):
					self.sync_file_update_timer.set()
					self.gui.sync_speed = size / self.sync_file_timer.get()
					self.gui.sync_progress = _("Copying files to device") + " @ " + get_filesize_string_rounded(
						self.gui.sync_speed) + "/s"
					if self.gui.stop_sync:
						self.gui.sync_progress = _("Aborting Sync") + " @ " + get_filesize_string_rounded(self.gui.sync_speed) + "/s"

			logging.info("Finished copying folder")

		self.gui.sync_speed = 0
		self.gui.sync_progress = ""
		self.gui.stop_sync = False
		self.gui.update += 1
		self.show_message(_("Sync completed"), mode="done")

	def auto_sync(self, pl: int) -> None:
		shoot_dl = threading.Thread(target=self.auto_sync_thread, args=([pl]))
		shoot_dl.daemon = True
		shoot_dl.start()

	def set_sync_playlist(self, pl: int) -> None:
		id = self.pctl.pl_to_id(pl)
		if self.prefs.sync_playlist == id:
			self.prefs.sync_playlist = None
		else:
			self.prefs.sync_playlist = self.pctl.pl_to_id(pl)

	def sync_playlist_deco(self, pl: int) -> Decorator:
		text = _("Set as Sync Playlist")
		id = self.pctl.pl_to_id(pl)
		if id == self.prefs.sync_playlist:
			text = _("Un-set as Sync Playlist")
		return Decorator(self.colours.menu_text, self.colours.menu_background, text)

	def set_download_playlist(self, pl: int) -> None:
		id = self.pctl.pl_to_id(pl)
		if self.prefs.download_playlist == id:
			self.prefs.download_playlist = None
		else:
			self.prefs.download_playlist = self.pctl.pl_to_id(pl)

	def set_podcast_playlist(self, pl: int) -> None:
		self.pctl.multi_playlist[pl].persist_time_positioning ^= True

	def set_download_deco(self, pl: int) -> Decorator:
		id = self.pctl.pl_to_id(pl)
		text = _("Set as Downloads Playlist")
		if id == self.prefs.download_playlist:
			text = _("Un-set as Downloads Playlist")
		return Decorator(self.colours.menu_text, self.colours.menu_background, text)

	def set_podcast_deco(self, pl: int) -> Decorator:
		text = _("Set Use Persistent Time")
		if self.pctl.multi_playlist[pl].persist_time_positioning:
			text = _("Un-set Use Persistent Time")
		return Decorator(self.colours.menu_text, self.colours.menu_background, text)

	def export_playlist_albums(self, pl: int) -> None:
		p = self.pctl.multi_playlist[pl]
		name = p.title
		playlist = p.playlist_ids

		albums: list[int] = []
		playtimes: dict[str, int] = {}
		last_folder = None
		for i, id in enumerate(playlist):
			track = self.pctl.get_track(id)
			if last_folder != track.parent_folder_path:
				last_folder = track.parent_folder_path
				if id not in albums:
					albums.append(id)

			playtimes[last_folder] = playtimes.get(last_folder, 0) + int(self.star_store.get(id))

		filepath = self.user_directory / f"{name}.csv"
		with open(filepath, "w", encoding="utf-8") as xport:
			xport.write("Album name,Artist,Release date,Genre,Rating,Playtime,Folder path")

			for id in albums:
				track = self.pctl.get_track(id)
				artist = track.album_artist
				if not artist:
					artist = track.artist

				xport.write("\n")
				xport.write(csv_string(track.album) + ",")
				xport.write(csv_string(artist) + ",")
				xport.write(csv_string(track.date) + ",")
				xport.write(csv_string(track.genre) + ",")
				xport.write(str(int(self.album_star_store.get_rating(track))))
				xport.write(",")
				xport.write(str(round(playtimes[track.parent_folder_path])))
				xport.write(",")
				xport.write(csv_string(track.parent_folder_path))

		self.show_message(_("Export complete."), _("Saved as: ") + str(filepath), mode="done")

	def best(self, index: int) -> float:
		# key = self.pctl.master_library[index].title + pctl.master_library[index].filename
		if self.pctl.master_library[index].length < 1:
			return 0
		return int(self.star_store.get(index))

	def key_rating(self, index: int) -> int:
		return self.star_store.get_rating(index)

	def key_scrobbles(self, index: int) -> int:
		return self.pctl.get_track(index).lfm_scrobbles

	def key_disc(self, index: int) -> str:
		return self.pctl.get_track(index).disc_number

	def key_cue(self, index: int) -> bool:
		return self.pctl.get_track(index).is_cue

	def key_track_id(self, index: int) -> int:
		return index

	def key_playcount(self, index: int) -> float:
		# key = self.pctl.master_library[index].title + self.pctl.master_library[index].filename
		if self.pctl.master_library[index].length < 1:
			return 0
		return self.star_store.get(index) / self.pctl.master_library[index].length
		# if key in self.pctl.star_library:
		#	 return self.pctl.star_library[key] / self.pctl.master_library[index].length
		# else:
		#	 return 0

	def gen_top_rating(self, index: int, custom_list: list[int] | None = None) -> list[int] | None:
		source = self.pctl.multi_playlist[index].playlist_ids if custom_list is None else custom_list
		playlist = copy.deepcopy(source)
		playlist = sorted(playlist, key=self.key_rating, reverse=True)

		if custom_list is not None:
			return playlist

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[index].title + add_pl_tag(_("Top Rated Tracks")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=True))

		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[index].title + "\" a rat>"
		return None

	def gen_top_100(self, index: int, custom_list: list[int] | None = None) -> list[int] | None:
		source = self.pctl.multi_playlist[index].playlist_ids if custom_list is None else custom_list
		playlist = copy.deepcopy(source)
		playlist = sorted(playlist, key=self.best, reverse=True)

		if custom_list is not None:
			return playlist

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[index].title + add_pl_tag(_("Top Played Tracks")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=True))

		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[index].title + "\" a pt>"
		return None

	def gen_folder_top(self, pl: int, get_sets: bool = False, custom_list: list[int] | None = None) -> list[int] | list[tuple[list[int], int]] | None:
		source = self.pctl.multi_playlist[pl].playlist_ids if custom_list is None else custom_list

		if not source:
			return []

		sets: list[list[int]] = []
		se: list[int] = []
		tr = self.pctl.get_track(source[0])
		last = tr.parent_folder_path
		last_al = tr.album
		for track in source:
			if last != self.pctl.master_library[track].parent_folder_path or last_al != self.pctl.master_library[track].album:
				last = self.pctl.master_library[track].parent_folder_path
				last_al = self.pctl.master_library[track].album
				sets.append(copy.deepcopy(se))
				se = []
			se.append(track)
		sets.append(copy.deepcopy(se))

		def best(folder: list[int]) -> int:
			#logging.info(folder)
			total_star: int = 0
			for item in folder:
				# key = self.pctl.master_library[item].title + self.pctl.master_library[item].filename
				# if key in self.pctl.star_library:
				#	 total_star += int(self.pctl.star_library[key])
				total_star += int(self.star_store.get(item))
			#logging.info(total_star)
			return total_star

		if get_sets:
			r: list[tuple[list[int], int]] = []
			for item in sets:
				r.append((item, best(item)))
			return r

		sets = sorted(sets, key=best, reverse=True)

		playlist: list[int] = []

		for se in sets:
			playlist += se

		# self.pctl.multi_playlist.append(
		#	 [self.pctl.multi_playlist[pl].title + " <Most Played Albums>", 0, copy.deepcopy(playlist), 0, 0, 0])
		if custom_list is not None:
			return playlist

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[pl].title + add_pl_tag(_("Top Played Albums")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=False))

		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[pl].title + "\" a pa>"
		return None

	def gen_folder_top_rating(self, pl: int, get_sets: bool = False, custom_list: list[int] | None = None) -> list[int] | None:
		source = self.pctl.multi_playlist[pl].playlist_ids if custom_list is None else custom_list

		if not source:
			return []

		sets: list[list[int]] = []
		se: list[int] = []
		tr = self.pctl.get_track(source[0])
		last = tr.parent_folder_path
		last_al = tr.album
		for track in source:
			if last != self.pctl.master_library[track].parent_folder_path or last_al != self.pctl.master_library[track].album:
				last = self.pctl.master_library[track].parent_folder_path
				last_al = self.pctl.master_library[track].album
				sets.append(copy.deepcopy(se))
				se = []
			se.append(track)
		sets.append(copy.deepcopy(se))

		def best(folder: list[int]) -> int:
			return self.album_star_store.get_rating(self.pctl.get_track(folder[0]))

		if get_sets:
			r = []
			for item in sets:
				r.append((item, best(item)))
			return r

		sets = sorted(sets, key=best, reverse=True)

		playlist: list[int] = []

		for se in sets:
			playlist += se

		if custom_list is not None:
			return playlist

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[pl].title + add_pl_tag(_("Top Rated Albums")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=False))

		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[pl].title + "\" a rata>"
		return None

	def gen_lyrics(self, pl: int, custom_list: list[int] | None = None) -> list[int] | None:
		playlist: list[int] = []
		source = self.pctl.multi_playlist[pl].playlist_ids if custom_list is None else custom_list

		for item in source:
			if self.pctl.master_library[item].lyrics:
				playlist.append(item)

		if custom_list is not None:
			return playlist

		if len(playlist) > 0:
			self.pctl.multi_playlist.append(
				self.pl_gen(
					title=_("Tracks with lyrics"),
					playlist_ids=copy.deepcopy(playlist),
					hide_title=False))

			self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[pl].title + "\" a ly"
		else:
			self.show_message(_("No tracks with lyrics were found."))
		return None

	def gen_incomplete(self, pl: int, custom_list: list[int] | None = None) -> list[int] | None:
		playlist: list[int] = []

		source = self.pctl.multi_playlist[pl].playlist_ids if custom_list is None else custom_list

		albums: dict[str, list[TrackClass]] = {}
		nums: dict[str, list[int]] = {}
		for id in source:
			track = self.pctl.get_track(id)
			if track.album and track.track_number:

				if type(track.track_number) is str and not track.track_number.isdigit():
					continue

				if track.album not in albums:
					albums[track.album] = []
					nums[track.album] = []

				if track not in albums[track.album]:
					albums[track.album].append(track)
					nums[track.album].append(int(track.track_number))

		for album, tracks in albums.items():
			numbers = nums[album]
			if len(numbers) > 2:
				mi = min(numbers)
				mx = max(numbers)
				for track in tracks:
					if type(track.track_total) is int or (type(track.track_total) is str and track.track_total.isdigit()):
						mx = max(mx, int(track.track_total))
				r = list(range(int(mi), int(mx) + 1))
				for track in tracks:
					if int(track.track_number) in r:
						r.remove(int(track.track_number))
				if r or mi > 1:
					for tr in tracks:
						playlist.append(tr.index)

		if custom_list is not None:
			return playlist

		if len(playlist) > 0:
			self.show_message(_("Note this may include albums that simply have tracks missing an album tag"))
			self.pctl.multi_playlist.append(
				self.pl_gen(
					title=self.pctl.multi_playlist[pl].title + add_pl_tag(_("Incomplete Albums")),
					playlist_ids=copy.deepcopy(playlist),
					hide_title=False))

			# self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[pl].title + "\" a ly"
		else:
			self.show_message(_("No incomplete albums were found."))
		return None

	def gen_codec_pl(self, codec: str) -> None:
		playlist = []

		for pl in self.pctl.multi_playlist:
			for item in pl.playlist_ids:
				if self.pctl.master_library[item].file_ext == codec and item not in playlist:
					playlist.append(item)

		if len(playlist) > 0:
			self.pctl.multi_playlist.append(
				self.pl_gen(
					title=_("Codec: ") + codec,
					playlist_ids=copy.deepcopy(playlist),
					hide_title=False))

	def gen_last_imported_folders(self, index: int, custom_list: list[int] | None = None, reverse: bool = True) -> int | list[int] | None:
		source = self.pctl.multi_playlist[index].playlist_ids if custom_list is None else custom_list

		a_cache: dict[tuple[str, str], int] = {}

		def key_import(index: int) -> int:
			track = self.pctl.master_library[index]
			cached = a_cache.get((track.album, track.parent_folder_name))
			if cached is not None:
				return cached

			if track.album:
				a_cache[(track.album, track.parent_folder_name)] = index
			return index

		playlist = copy.deepcopy(source)
		playlist = sorted(playlist, key=key_import, reverse=reverse)
		self.sort_track_2(0, playlist)

		if custom_list is not None:
			return playlist
		return None

	def gen_last_modified(self, index: int, custom_list: list[int] | None = None, reverse: bool = True) -> list[int] | None:
		source = self.pctl.multi_playlist[index].playlist_ids if custom_list is None else custom_list

		a_cache: dict[tuple[str, str], float] = {}

		def key_modified(index: int) -> float:
			track = self.pctl.master_library[index]
			cached = a_cache.get((track.album, track.parent_folder_name))
			if cached is not None:
				return cached

			if track.album:
				a_cache[(track.album, track.parent_folder_name)] = self.pctl.master_library[index].modified_time
			return self.pctl.master_library[index].modified_time

		playlist = copy.deepcopy(source)
		playlist = sorted(playlist, key=key_modified, reverse=reverse)
		self.sort_track_2(0, playlist)

		if custom_list is not None:
			return playlist

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[index].title + add_pl_tag(_("File Modified")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=False))

		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[index].title + "\" a m>"
		return None

	def gen_love(self, pl: int, custom_list: list[int] | None = None) -> list[int] | None:
		playlist: list[int] = []

		source = self.pctl.multi_playlist[pl].playlist_ids if custom_list is None else custom_list

		for item in source:
			if self.get_love_index(item):
				playlist.append(item)

		playlist.sort(key=lambda x: self.get_love_timestamp_index(x), reverse=True)

		if custom_list is not None:
			return playlist

		if len(playlist) > 0:
			# self.pctl.multi_playlist.append(["Interesting Comments", 0, copy.deepcopy(playlist), 0, 0, 0])
			self.pctl.multi_playlist.append(
				self.pl_gen(
					title=_("Loved"),
					playlist_ids=copy.deepcopy(playlist),
					hide_title=False))
			self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[pl].title + "\" a l"
		else:
			self.show_message(_("No loved tracks were found."))
		return None

	def gen_comment(self, pl: int) -> None:
		playlist: list[int] = []

		for item in self.pctl.multi_playlist[pl].playlist_ids:
			cm = self.pctl.master_library[item].comment
			if len(cm) > 20 and \
					cm[0] != "0" and \
					"http://" not in cm and \
					"www." not in cm and \
					"Release" not in cm and \
					"EAC" not in cm and \
					"@" not in cm and \
					".com" not in cm and \
					"ipped" not in cm and \
					"ncoded" not in cm and \
					"ExactA" not in cm and \
					"WWW." not in cm and \
					cm[2] != "+" and \
					cm[1] != "+":
				playlist.append(item)

		if len(playlist) > 0:
			# self.pctl.multi_playlist.append(["Interesting Comments", 0, copy.deepcopy(playlist), 0, 0, 0])
			self.pctl.multi_playlist.append(
				self.pl_gen(
					title=_("Interesting Comments"),
					playlist_ids=copy.deepcopy(playlist),
					hide_title=False))
		else:
			self.show_message(_("Nothing of interest was found."))

	def gen_replay(self, pl: int) -> None:
		playlist: list[int] = []

		for item in self.pctl.multi_playlist[pl].playlist_ids:
			if self.pctl.master_library[item].replaygain_track_gain:
				playlist.append(item)

		if len(playlist) > 0:
			self.pctl.multi_playlist.append(
				self.pl_gen(
					title=_("ReplayGain Tracks"),
					playlist_ids=copy.deepcopy(playlist),
					hide_title=False))
		else:
			self.show_message(_("No replay gain tags were found."))

	def gen_sort_len(self, index: int, custom_list: list[int] | None = None) -> list[int] | None:
		source = self.pctl.multi_playlist[index].playlist_ids if custom_list is None else custom_list

		def length(index: int) -> int:
			if self.pctl.master_library[index].length < 1:
				return 0
			return int(self.pctl.master_library[index].length)

		playlist = copy.deepcopy(source)
		playlist = sorted(playlist, key=length, reverse=True)

		if custom_list is not None:
			return playlist

		# self.pctl.multi_playlist.append(
		#	 [self.pctl.multi_playlist[index].title + " <Duration Sorted>", 0, copy.deepcopy(playlist), 0, 1, 0])

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[index].title + add_pl_tag(_("Duration Sorted")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=True))

		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[index].title + "\" a d>"
		return None

	def gen_folder_duration(
		self, pl: int, get_sets: bool = False, custom_list: list[int] | None = None, reverse: bool = True,
	) -> list[int] | list[tuple[list[int], float]] | None:
		source = self.pctl.multi_playlist[pl].playlist_ids if custom_list is None else custom_list

		if not source:
			if custom_list is not None or get_sets:
				return []
			return None

		sets: list[list[int]] = []
		se:         list[int] = []
		last = self.pctl.master_library[source[0]].parent_folder_path
		last_al = self.pctl.master_library[source[0]].album
		for track in source:
			if last != self.pctl.master_library[track].parent_folder_path or last_al != self.pctl.master_library[track].album:
				last = self.pctl.master_library[track].parent_folder_path
				last_al = self.pctl.master_library[track].album
				sets.append(copy.deepcopy(se))
				se = []
			se.append(track)
		sets.append(copy.deepcopy(se))

		def best(folder: list[int]) -> float:
			total_duration = 0.
			for item in folder:
				total_duration += self.pctl.master_library[item].length
			return total_duration

		if get_sets:
			r: list[tuple[list[int], float]] = []
			for item in sets:
				r.append((item, best(item)))
			return r

		sets = sorted(sets, key=best, reverse=reverse)
		playlist: list[int] = []

		for se in sets:
			playlist += se

		if custom_list is not None:
			return playlist

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[pl].title + add_pl_tag(_("Longest Albums")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=False))
		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[pl].title + "\" a ad>"
		return None

	def gen_sort_date(self, index: int, rev: bool = False, custom_list: list[int] | None = None) -> list[int] | None:
		def g_date(index: int) -> str:
			if self.pctl.master_library[index].date:
				return str(self.pctl.master_library[index].date)
			return "z"

		playlist: list[int] = []
		lowest = 0
		highest = 0
		first = True

		source = self.pctl.multi_playlist[index].playlist_ids if custom_list is None else custom_list

		for item in source:
			date = self.pctl.master_library[item].date
			if date:
				playlist.append(item)
				if len(date) > 4 and date[:4].isdigit():
					date = date[:4]
				if len(date) == 4 and date.isdigit():
					year = int(date)
					if first:
						lowest = year
						highest = year
						first = False
					lowest = min(year, lowest)
					highest = max(year, highest)

		playlist = sorted(playlist, key=g_date, reverse=rev)

		if custom_list is not None:
			return playlist

		line = add_pl_tag(_("Year Sorted"))
		if lowest not in (highest, 0) and highest != 0:
			if rev:
				line = " <" + str(highest) + "-" + str(lowest) + ">"
			else:
				line = " <" + str(lowest) + "-" + str(highest) + ">"

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[index].title + line,
				playlist_ids=copy.deepcopy(playlist),
				hide_title=False))

		if rev:
			self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[index].title + "\" a y>"
		else:
			self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[index].title + "\" a y<"
		return None

	def gen_sort_date_new(self, index: int) -> None:
		self.gen_sort_date(index, True)

	def gen_500_random(self, index: int) -> None:
		playlist = copy.deepcopy(self.pctl.multi_playlist[index].playlist_ids)

		random.shuffle(playlist)

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[index].title + add_pl_tag(_("Shuffled Tracks")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=True))

		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[index].title + "\" a st"

	def gen_folder_shuffle(self, index: int, custom_list: list[int] | None = None) -> list[int] | None:
		folders: list[str] = []
		dick: dict[str, list[int]] = {}

		source = self.pctl.multi_playlist[index].playlist_ids if custom_list is None else custom_list

		for track in source:
			parent = self.pctl.master_library[track].parent_folder_path
			if parent not in folders:
				folders.append(parent)
			if parent not in dick:
				dick[parent] = []
			dick[parent].append(track)

		random.shuffle(folders)
		playlist: list[int] = []

		for folder in folders:
			playlist += dick[folder]

		if custom_list is not None:
			return playlist

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[index].title + add_pl_tag(_("Shuffled Albums")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=False))

		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[index].title + "\" a ra"
		return None

	def gen_best_random(self, index: int) -> None:
		playlist = []

		for p in self.pctl.multi_playlist[index].playlist_ids:
			time = self.star_store.get(p)

			if time > 300:
				playlist.append(p)

		random.shuffle(playlist)

		if len(playlist) > 0:
			self.pctl.multi_playlist.append(
				self.pl_gen(
					title=self.pctl.multi_playlist[index].title + add_pl_tag(_("Lucky Random")),
					playlist_ids=copy.deepcopy(playlist),
					hide_title=True))

			self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[index].title + "\" a pt>300 rt"

	def gen_reverse(self, index: int, custom_list: list[int] | None = None) -> list[int] | None:
		source = self.pctl.multi_playlist[index].playlist_ids if custom_list is None else custom_list

		playlist = list(reversed(source))

		if custom_list is not None:
			return playlist

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[index].title + add_pl_tag(_("Reversed")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=self.pctl.multi_playlist[index].hide_title))

		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[index].title + "\" a rv"
		return None

	def gen_folder_reverse(self, index: int, custom_list: list[int] | None = None) -> list[int] | None:
		source = self.pctl.multi_playlist[index].playlist_ids if custom_list is None else custom_list

		folders: list[str] = []
		dick: dict[str, list[int]] = {}
		for track in source:
			parent = self.pctl.master_library[track].parent_folder_path
			if parent not in folders:
				folders.append(parent)
			if parent not in dick:
				dick[parent] = []
			dick[parent].append(track)

		folders = list(reversed(folders))
		playlist: list[int] = []

		for folder in folders:
			playlist += dick[folder]

		if custom_list is not None:
			return playlist

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[index].title + add_pl_tag(_("Reversed Albums")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=False))

		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[index].title + "\" a rva"
		return None

	def gen_dupe(self, index: int) -> None:
		playlist = self.pctl.multi_playlist[index].playlist_ids

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.gen_unique_pl_title(self.pctl.multi_playlist[index].title, _("Duplicate") + " ", 0),
				playing=self.pctl.multi_playlist[index].playing,
				playlist_ids=copy.deepcopy(playlist),
				position=self.pctl.multi_playlist[index].position,
				hide_title=self.pctl.multi_playlist[index].hide_title,
				selected=self.pctl.multi_playlist[index].selected))

	def gen_sort_path(self, index: int) -> None:
		def path(index: int) -> str:
			return self.pctl.master_library[index].fullpath

		playlist = copy.deepcopy(self.pctl.multi_playlist[index].playlist_ids)
		playlist = sorted(playlist, key=path)

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[index].title + add_pl_tag(_("Filepath Sorted")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=False))

	def gen_sort_artist(self, index: int) -> None:
		def artist(index: int) -> str:
			return self.pctl.master_library[index].artist

		playlist = copy.deepcopy(self.pctl.multi_playlist[index].playlist_ids)
		playlist = sorted(playlist, key=artist)

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[index].title + add_pl_tag(_("Artist Sorted")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=False))

	def gen_sort_album(self, index: int) -> None:
		def album(index: int) -> str:
			return self.pctl.master_library[index].album

		playlist = copy.deepcopy(self.pctl.multi_playlist[index].playlist_ids)
		playlist = sorted(playlist, key=album)

		self.pctl.multi_playlist.append(
			self.pl_gen(
				title=self.pctl.multi_playlist[index].title + add_pl_tag(_("Album Sorted")),
				playlist_ids=copy.deepcopy(playlist),
				hide_title=False))

	def get_playing_line(self) -> str:
		if self.pctl.playing_state in (PlayingState.PLAYING, PlayingState.PAUSED):
			title = self.pctl.master_library[self.pctl.track_queue[self.pctl.queue_step]].title
			artist = self.pctl.master_library[self.pctl.track_queue[self.pctl.queue_step]].artist
			return artist + " - " + title
		return "Stopped"

	def reload_config_file(self) -> None:
		if self.transcode_list:
			self.show_message(_("Cannot reload while a transcode is in progress!"), mode="error")
			return

		load_prefs(bag=self.bag)
		self.gui.opened_config_file = False

		self.ddt.force_subpixel_text = self.prefs.force_subpixel_text
		self.ddt.clear_text_cache()
		self.pctl.playerCommand = "reload"
		self.pctl.playerCommandReady = True
		self.show_message(_("Configuration reloaded"), mode="done")
		self.gui.update_layout = True

	def open_config_file(self) -> None:
		save_prefs(bag=self.bag)
		target = str(self.config_directory / "tauon.conf")
		if self.windows:
			os.startfile(target)
		elif self.macos:
			subprocess.call(["open", "-t", target])
		else:
			subprocess.call(["xdg-open", target])
		self.show_message(_("Config file opened."), _('Click "Reload" if you made any changes'), mode="arrow")
		# self.reload_config_file()
		# self.gui.message_box = False
		self.gui.opened_config_file = True

	def open_keymap_file(self) -> None:
		target = str(self.config_directory / "input.txt")

		if not os.path.isfile(target):
			self.show_message(_("Input file missing"))
			return

		if self.windows:
			os.startfile(target)
		elif self.macos:
			subprocess.call(["open", target])
		else:
			subprocess.call(["xdg-open", target])

	def open_file(self, target: str) -> None:
		if not os.path.isfile(target):
			self.show_message(_("Input file missing"))
			return

		if self.windows:
			os.startfile(target)
		elif self.macos:
			subprocess.call(["open", target])
		else:
			subprocess.call(["xdg-open", target])

	def open_data_directory(self) -> None:
		target = str(self.user_directory)
		if self.windows:
			os.startfile(target)
		elif self.macos:
			subprocess.call(["open", target])
		else:
			subprocess.call(["xdg-open", target])

	def remove_folder(self, index: int) -> None:
		for b in range(len(self.pctl.default_playlist) - 1, -1, -1):
			r_folder = self.pctl.master_library[index].parent_folder_name
			if self.pctl.master_library[self.pctl.default_playlist[b]].parent_folder_name == r_folder:
				del self.pctl.default_playlist[b]
		self.reload()

	def convert_folder(self, ref: MenuTrackRef) -> None:
		if not self.test_ffmpeg():
			return

		track_id = ref.track_id
		folder = []
		if self.inp.key_shift_down or self.inp.key_shiftr_down:
			track_object = self.pctl.get_track(track_id)
			if track_object.is_network:
				self.show_message(_("Transcoding tracks from network locations is not supported"))
				return
			folder = [track_id]

			if self.prefs.transcode_codec == "flac" and track_object.file_ext.lower() in (
				"mp3", "opus",
				"mp4", "ogg",
				"aac"):
				self.show_message(_("NO! Bad user!"), _("I'm not going to let you transcode a lossy codec to a lossless one!"),
					mode="warning")

				return
			folder = [track_id]

		else:
			r_folder = self.pctl.master_library[track_id].parent_folder_path
			for item in self.pctl.default_playlist:
				if r_folder == self.pctl.master_library[item].parent_folder_path:

					track_object = self.pctl.get_track(item)
					if track_object.file_ext == "SPOT":  # track_object.is_network:
						self.show_message(_("Transcoding this network track type is not possible"))
						return

					if item not in folder:
						folder.append(item)
					#logging.info(prefs.transcode_codec)
					#logging.info(track_object.file_ext)
					if self.prefs.transcode_codec == "flac" and track_object.file_ext.lower() in (
						"mp3", "opus",
						"mp4", "ogg",
						"aac"):
						self.show_message(_("NO! Bad user!"), _("I'm not going to let you transcode a lossy codec to a lossless one!"),
							mode="warning")

						return

		#logging.info(folder)
		self.transcode_list.append(folder)
		self.thread_manager.ready("worker")

	def transfer(self, index: int, args: list[int]) -> None:
		old_cargo = copy.deepcopy(self.pctl.cargo)

		if args[0] == 1 or args[0] == 0:  # copy
			if args[1] == 1:  # single track
				self.pctl.cargo.append(index)
				if args[0] == 0:  # cut
					del self.pctl.default_playlist[self.pctl.selected_in_playlist]
			elif args[1] == 2:  # folder
				for b in range(len(self.pctl.default_playlist)):
					if self.pctl.master_library[self.pctl.default_playlist[b]].parent_folder_name == self.pctl.master_library[
						index].parent_folder_name:
						self.pctl.cargo.append(self.pctl.default_playlist[b])
				if args[0] == 0:  # cut
					for b in reversed(range(len(self.pctl.default_playlist))):
						if self.pctl.master_library[self.pctl.default_playlist[b]].parent_folder_name == self.pctl.master_library[
							index].parent_folder_name:
							del self.pctl.default_playlist[b]
			elif args[1] == 3:  # playlist
				self.pctl.cargo += self.pctl.default_playlist
				if args[0] == 0:  # cut
					self.pctl.default_playlist = []
		elif args[0] == 2:  # Drop
			if args[1] == 1:  # Before
				insert = self.pctl.selected_in_playlist
				while insert > 0 and self.pctl.master_library[self.pctl.default_playlist[insert]].parent_folder_name == \
						self.pctl.master_library[index].parent_folder_name:
					insert -= 1
					if insert == 0:
						break
				else:
					insert += 1

				while len(self.pctl.cargo) > 0:
					self.pctl.default_playlist.insert(insert, self.pctl.cargo.pop())
			elif args[1] == 2:  # After
				insert = self.pctl.selected_in_playlist

				while insert < len(self.pctl.default_playlist) \
				and self.pctl.master_library[self.pctl.default_playlist[insert]].parent_folder_name == self.pctl.master_library[index].parent_folder_name:
					insert += 1

				while len(self.pctl.cargo) > 0:
					self.pctl.default_playlist.insert(insert, self.pctl.cargo.pop())
			elif args[1] == 3:  # End
				self.pctl.default_playlist += self.pctl.cargo
				# self.pctl.cargo = []

			self.pctl.cargo = old_cargo
		self.reload()

	def temp_copy_folder(self, ref: int) -> None:
		self.pctl.cargo = []
		self.transfer(ref, args=[1, 2])

	def show_track_box(self, track_id: int) -> None:
		self.gui.track_box_track_id = track_id
		self.gui.track_box = True
		self.track_box_path_tool_timer.set()

	def sync_track_box_to_selected(self) -> None:
		if self.gui.track_box and self.pctl.selected_ready():
			self.gui.track_box_track_id = self.pctl.default_playlist[self.pctl.selected_in_playlist]

	def activate_track_box(self, ref: MenuTrackRef) -> None:
		self.show_track_box(ref.track_id)

	def menu_paste(self, ref: MenuTrackRef) -> None:
		self.paste(None, ref.track_id, ref.position)

	def lightning_paste(self) -> None:
		move = True
		# if not self.inp.key_shift_down:
		#	 move = False

		move_track = self.pctl.get_track(self.pctl.cargo[0])
		move_path = move_track.parent_folder_path

		for item in self.pctl.cargo:
			if move_path != self.pctl.get_track(item).parent_folder_path:
				self.show_message(
					_("More than one folder is in the clipboard"),
					_("This function can only move one folder at a time."), mode="info")
				return

		match_track = self.pctl.get_track(self.pctl.default_playlist[self.gui.shift_selection[0]])
		match_path = match_track.parent_folder_path

		if self.pctl.playing_state != PlayingState.STOPPED and move and self.pctl.playing_object().parent_folder_path == move_path:
			self.pctl.stop(True)

		p = Path(match_path)
		s = list(p.parts)
		base = s[0]
		c = base
		del s[0]

		to_move = []
		for pl in self.pctl.multi_playlist:
			for i in reversed(range(len(pl.playlist_ids))):
				if self.pctl.get_track(pl.playlist_ids[i]).parent_folder_path == move_track.parent_folder_path:
					to_move.append(pl.playlist_ids[i])

		to_move = list(set(to_move))

		for level in s:
			upper = c
			c = os.path.join(c, level)

			t_artist = match_track.artist
			ta_artist = match_track.album_artist

			t_artist = filename_safe(t_artist)
			ta_artist = filename_safe(ta_artist)

			if (len(t_artist) > 0 and t_artist in level) or \
					(len(ta_artist) > 0 and ta_artist in level):

				logging.info("found target artist level")
				logging.info(t_artist)
				logging.info(f"Upper folder is: {upper}")

				if len(move_path) < 4:
					self.show_message(_("Safety interrupt! The source path seems oddly short."), move_path, mode="error")
					return

				if not os.path.isdir(upper):
					self.show_message(_("The target directory is missing!"), upper, mode="warning")
					return

				if not os.path.isdir(move_path):
					self.show_message(_("The source directory is missing!"), move_path, mode="warning")
					return

				protect = ("", "Documents", "Music", "Desktop", "Downloads")
				for fo in protect:
					if move_path.strip("\\/") == os.path.join(os.path.expanduser("~"), fo).strip("\\/"):
						self.show_message(_("Better not do anything to that folder!"), os.path.join(os.path.expanduser("~"), fo),
							mode="warning")
						return

				if directory_size(move_path) > 3000000000:
					self.show_message(_("Folder size safety limit reached! (3GB)"), move_path, mode="warning")
					return

				if len(next(os.walk(move_path))[2]) > max(20, len(to_move) * 2):
					self.show_message(_("Safety interrupt! The source folder seems to have many files."), move_path, mode="warning")
					return

				artist = move_track.artist
				if move_track.album_artist:
					artist = move_track.album_artist

				artist = filename_safe(artist)

				if artist == "":
					self.show_message(_("The track needs to have an artist name."))
					return

				artist_folder = os.path.join(upper, artist)

				logging.info(f"Target will be: {artist_folder}")

				if os.path.isdir(artist_folder):
					logging.info("The target artist folder already exists")
				else:
					logging.info("Need to make artist folder")
					os.makedirs(artist_folder)

				logging.info(f"The folder to be moved is: {move_path}")
				load_order = LoadClass()
				load_order.target = os.path.join(artist_folder, move_track.parent_folder_name)
				load_order.playlist = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].uuid_int

				insert = self.gui.shift_selection[0]
				old_insert = insert
				while insert < len(self.pctl.default_playlist) and self.pctl.master_library[
					self.pctl.multi_playlist[self.pctl.active_playlist_viewing].playlist_ids[insert]].parent_folder_name == \
						self.pctl.master_library[
							self.pctl.multi_playlist[self.pctl.active_playlist_viewing].playlist_ids[old_insert]].parent_folder_name:
					insert += 1

				load_order.playlist_position = insert

				self.move_jobs.append(
					(move_path, os.path.join(artist_folder, move_track.parent_folder_name), move,
					move_track.parent_folder_name, load_order))
				self.thread_manager.ready("worker")
				# Remove all tracks with the old paths
				for pl in self.pctl.multi_playlist:
					for i in reversed(range(len(pl.playlist_ids))):
						if self.pctl.get_track(pl.playlist_ids[i]).parent_folder_path == move_track.parent_folder_path:
							del pl.playlist_ids[i]

				break
		else:
			self.show_message(_("Could not find a folder with the artist's name to match level at."))
			return

		# for file in os.listdir(artist_folder):

		if self.prefs.album_mode:
			self.prep_gal()
			self.reload_albums(True)

		self.pctl.cargo.clear()
		self.gui.lightning_copy = False

	def repair_playing_position_after_removals(self, removed_positions: list[int], playlist_index: int) -> None:
		if self.pctl.active_playlist_playing != playlist_index:
			return
		if self.pctl.playlist_playing_position < 0 or not removed_positions:
			return

		current_position = self.pctl.playlist_playing_position
		removed_positions = sorted(set(removed_positions))
		removed_before_current = sum(item < current_position for item in removed_positions)

		if current_position in removed_positions:
			self.pctl.playlist_playing_position = current_position - removed_before_current - 1
		else:
			self.pctl.playlist_playing_position = current_position - removed_before_current

		if not self.pctl.playing_playlist():
			self.pctl.playlist_playing_position = -1
		else:
			self.pctl.playlist_playing_position = max(self.pctl.playlist_playing_position, 0)
			self.pctl.playlist_playing_position = min(
				self.pctl.playlist_playing_position,
				len(self.pctl.playing_playlist()) - 1,
			)

	def del_selected(self, force_delete: bool = False) -> None:
		self.gui.update += 1
		self.gui.pl_update = 1

		if not self.gui.shift_selection:
			if not self.pctl.selected_ready():
				return
			self.gui.shift_selection = [self.pctl.selected_in_playlist]

		if not self.pctl.default_playlist:
			return

		li: list[tuple[int, int]] = []
		removed_positions = sorted(self.gui.shift_selection)

		for item in reversed(self.gui.shift_selection):
			if not 0 <= item < len(self.pctl.default_playlist):
				return

			li.append((item, self.pctl.default_playlist[item]))  # take note for force delete

			del self.pctl.default_playlist[item]

		self.repair_playing_position_after_removals(removed_positions, self.pctl.active_playlist_viewing)

		if force_delete:
			for item in li:
				tr = self.pctl.get_track(item[1])
				if not tr.is_network:
					try:
						send2trash(tr.fullpath)
						self.show_message(_("Tracks sent to trash"))
					except Exception:
						logging.exception("One or more tracks could not be sent to trash")
						self.show_message(_("One or more tracks could not be sent to trash"))

						if force_delete:
							try:
								os.remove(tr.fullpath)
								self.show_message(_("Files deleted"), mode="info")
							except Exception:
								logging.exception("Error deleting one or more files")
								self.show_message(_("Error deleting one or more files"), mode="error")
		else:
			self.undo.bk_tracks(self.pctl.active_playlist_viewing, li)

		self.reload()
		self.tree_view_box.clear_target_pl(self.pctl.active_playlist_viewing)

		self.pctl.selected_in_playlist = min(self.pctl.selected_in_playlist, len(self.pctl.default_playlist) - 1)

		self.gui.shift_selection = [self.pctl.selected_in_playlist]
		self.gui.pl_update += 1
		self.pctl.notify_database_changed()

	def force_del_selected(self) -> None:
		self.del_selected(force_delete=True)

	def test_show(self, _: MenuTrackRef) -> bool:
		return self.prefs.album_mode

	def show_in_gal(self, position: int, silent: bool = False) -> None:
		self.gui.gallery_animate_highlight_on = self.goto_album(position)
		if not silent:
			self.gallery_select_animate_timer.set()

	def menu_show_in_gal(self, ref: MenuTrackRef) -> None:
		self.show_in_gal(ref.position)

	def last_fm_test(self, _ignore) -> bool:
		return self.lastfm.connected

	def heart_xmenu_colour(self) -> ColourRGBA | None:
		if self.love(False, self.track_menu.reference.track_id):
			return ColourRGBA(245, 60, 60, 255)
		if self.colours.lm:
			return ColourRGBA(255, 150, 180, 255)
		return None

	def love_decox(self, ref: MenuTrackRef) -> Decorator:
		if self.love(False, ref.track_id):
			return Decorator(self.colours.menu_text, self.colours.menu_background, _("Un-Love Track"))
		return Decorator(self.colours.menu_text, self.colours.menu_background, _("Love Track"))

	def love_index(self, ref: MenuTrackRef) -> None:
		notify = False
		if not self.gui.show_hearts:
			notify = True

		shoot_love = threading.Thread(target=self.love, args=[True, ref.track_id, False, notify])
		shoot_love.daemon = True
		shoot_love.start()

	def add_to_queue(self, ref: MenuTrackRef) -> None:
		self.pctl.force_queue.append(queue_item_gen(ref.track_id, ref.position, ref.playlist_id))
		self.queue_timer_set()
		if self.prefs.stop_end_queue:
			self.pctl.stop_mode = StopMode.OFF

	def add_selected_to_queue(self) -> None:
		self.gui.pl_update += 1
		if self.prefs.stop_end_queue:
			self.pctl.stop_mode = StopMode.OFF
		if self.gui.album_tab_mode:
			self.add_album_to_queue(
				self.pctl.default_playlist[self.get_album_info(self.pctl.selected_in_playlist)[1][0]],
				self.pctl.selected_in_playlist,
				self.pctl.pl_to_id(self.pctl.active_playlist_viewing),
			)
			self.queue_timer_set()
		else:
			self.pctl.force_queue.append(
				queue_item_gen(self.pctl.default_playlist[self.pctl.selected_in_playlist],
				self.pctl.selected_in_playlist,
				self.pctl.pl_to_id(self.pctl.active_playlist_viewing)))
			self.queue_timer_set()

	def add_selected_to_queue_multi(self) -> None:
		if self.prefs.stop_end_queue:
			self.pctl.stop_mode = StopMode.OFF
		for index in self.gui.shift_selection:
			self.pctl.force_queue.append(
				queue_item_gen(self.pctl.default_playlist[index],
				index,
				self.pctl.pl_to_id(self.pctl.active_playlist_viewing)))

	def queue_timer_set(self, plural: bool = False, queue_object: TauonQueueItem | None = None) -> None:
		self.queue_add_timer.set()
		self.gui.frame_callback_list.append(TestTimer(2.51))
		self.gui.queue_toast_plural = plural
		if queue_object:
			self.gui.toast_queue_object = queue_object
		elif self.pctl.force_queue:
			self.gui.toast_queue_object = self.pctl.force_queue[-1]

	def split_queue_album(self, id: int) -> int | None:
		item = self.pctl.force_queue[0]

		pl = self.pctl.id_to_pl(item.playlist_id)
		if pl is None:
			return None

		playlist = self.pctl.multi_playlist[pl].playlist_ids

		i = self.pctl.playlist_playing_position + 1
		parts = []
		album_parent_path = self.pctl.get_track(item.track_id).parent_folder_path

		while i < len(playlist):
			if self.pctl.get_track(playlist[i]).parent_folder_path != album_parent_path:
				break

			parts.append((playlist[i], i))
			i += 1

		del self.pctl.force_queue[0]

		for part in reversed(parts):
			self.pctl.force_queue.insert(0, queue_item_gen(part[0], part[1], item.playlist_id, queue_type=item.type))
		return (len(parts))

	def add_to_queue_next(self, ref: MenuTrackRef) -> None:
		if self.pctl.force_queue and self.pctl.force_queue[0].album_stage == 1:
			self.split_queue_album(None)

		self.pctl.force_queue.insert(0, queue_item_gen(ref.track_id, ref.position, ref.playlist_id))

	def delete_track(self, ref: MenuTrackRef) -> None:
		tr = self.pctl.get_track(ref.track_id)
		fullpath = tr.fullpath

		if self.windows:
			fullpath = fullpath.replace("/", "\\")

		if tr.is_network:
			self.show_message(_("Cannot delete a network track"))
			return

		removed_positions = [i for i, track_id in enumerate(self.pctl.default_playlist) if track_id == ref.track_id]
		while ref.track_id in self.pctl.default_playlist:
			self.pctl.default_playlist.remove(ref.track_id)
		self.repair_playing_position_after_removals(removed_positions, self.pctl.active_playlist_viewing)

		try:
			send2trash(fullpath)

			if os.path.exists(fullpath):
				try:
					os.remove(fullpath)
					self.show_message(_("File deleted"), fullpath, mode="info")
				except Exception:
					logging.exception("Error deleting file")
					self.show_message(_("Error deleting file"), fullpath, mode="error")
			else:
				self.show_message(_("File moved to trash"))

		except Exception:
			try:
				os.remove(fullpath)
				self.show_message(_("File deleted"), fullpath, mode="info")
			except Exception:
				logging.exception("Error deleting file")
				self.show_message(_("Error deleting file"), fullpath, mode="error")

		self.reload()
		self.pctl.notify_database_changed()

	def rename_tracks_deco(self, _track_ref: MenuTrackRef) -> Decorator:
		if self.inp.key_shift_down or self.inp.key_shiftr_down:
			return Decorator(self.colours.menu_text, self.colours.menu_background, _("Rename (Single track)"))
		return Decorator(self.colours.menu_text, self.colours.menu_background, _("Rename Tracks…"))

	def activate_trans_editor(self) -> None:
		self.trans_edit_box.active = True

	def delete_folder(self, index: int, force: bool = False) -> None:
		track = self.pctl.master_library[index]

		if track.is_network:
			self.show_message(_("Cannot physically delete"), _("One or more tracks is from a network location!"), mode="info")
			return

		old = track.parent_folder_path

		if len(old) < 5:
			self.show_message(_("This folder path seems short, I don't wanna try delete that"), mode="warning")
			return

		if not os.path.exists(old):
			self.show_message(_("Error deleting folder. The folder seems to be missing."), _("It's gone! Just gone!"), mode="error")
			return

		protect = ("", "Documents", "Music", "Desktop", "Downloads")

		for fo in protect:
			if old.strip("\\/") == os.path.join(os.path.expanduser("~"), fo).strip("\\/"):
				self.show_message(_("Woah, careful there!"), _("I don't think we should delete that folder."), mode="warning")
				return

		if directory_size(old) > 1500000000:
			self.show_message(_("Delete size safety limit reached! (1.5GB)"), old, mode="warning")
			return

		try:
			if self.pctl.playing_state != PlayingState.STOPPED and os.path.normpath(
					self.pctl.master_library[self.pctl.track_queue[self.pctl.queue_step]].parent_folder_path) == os.path.normpath(old):
				self.pctl.stop(True)

			if force:
				shutil.rmtree(old)
			elif self.windows:
				send2trash(old.replace("/", "\\"))
			else:
				send2trash(old)

			for i in reversed(range(len(self.pctl.default_playlist))):

				if old == self.pctl.master_library[self.pctl.default_playlist[i]].parent_folder_path:
					del self.pctl.default_playlist[i]

			if not os.path.exists(old):
				if force:
					self.show_message(_("Folder deleted."), old, mode="done")
				else:
					self.show_message(_("Folder sent to trash."), old, mode="done")
			else:
				self.show_message(_("Hmm, its still there"), old, mode="error")

			if self.prefs.album_mode:
				self.prep_gal()
				self.reload_albums()

		except Exception:
			if force:
				logging.exception("Unable to comply, could not delete folder. Try checking permissions.")
				self.show_message(_("Unable to comply."), _("Could not delete folder. Try checking permissions."), mode="error")
			else:
				logging.exception("Folder could not be trashed, try again while holding shift to force delete.")
				self.show_message(_("Folder could not be trashed."), _("Try again while holding shift to force delete."),
					mode="error")

		self.tree_view_box.clear_target_pl(self.pctl.active_playlist_viewing)
		self.gui.pl_update += 1
		self.pctl.notify_database_changed()

	def rename_parent(self, index: int, template: str) -> None:
		# template = prefs.rename_folder_template
		template = template.strip("/\\")
		track = self.pctl.master_library[index]

		if track.is_network:
			self.show_message(_("Cannot rename"), _("One or more tracks is from a network location!"), mode="info")
			return

		old = track.parent_folder_path
		#logging.info(old)

		new = parse_template2(template, track)

		if len(new) < 1:
			self.show_message(_("Rename error."), _("The generated name is too short"), mode="warning")
			return

		if len(old) < 5:
			self.show_message(_("Rename error."), _("This folder path seems short, I don't wanna try rename that"), mode="warning")
			return

		if not os.path.exists(old):
			self.show_message(_("Rename Failed. The original folder is missing."), mode="warning")
			return

		protect = ("", "Documents", "Music", "Desktop", "Downloads")

		for fo in protect:
			if os.path.normpath(old) == os.path.normpath(os.path.join(os.path.expanduser("~"), fo)):
				self.show_message(_("Woah, careful there!"), _("I don't think we should rename that folder."), mode="warning")
				return

		logging.info(track.parent_folder_path)
		re = os.path.dirname(track.parent_folder_path.rstrip("/\\"))
		logging.info(re)
		new_parent_path = os.path.join(re, new)
		logging.info(new_parent_path)

		if os.path.normpath(new_parent_path) == os.path.normpath(old):
			self.show_message(_("The folder already has that name."))
			return

		if os.path.exists(new_parent_path):
			self.show_message(_("Rename Failed."), _("A folder with that name already exists"), mode="warning")
			return

		def path_contains(parent: str, child: str) -> bool:
			try:
				parent = os.path.normpath(parent)
				child = os.path.normpath(child)
				return child != parent and os.path.commonpath([parent, child]) == parent
			except ValueError:
				return False

		pre_state = 0
		affected_tracks: list[tuple[int, TrackClass, str, str, str]] = []

		for key, track_object in self.pctl.master_library.items():
			if track_object.fullpath == "":
				continue

			if old == track_object.parent_folder_path:
				new_fullpath = os.path.join(new_parent_path, track_object.filename)
				affected_tracks.append((key, track_object, new, new_parent_path, new_fullpath))

			# Fix any other tracks paths that contain the old path
			elif path_contains(old, track_object.fullpath):
				new_fullpath = os.path.join(new_parent_path, os.path.relpath(track_object.fullpath, old))
				new_folder_path = os.path.join(new_parent_path, os.path.relpath(track_object.parent_folder_path, old))
				affected_tracks.append((
					key,
					track_object,
					track_object.parent_folder_name,
					new_folder_path,
					new_fullpath,
				))

		if self.pctl.playing_state != PlayingState.STOPPED and self.pctl.track_queue:
			playing_track = self.pctl.track_queue[self.pctl.queue_step]
			if any(key == playing_track for key, *_ in affected_tracks):
				pre_state = self.pctl.stop(True)

		try:
			os.rename(old, new_parent_path)
			logging.info(new_parent_path)
		except Exception:
			logging.exception("Rename failed, something went wrong!")
			self.show_message(_("Rename Failed!"), _("Something went wrong, sorry."), mode="error")
			if pre_state == 1:
				self.pctl.revert()
			return

		for key, track_object, parent_folder_name, parent_folder_path, fullpath in affected_tracks:
			track_object.parent_folder_name = parent_folder_name
			track_object.parent_folder_path = parent_folder_path
			track_object.fullpath = fullpath

			self.search_string_cache.pop(key, None)
			self.search_dia_string_cache.pop(key, None)
			self.search_field_cache.pop(key, None)
			self.search_dia_field_cache.pop(key, None)

		self.show_message(_("Folder renamed."), _("Renamed to: {name}").format(name=new), mode="done")

		if pre_state == 1:
			self.pctl.revert()

		self.tree_view_box.clear_target_pl(self.pctl.active_playlist_viewing)
		self.pctl.notify_database_changed()

	def rename_folders_disable_test(self, ref: MenuTrackRef) -> bool:
		return self.pctl.get_track(ref.track_id).is_network

	def rename_folders(self, ref: MenuTrackRef) -> None:
		track_id = ref.track_id
		self.gui.track_box = False
		self.gui.rename_index = track_id

		if self.rename_folders_disable_test(ref):
			self.show_message(_("Not applicable for a network track."))
			return

		self.gui.rename_folder_box = True
		self.inp.input_text = ""
		self.gui.shift_selection.clear()

		self.inp.quick_drag = False
		self.gui.playlist_hold = False

	def move_folder_up(self, index: int, do: bool = False) -> bool | None:
		track = self.pctl.master_library[index]

		if track.is_network:
			self.show_message(_("Cannot move"), _("One or more tracks is from a network location!"), mode="info")
			return None

		parent_folder = os.path.dirname(track.parent_folder_path)
		folder_name = track.parent_folder_name
		move_target = track.parent_folder_path
		upper_folder = os.path.dirname(parent_folder)

		if not os.path.exists(track.parent_folder_path):
			if do:
				self.show_message(_("Error shifting directory"), _("The directory does not appear to exist"), mode="warning")
			return False

		if len(os.listdir(parent_folder)) > 1:
			return False

		if do is False:
			return True

		pre_state = 0
		if self.pctl.playing_state != PlayingState.STOPPED and track.parent_folder_path in self.pctl.playing_object().parent_folder_path:
			pre_state = self.pctl.stop(True)

		try:
			# Rename the track folder to something temporary
			os.rename(move_target, os.path.join(parent_folder, "RMTEMP000"))

			# Move the temporary folder up 2 levels
			shutil.move(os.path.join(parent_folder, "RMTEMP000"), upper_folder)

			# Delete the old directory that contained the original folder
			shutil.rmtree(parent_folder)

			# Rename the moved folder back to its original name
			os.rename(os.path.join(upper_folder, "RMTEMP000"), os.path.join(upper_folder, folder_name))

		except Exception as e:
			logging.exception("System Error!")
			self.show_message(_("System Error!"), str(e), mode="error")

		# Fix any other tracks paths that contain the old path
		old = track.parent_folder_path
		new_parent_path = os.path.join(upper_folder, folder_name)
		for key, object in self.pctl.master_library.items():
			if os.path.normpath(object.fullpath)[:len(old)] == os.path.normpath(old) \
					and os.path.normpath(object.fullpath)[len(old)] in ("/", "\\"):
				object.fullpath = os.path.join(new_parent_path, object.fullpath[len(old):].lstrip("\\/"))
				object.parent_folder_path = os.path.join(
					new_parent_path, object.parent_folder_path[len(old):].lstrip("\\/"))

				self.search_string_cache.pop(object.index, None)
				self.search_dia_string_cache.pop(object.index, None)
				self.search_field_cache.pop(object.index, None)
				self.search_dia_field_cache.pop(object.index, None)

				logging.info(object.fullpath)
				logging.info(object.parent_folder_path)

		if pre_state == 1:
			self.pctl.revert()
		return None

	def clean_folder(self, index: int, do: bool = False) -> int | None:
		track = self.pctl.master_library[index]

		if track.is_network:
			self.show_message(_("Cannot clean"), _("One or more tracks is from a network location!"), mode="info")
			return None

		folder = track.parent_folder_path
		found = 0
		to_purge = []
		if not os.path.isdir(folder):
			return 0
		try:
			for item in os.listdir(folder):
				if (item[:8] == "AlbumArt" and ".jpg" in item.lower()) or item in ("desktop.ini", "Thumbs.db", ".DS_Store"):

					to_purge.append(item)
					found += 1
				elif item == "__MACOSX" and os.path.isdir(os.path.join(folder, item)):
					found += 1
					found += 1
					if do:
						logging.info(f"Deleting Folder: {os.path.join(folder, item)}")
						shutil.rmtree(os.path.join(folder, item))

			if do:
				for item in to_purge:
					if os.path.isfile(os.path.join(folder, item)):
						logging.info(f"Deleting File: {os.path.join(folder, item)}")
						os.remove(os.path.join(folder, item))
				# self.clear_img_cache()

				for track_id in self.pctl.default_playlist:
					if self.pctl.get_track(track_id).parent_folder_path == folder:
						self.clear_track_image_cache(self.pctl.get_track(track_id))

		except Exception:
			logging.exception("Error deleting files, may not have permission or file may be set to read-only")
			self.show_message(_("Error deleting files."), _("May not have permission or file may be set to read-only"), mode="warning")
			return 0

		return found

	def reset_play_count(self, index: int) -> None:
		self.star_store.remove(index)

	def vacuum_playtimes(self, ref: MenuTrackRef) -> None:
		track_id = ref.track_id
		todo = []
		for k in self.pctl.default_playlist:
			if self.pctl.master_library[track_id].parent_folder_name == self.pctl.master_library[k].parent_folder_name:
				todo.append(k)

		for track in todo:
			tr = self.pctl.get_track(track)

			total_playtime = 0
			flags = ""

			to_del = []

			for key, value in self.star_store.db.items():
				if key[0].lower() == tr.artist.lower() and tr.artist and key[1].lower().replace(
					" ", "") == tr.title.lower().replace(
					" ", "") and tr.title:
					to_del.append(key)
					total_playtime += value.playtime

			for key in to_del:
				del self.star_store.db[key]

			key = self.star_store.object_key(tr)
			value = StarRecord(playtime=total_playtime)
			if key not in self.star_store.db:
				logging.info("Saving value")
				self.star_store.db[key] = value
			else:
				logging.error("KEY ALREADY HERE?")

	def intel_moji(self, ref: MenuTrackRef) -> None:
		self.gui.pl_update += 1
		self.gui.update += 1

		index = ref.track_id
		track = self.pctl.master_library[index]
		lot = []

		for item in self.pctl.default_playlist:
			if track.album == self.pctl.master_library[item].album and \
					track.parent_folder_name == self.pctl.master_library[item].parent_folder_name:
				lot.append(item)

		lot = set(lot)

		l_artist = track.artist.encode("Latin-1", "ignore")
		l_album = track.album.encode("Latin-1", "ignore")
		detect = None

		if track.artist not in track.parent_folder_path:
			for enc in self.encodings:
				try:
					q_artist = l_artist.decode(enc)
					if q_artist.strip(" ") in track.parent_folder_path.strip(" "):
						detect = enc
						break
				except Exception:
					logging.exception("Error decoding artist")
					continue

		if detect is None and track.album not in track.parent_folder_path:
			for enc in self.encodings:
				try:
					q_album = l_album.decode(enc)
					if q_album in track.parent_folder_path:
						detect = enc
						break
				except Exception:
					logging.exception("Error decoding album")
					continue

		for item in lot:
			t_track = self.pctl.master_library[item]

			if detect is None:
				for enc in self.encodings:
					test = recode(t_track.artist, enc)
					for cha in test:
						if cha in j_chars:
							detect = enc
							logging.info(f"This looks like Japanese: {test}")
							break
						if detect is not None:
							break

			if detect is None:
				for enc in self.encodings:
					test = recode(t_track.title, enc)
					for cha in test:
						if cha in j_chars:
							detect = enc
							logging.info(f"This looks like Japanese: {test}")
							break
						if detect is not None:
							break
			if detect is not None:
				break

		if detect is not None:
			logging.info(f"Fix Mojibake: Detected encoding as: {detect}")
			for item in lot:
				track = self.pctl.master_library[item]
				# key = self.pctl.master_library[item].title + self.pctl.master_library[item].filename
				key = self.star_store.full_get(item)
				self.star_store.remove(item)

				track.title = recode(track.title, detect)
				track.album = recode(track.album, detect)
				track.artist = recode(track.artist, detect)
				track.album_artist = recode(track.album_artist, detect)
				track.genre = recode(track.genre, detect)
				track.comment = recode(track.comment, detect)
				track.lyrics = recode(track.lyrics, detect)

				if key is not None:
					self.star_store.insert(item, key)

				self.search_string_cache.pop(track.index, None)
				self.search_dia_string_cache.pop(track.index, None)
				self.search_field_cache.pop(track.index, None)
				self.search_dia_field_cache.pop(track.index, None)
		else:
			self.show_message(_("Autodetect failed"))

	def sel_to_car(self) -> None:
		self.pctl.cargo = []

		for item in self.gui.shift_selection:
			self.pctl.cargo.append(self.pctl.default_playlist[item])

	def cut_selection(self) -> None:
		self.sel_to_car()
		self.del_selected()

	def clip_ar_al(self, index: int) -> None:
		line = self.pctl.master_library[index].artist + " - " + self.pctl.master_library[index].album
		sdl3.SDL_SetClipboardText(line.encode("utf-8"))

	def clip_ar(self, index: int) -> None:
		if self.pctl.master_library[index].album_artist:
			line = self.pctl.master_library[index].album_artist
		else:
			line = self.pctl.master_library[index].artist
		sdl3.SDL_SetClipboardText(line.encode("utf-8"))

	def clip_title(self, ref: MenuTrackRef) -> None:
		n_track = self.pctl.get_track(ref.track_id)

		if not self.prefs.use_title and n_track.album_artist and n_track.album:
			line = n_track.album_artist + " - " + n_track.album
		else:
			line = n_track.parent_folder_name
		sdl3.SDL_SetClipboardText(line.encode("utf-8"))

	def lightning_copy(self) -> None:
		self.s_copy()
		self.gui.lightning_copy = True

	def transcode_deco(self) -> Decorator:
		if self.inp.key_shift_down or self.inp.key_shiftr_down:
			return Decorator(self.colours.menu_text, self.colours.menu_background, _("Transcode Single"))
		return Decorator(self.colours.menu_text, self.colours.menu_background, _("Transcode Folder"))

	def selection_queue_deco(self) -> Decorator:
		total = 0
		for item in self.gui.shift_selection:
			total += self.pctl.get_track(self.pctl.default_playlist[item]).length

		total = get_hms_time(total)
		text = (_("Queue {N}").format(N=len(self.gui.shift_selection))) + f" [{total}]"
		return Decorator(self.colours.menu_text, self.colours.menu_background, text)

	def ser_band_done(self, result: str) -> None:
		if result:
			webbrowser.open(result, new=2, autoraise=True)
			self.gui.message_box = False
			self.gui.update += 1
		else:
			self.show_message(_("No matching artist result found"))

	def ser_band(self, ref: MenuTrackRef) -> None:
		tr = self.pctl.get_track(ref.track_id)
		if tr.artist:
			shoot_dl = threading.Thread(target=bandcamp_search, args=([tr.artist, self.ser_band_done]))
			shoot_dl.daemon = True
			shoot_dl.start()
			self.show_message(_("Searching..."))

	def ser_rym(self, ref: MenuTrackRef) -> None:
		index = ref.track_id
		if len(self.pctl.master_library[index].artist) < 2:
			return
		line = "https://rateyourmusic.com/search?searchtype=a&searchterm=" + urllib.parse.quote(
			self.pctl.master_library[index].artist)
		webbrowser.open(line, new=2, autoraise=True)

	def vis_off(self) -> None:
		self.gui.vis_want = 0
		self.gui.update_layout = True
		# self.gui.turbo = False

	def level_on(self) -> None:
		if self.gui.vis_want == 1 and self.gui.turbo is True:
			self.gui.level_meter_colour_mode += 1
			if self.gui.level_meter_colour_mode > 4:
				self.gui.level_meter_colour_mode = 0

		self.gui.vis_want = 1
		self.gui.update_layout = True
		# if self.prefs.backend == Backend.GSTREAMER:
		# 	self.show_message("Visualisers not implemented in GStreamer mode")
		# self.gui.turbo = True

	def spec_on(self) -> None:
		self.gui.vis_want = 2
		# if self.prefs.backend == Backend.GSTREAMER:
		# 	self.show_message("Not implemented")
		self.gui.update_layout = True

	def spec2_def(self) -> None:
		if self.gui.vis_want == 3:
			self.prefs.spec2_colour_mode += 1
			if self.prefs.spec2_colour_mode > 1:
				self.prefs.spec2_colour_mode = 0

		self.gui.vis_want = 3
		if self.prefs.backend == Backend.GSTREAMER:
			self.show_message(_("Not implemented"))
		# self.gui.turbo = True
		self.prefs.spec2_colour_setting = "custom"
		self.gui.update_layout = True

	def sa_regen_menu(self) -> None:
		"""Recreate the column select menu to correctly populate the checkbox state"""
		set_menu = self.set_menu
		set_menu.subs = []
		set_menu.sub_number = 0
		set_menu.items = []
		checked = set()
		fields = [
			[_("Artist"),"Artist", self.sa_artist],
			[_("Title"), "Title", self.sa_title],
			[_("Album"), "Album", self.sa_album],
			[_("Album Art"), "Album Art", self.sa_album_art],
			[_("Duration"), "Time", self.sa_time],
			[_("Date"), "Date", self.sa_date],
			[_("Genre"), "Genre", self.sa_genre],
			[_("Track Number"), "#", self.sa_track],
			[_("Play Count"), "P", self.sa_count],
			[_("Codec"), "Codec", self.sa_codec],
			[_("Bitrate"), "Bitrate", self.sa_bitrate],
			[_("Filename"), "Filename", self.sa_filename],
			[_("Starline"), "Starline", self.sa_star],
			[_("Rating"), "Rating", self.sa_rating],
			[_("Loved"), "❤", self.sa_love],
			[_("Album Artist"), "Album Artist", self.sa_album_artist],
			[_("Comment"), "Comment", self.sa_comment],
			[_("Filepath"), "Filepath", self.sa_file],
			[_("Scrobble Count"), "S", self.sa_scrobbles],
			[_("Composer"), "Composer", self.sa_composer],
			[_("Disc Number"), "Disc", self.sa_disc],
			[_("Has Lyrics"), "Lyrics", self.sa_lyrics],
			[_("Is CUE Sheet"), "CUE", self.sa_cue],
			[_("Internal Track ID"), "ID", self.sa_track_id],
			[_("File Changed"), "=/=", self.sa_modify_date],
		]
		for checked_column in self.gui.pl_st:
			checked.add( checked_column[0] )

		set_menu.add(MenuItem(_("Auto Resize"), self.auto_size_columns))
		set_menu.add(MenuItem(_("Hide bar"), self.hide_set_bar))
		# Magnet Mode: draw the next column's text immediately after this column's
		# text (packed left, standard-layout style) instead of confining it to its
		# own column. Only meaningful when there is a column to the right.
		set_menu.add(MenuItem(_("Enable Magnet Mode"), self.sa_toggle_magnet, pass_ref=True, show_test=self.sa_magnet_off_test))
		set_menu.add(MenuItem(_("Disable Magnet Mode"), self.sa_toggle_magnet, pass_ref=True, show_test=self.sa_magnet_on_test))
		set_menu.br()
		set_menu.add(MenuItem("- " + _("Remove This"), self.sa_remove, pass_ref=True))
		set_menu.br()

		for i, c in enumerate(fields):
			# The menu is rebuilt on each activation, so the column state can
			# be captured at build time.
			item = MenuItem(c[0], c[2], check_test=(lambda on=(c[1] in checked): on))
			if i < 14:
				set_menu.add(item)
			if i == 14:
				set_menu.add_sub("+ " + _("More…"), 150)
			if i >= 14:
				set_menu.add_to_sub(0, item)


	def sa_remove(self, h: int) -> None:
		if len(self.gui.pl_st) > 1:
			del self.gui.pl_st[h]
			self.gui.update_layout = True
			self.sa_regen_menu()
		else:
			self.show_message(_("Cannot remove the only column."))

	def sa_magnet_eligible(self, ref: object) -> bool:
		"""A column can hold magnet mode only if there is a column to its right."""
		return isinstance(ref, int) and 0 <= ref < len(self.gui.pl_st) - 1

	def sa_magnet_is_on(self, ref: object) -> bool:
		if not self.sa_magnet_eligible(ref):
			return False
		item = self.gui.pl_st[ref]
		return len(item) > 3 and bool(item[3])

	def sa_magnet_off_test(self, ref: object) -> bool:
		return self.sa_magnet_eligible(ref) and not self.sa_magnet_is_on(ref)

	def sa_magnet_on_test(self, ref: object) -> bool:
		return self.sa_magnet_is_on(ref)

	def sa_toggle_magnet(self, ref: int) -> None:
		if not self.sa_magnet_eligible(ref):
			return
		item = self.gui.pl_st[ref]
		while len(item) < 4:
			item.append(False)
		item[3] = not item[3]
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_try_uncheck(self, field: str) -> bool:
		unchecks = [] # you could have multiple copies of the same column
		for i, column in enumerate(self.gui.pl_st):
			if column[0] == field:
				unchecks.append(i)
		unchecks.reverse()
		for column in unchecks:
			self.sa_remove(column)
		return bool(unchecks)

	def sa_artist(self) -> None:
		if not self.sa_try_uncheck("Artist"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Artist", 220, False])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_album_artist(self) -> None:
		if not self.sa_try_uncheck("Album Artist"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Album Artist", 220, False])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_composer(self) -> None:
		if not self.sa_try_uncheck("Composer"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Composer", 220, False])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_title(self) -> None:
		if not self.sa_try_uncheck("Title"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Title", 220, False])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_album(self) -> None:
		if not self.sa_try_uncheck("Album"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Album", 220, False])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_album_art(self) -> None:
		if not self.sa_try_uncheck("Album Art"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Album Art", 140, False])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_comment(self) -> None:
		if not self.sa_try_uncheck("Comment"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Comment", 300, False])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_track(self) -> None:
		if not self.sa_try_uncheck("#"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["#", 25, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_count(self) -> None:
		if not self.sa_try_uncheck("P"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["P", 25, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_scrobbles(self) -> None:
		if not self.sa_try_uncheck("S"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["S", 25, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_time(self) -> None:
		if not self.sa_try_uncheck("Time"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Time", 55, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_date(self) -> None:
		if not self.sa_try_uncheck("Date"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Date", 95, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_genre(self) -> None:
		if not self.sa_try_uncheck("Genre"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Genre", 150, False])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_file(self) -> None:
		if not self.sa_try_uncheck("Filepath"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Filepath", 350, False])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_filename(self) -> None:
		if not self.sa_try_uncheck("Filename"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Filename", 300, False])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_codec(self) -> None:
		if not self.sa_try_uncheck("Codec"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Codec", 65, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_bitrate(self) -> None:
		if not self.sa_try_uncheck("Bitrate"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Bitrate", 65, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_lyrics(self) -> None:
		if not self.sa_try_uncheck("Lyrics"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Lyrics", 50, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_cue(self) -> None:
		if not self.sa_try_uncheck("CUE"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["CUE", 50, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_star(self) -> None:
		if not self.sa_try_uncheck("Starline"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Starline", 80, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_disc(self) -> None:
		if not self.sa_try_uncheck("Disc"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Disc", 50, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_rating(self) -> None:
		if not self.sa_try_uncheck("Rating"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["Rating", 80, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_love(self) -> None:
		if not self.sa_try_uncheck("❤"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["❤", 25, True])
		# self.gui.pl_st.append(["❤", 25, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_track_id(self) -> None:
		if not self.sa_try_uncheck("ID"):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["ID", 55, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def sa_modify_date(self) -> None:
		if not self.sa_try_uncheck("=/="):
			self.gui.pl_st.insert(self.set_menu.reference + 1, ["=/=", 65, True])
		self.gui.update_layout = True
		self.sa_regen_menu()

	def key_love(self, index: int) -> bool:
		return self.get_love_index(index)

	def key_artist(self, index: int) -> str:
		return self.pctl.master_library[index].artist.lower()

	def key_album_artist(self, index: int) -> str:
		return self.pctl.master_library[index].album_artist.lower()

	def key_composer(self, index: int) -> str:
		return self.pctl.master_library[index].composer.lower()

	def key_comment(self, index: int) -> str:
		return self.pctl.master_library[index].comment

	def key_title(self, index: int) -> str:
		return self.pctl.master_library[index].title.lower()

	def key_album(self, index: int) -> str:
		return self.pctl.master_library[index].album.lower()

	def key_duration(self, index: int) -> float:
		return self.pctl.master_library[index].length

	def key_date(self, index: int) -> str:
		return self.pctl.master_library[index].date

	def key_genre(self, index: int) -> str:
		return self.pctl.master_library[index].genre.lower()

	def key_t(self, index: int) -> list[int | str] | Literal["a"]:
		# return str(self.pctl.master_library[index].track_number)
		return self.pctl.index_key(index)

	def key_codec(self, index: int) -> str:
		return self.pctl.master_library[index].file_ext

	def key_bitrate(self, index: int) -> int:
		return self.pctl.master_library[index].bitrate

	def key_hl(self, index: int) -> int:
		if len(self.pctl.master_library[index].lyrics) > 5:
			return 0
		return 1

	def key_modify_date(self, index: int) -> float:
		return self.pctl.master_library[index].modified_time

	def sort_dec(self, h: int) -> None:
		self.sort_ass(h, True)

	def sort_ass(self, h: int, invert: bool = False, custom_list: list[int] | None = None, custom_name: str = "") -> None:
		if custom_list is None:
			if self.pl_is_locked(self.pctl.active_playlist_viewing):
				self.show_message(_("Playlist is locked"))
				return

			name = self.gui.pl_st[h][0]
			playlist = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].playlist_ids
		else:
			name = custom_name
			playlist = custom_list

		key = None
		ns = False

		if self.use_natsort:
			natsort = sys.modules.get("natsort")  # Fetch from loaded modules

		if name == "Filepath":
			key = self.key_filepath
			if self.use_natsort:
				key = self.key_fullpath
				ns = True
		if name == "Filename":
			key = self.key_filepath  # self.key_filename
			if self.use_natsort:
				key = self.key_fullpath
				ns = True
		if name == "Artist":
			key = self.key_artist
		if name == "Album Artist":
			key = self.key_album_artist
		if name == "Title":
			key = self.key_title
		if name == "Album":
			key = self.key_album
		if name == "Composer":
			key = self.key_composer
		if name == "Time":
			key = self.key_duration
		if name == "Date":
			key = self.key_date
		if name == "Genre":
			key = self.key_genre
		if name == "#":
			key = self.key_t
		if name == "S":
			key = self.key_scrobbles
		if name == "P":
			key = self.key_playcount
		if name == "Starline":
			key = self.best
		if name == "Rating":
			key = self.key_rating
		if name == "Comment":
			key = self.key_comment
		if name == "Codec":
			key = self.key_codec
		if name == "Bitrate":
			key = self.key_bitrate
		if name == "Lyrics":
			key = self.key_hl
		if name == "❤":
			key = self.key_love
		if name == "Disc":
			key = self.key_disc
		if name == "CUE":
			key = self.key_cue
		if name == "ID":
			key = self.key_track_id
		if name == "=/=":
			key = self.key_modify_date

		if custom_list is None:
			if key is not None:
				if ns:
					key = natsort.natsort_keygen(key=key, alg=natsort.PATH|natsort.U|natsort.I)

				playlist.sort(key=key, reverse=invert)

				self.pctl.multi_playlist[self.pctl.active_playlist_viewing].playlist_ids = playlist
				self.pctl.default_playlist = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].playlist_ids

				self.pctl.playlist_view_position = 0
				logging.debug("Position changed by sort")
				self.gui.pl_update = 1
		elif custom_list is not None:
			playlist.sort(key=key, reverse=invert)
		self.reload()

	def stt2(self, sec: int) -> str:
		"""Converts seconds into days hours minutes"""
		days, rem = divmod(sec, 86400)
		hours, rem = divmod(rem, 3600)
		min, sec = divmod(rem, 60)

		s_day = str(days) + "d"
		if s_day == "0d":
			s_day = "  "

		s_hours = str(hours) + "h"
		if s_hours == "0h" and s_day == "  ":
			s_hours = "  "

		s_min = str(min) + "m"
		return s_day.rjust(3) + " " + s_hours.rjust(3) + " " + s_min.rjust(3)

	def export_database(self) -> None:
		path = self.user_directory / "DatabaseExport.csv"
		xport = path.open("w", encoding="utf-8")

		xport.write("Artist,Title,Album,Album artist,Track number,Type,Duration,Release date,Genre,Playtime,File path")

		for index, track in self.pctl.master_library.items():
			xport.write("\n")
			xport.write(csv_string(track.artist) + ",")
			xport.write(csv_string(track.title) + ",")
			xport.write(csv_string(track.album) + ",")
			xport.write(csv_string(track.album_artist) + ",")
			xport.write(csv_string(track.track_number) + ",")
			track_type = "File"
			if track.is_network:
				track_type = "Network"
			elif track.is_cue:
				track_type = "CUE File"
			xport.write(track_type + ",")
			xport.write(str(track.length) + ",")
			xport.write(csv_string(track.date) + ",")
			xport.write(csv_string(track.genre) + ",")
			xport.write(str(int(self.star_store.get_by_object(track))) + ",")
			xport.write(csv_string(track.fullpath))

		xport.close()
		self.show_message(_("Export complete."), _("Saved as: ") + str(path), mode="done")

	def q_to_playlist(self) -> None:
		self.pctl.multi_playlist.append(self.pl_gen(
			title=_("Play History"),
			playing=0,
			playlist_ids=list(reversed(copy.deepcopy(self.pctl.track_queue))),
			position=0,
			hide_title=True,
			selected=0))

	def clean_db(self) -> None:
		self.prefs.remove_network_tracks = False
		self.cm_clean_db = True
		self.thread_manager.ready("worker")

	def clean_db2(self) -> None:
		self.prefs.remove_network_tracks = True
		self.cm_clean_db = True
		self.thread_manager.ready("worker")

	def import_fmps(self) -> None:
		"""FMPS spec: https://gitorious.org/xdg-specs/xdg-specs/trees/master/specifications/FMPSpecs?p=xdg-specs:xdg-specs.git;a=blob;f=specifications/FMPSpecs/specification.txt;hb=HEAD#l235"""
		unique = set()
		for playlist in self.pctl.multi_playlist:
			for id in playlist.playlist_ids:
				tr = self.pctl.get_track(id)
				if tr.FMPS_Rating is not None:
					if tr.FMPS_Rating > 1 or tr.FMPS_Rating < 0:
						logging.warning(f"Nonstandard FMPS_RATING in track, skipping {tr.fullpath}: {tr.FMPS_Rating}")
						continue
					rating = round(tr.FMPS_Rating * 10)
					self.star_store.set_rating(tr.index, rating)
					unique.add(tr.index)

		self.show_message(_("{N} ratings imported").format(N=str(len(unique))), mode="done")

		self.gui.pl_update += 1

	def import_popm(self) -> None:
		unique = set()
		skipped = set()
		for playlist in self.pctl.multi_playlist:
			for id in playlist.playlist_ids:
				tr = self.pctl.get_track(id)
				if tr.POPM is not None:
					rating = tr.POPM
					t_rating = 0
					if rating <= 1:
						t_rating = 2
					elif rating <= 64:
						t_rating = 4
					elif rating <= 128:
						t_rating = 6
					elif rating <= 196:
						t_rating = 8
					elif rating <= 255:
						t_rating = 10

					if self.star_store.get_rating(tr.index) == 0:
						self.star_store.set_rating(tr.index, t_rating)
						unique.add(tr.index)
					else:
						logging.info("Won't import POPM because track is already rated")
						skipped.add(tr.index)

		s = str(len(unique)) + " ratings imported"
		if len(skipped) > 0:
			s += f", {len(skipped)} skipped"
		self.show_message(s, mode="done")

		self.gui.pl_update += 1

	def clear_ratings(self) -> None:
		if not self.inp.key_shift_down:
			self.show_message(
				_("This will delete all track and album ratings from the local database!"),
				_("Press button again while holding shift key if you're sure you want to do that."),
				mode="warning")
			return
		for key, star in self.star_store.db.items():
			star.rating = 0
		self.album_star_store.db.clear()
		self.gui.pl_update += 1

	def find_incomplete(self) -> None:
		self.gen_incomplete(self.pctl.active_playlist_viewing)

	def cast_deco(self) -> Decorator:
		line_colour = self.colours.menu_text
		if self.chrome_mode:
			return Decorator(line_colour, self.colours.menu_background, _("Stop Cast"))  # [24, 25, 60, 255]
		return Decorator(line_colour, self.colours.menu_background, None)

	def cast_search2(self) -> None:
		self.chrome.rescan()

	def cast_search(self) -> None:
		if self.chrome_mode:
			self.pctl.stop()
			self.chrome.end()
		else:
			if not self.chrome:
				self.show_message(_("pychromecast not found"))
				return
			self.show_message(_("Searching for Chromecasts..."))
			shooter(self.cast_search2)

	def clear_queue(self) -> None:
		self.pctl.force_queue = []
		self.gui.pl_update = 1
		self.pctl.pause_queue = False

	def set_mini_mode_A1(self) -> None:
		self.prefs.mini_mode_mode = MiniModeMode.MINI
		self.set_mini_mode()

	def set_mini_mode_B1(self) -> None:
		self.prefs.mini_mode_mode = MiniModeMode.SQUARE
		self.set_mini_mode()

	def set_mini_mode_A2(self) -> None:
		self.prefs.mini_mode_mode = MiniModeMode.LARGE
		self.set_mini_mode()

	def set_mini_mode_C1(self) -> None:
		self.prefs.mini_mode_mode = MiniModeMode.SLATE
		self.set_mini_mode()

	def set_mini_mode_B2(self) -> None:
		self.prefs.mini_mode_mode = MiniModeMode.SQUARE_LARGE
		self.set_mini_mode()

	def set_mini_mode_D(self) -> None:
		self.prefs.mini_mode_mode = MiniModeMode.TAB
		self.set_mini_mode()

	def set_mini_mode_E(self) -> None:
		self.prefs.mini_mode_mode = MiniModeMode.SIGNAL
		self.set_mini_mode()

	def copy_bb_metadata(self) -> str | None:
		tr = self.pctl.playing_object()
		if tr is None:
			return None
		if not tr.title and not tr.artist and self.pctl.playing_state == PlayingState.URL_STREAM:
			return self.pctl.tag_meta
		text = f"{tr.artist} - {tr.title}".strip(" -")
		if text:
			copy_to_clipboard(text)
		else:
			self.show_message(_("No metadata available to copy"))
		return None

	def stop(self) -> None:
		self.pctl.stop()

	def stop_mode_off(self) -> None:
		self.pctl.stop_mode = StopMode.OFF
		self.pctl.stop_ref = None

	def stop_mode_track(self) -> None:
		self.pctl.stop_mode = StopMode.TRACK
		self.pctl.stop_ref = None

	def stop_mode_album(self) -> None:
		self.pctl.stop_mode = StopMode.ALBUM

	def stop_mode_track_persist(self) -> None:
		self.pctl.stop_mode = StopMode.TRACK_PERSIST
		self.pctl.stop_ref = None

	def stop_mode_album_persist(self) -> None:
		tr = self.pctl.playing_object()
		if tr:
			self.pctl.stop_mode = StopMode.ALBUM_PERSIST
			self.pctl.stop_ref = (tr.parent_folder_path, tr.album)

	def random_track(self) -> None:
		playlist = self.pctl.multi_playlist[self.pctl.active_playlist_playing].playlist_ids
		if playlist:
			random_position = random.randrange(0, len(playlist))
			track_id = playlist[random_position]
			self.pctl.jump(track_id, random_position)
			self.pctl.show_current()

	def random_album(self) -> None:
		folders = {}
		playlist = self.pctl.multi_playlist[self.pctl.active_playlist_playing].playlist_ids
		if playlist:
			for i, id in enumerate(playlist):
				track = self.pctl.get_track(id)
				if track.parent_folder_path not in folders:
					folders[track.parent_folder_path] = (id, i)

			key = random.choice(list(folders.keys()))
			result = folders[key]
			self.pctl.jump(*result)
			self.pctl.show_current()

	def radio_random(self) -> None:
		self.pctl.advance(rr=True)

	def heart_menu_colour(self) -> ColourRGBA | None:
		if self.pctl.playing_state not in (PlayingState.PLAYING, PlayingState.PAUSED):
			if self.colours.lm:
				return ColourRGBA(255, 150, 180, 255)
			return None
		if self.love(False):
			return ColourRGBA(245, 60, 60, 255)
		if self.colours.lm:
			return ColourRGBA(255, 150, 180, 255)
		return None

	def activate_search_overlay(self) -> None:
		if self.cm_clean_db:
			self.show_message(_("Please wait for cleaning process to finish"))
			return
		self.search_over.active = True
		self.search_over.delay_enter = False
		self.search_over.search_text.selection = 0
		self.search_over.search_text.cursor_position = 0

	def goto_playing_extra(self) -> None:
		self.pctl.show_current(highlight=True)

	def level_meter_special_2(self) -> None:
		self.gui.level_meter_colour_mode = 2

	def last_fm_menu_deco(self) -> Decorator:
		if self.prefs.scrobble_hold:
			if not self.prefs.auto_lfm and self.lb.enable:
				line = _("ListenBrainz is Paused")
			else:
				line = _("Scrobbling is Paused")
			bg = self.colours.menu_background
		else:
			if not self.prefs.auto_lfm and self.lb.enable:
				line = _("ListenBrainz is Active")
			else:
				line = _("Scrobbling is Active")

			bg = self.colours.menu_background
		return Decorator(self.colours.menu_text, bg, line)

	def lastfm_colour(self) -> ColourRGBA | None:
		if not self.prefs.scrobble_hold:
			return ColourRGBA(250, 50, 50, 255)
		return None

	def lastfm_menu_test(self, _: int) -> bool:
		return bool((self.prefs.auto_lfm and self.prefs.last_fm_token is not None) or self.prefs.enable_lb or self.prefs.maloja_enable)

	def lb_mode(self) -> bool:
		return self.prefs.enable_lb

	def get_album_art_url(self, tr: TrackClass) -> str | None:
		artist = tr.album_artist
		if not tr.album:
			return None
		if not artist:
			artist = tr.artist
		if not artist:
			return None

		# Embedded MBIDs are the most accurate source, check first
		release_id = tr.musicbrainz_albumid
		release_group_id = tr.musicbrainz_releasegroupid
		had_embedded_mbid = bool(release_id or release_group_id)

		# If no embedded MBIDs, fall back to the name-based cache
		if not had_embedded_mbid:
			cache_key = (artist, tr.album)
			if cache_key in self.pctl.album_mbid_release_cache or cache_key in self.pctl.album_mbid_release_group_cache:
				release_id = self.pctl.album_mbid_release_cache.get(cache_key)
				release_group_id = self.pctl.album_mbid_release_group_cache.get(cache_key)
				if release_id is None and release_group_id is None:
					return None

		if not release_group_id:
			try:
				s = musicbrainzngs.search_release_groups(tr.album, artist=artist, limit=1)
				release_group_id = s["release-group-list"][0]["id"]
				tr.musicbrainz_releasegroupid = release_group_id
			except Exception:
				logging.exception("Error lookup mbid for discord")
				if not had_embedded_mbid:
					self.pctl.album_mbid_release_group_cache[(artist, tr.album)] = None

		if not release_id:
			try:
				s = musicbrainzngs.search_releases(tr.album, artist=artist, limit=1)
				release_id = s["release-list"][0]["id"]
				tr.musicbrainz_albumid = release_id
			except Exception:
				logging.exception("Error lookup mbid for discord")
				if not had_embedded_mbid:
					self.pctl.album_mbid_release_cache[(artist, tr.album)] = None

		image_data = None
		final_id = None
		if release_group_id:
			url = self.pctl.mbid_image_url_cache.get(release_group_id)
			if url:
				return url

			base_url = "https://coverartarchive.org/release-group/"
			url = f"{base_url}{release_group_id}"

			try:
				#logging.info("lookup image url from release group")
				response = requests.get(url, timeout=10)
				response.raise_for_status()
				image_data = response.json()
				final_id = release_group_id
			except (requests.RequestException, ValueError):
				logging.exception("No image found for release group")
				self.pctl.album_mbid_release_group_cache[(artist, tr.album)] = None
			except Exception:
				logging.exception("Unknown error finding image for release group")

		if release_id and not image_data:
			url = self.pctl.mbid_image_url_cache.get(release_id)
			if url:
				return url

			base_url = "https://coverartarchive.org/release/"
			url = f"{base_url}{release_id}"

			try:
				response = requests.get(url, timeout=10)
				response.raise_for_status()
				image_data = response.json()
				final_id = release_id
			except (requests.RequestException, ValueError):
				logging.exception("No image found for album id")
				self.pctl.album_mbid_release_cache[(artist, tr.album)] = None
			except Exception:
				logging.exception("Unknown error getting image found for album id")

		if image_data:
			for image in image_data["images"]:
				if image.get("front") and ("250" in image["thumbnails"] or "small" in image["thumbnails"]):
					self.pctl.album_mbid_release_cache[(artist, tr.album)] = release_id
					self.pctl.album_mbid_release_group_cache[(artist, tr.album)] = release_group_id

					url = image["thumbnails"].get("250")
					if url is None:
						url = image["thumbnails"].get("small")

					if url:
						logging.info("got mb image url for discord")
						self.pctl.mbid_image_url_cache[final_id] = url
						return url

		self.pctl.album_mbid_release_cache[(artist, tr.album)] = None
		self.pctl.album_mbid_release_group_cache[(artist, tr.album)] = None

		return None

	def clean_track_title(self, title: str | None) -> str:
		if not title:
			return ""

		cleaned = title.strip()
		cleaned = re.sub(r"[\(\[\{].*?[\)\]\}]", "", cleaned)
		cleaned = re.split(r"\s*(?:ft\.?|feat\.?|featuring|with)\b", cleaned, flags=re.I)[0]
		if " - " in cleaned:
			left, right = cleaned.split(" - ", 1)
			if re.search(r"(?i)(remix|mix|version|edit|remaster|remastered|live|acoustic|instrumental|single|radio|bonus)", right):
				cleaned = left
		cleaned = cleaned.strip()
		cleaned = re.sub(r"[-–—:;]+$", "", cleaned).strip()
		return cleaned or title.strip()

	def discord_loop(self) -> None:
		try:
			from tauon.t_modules.t_discord import discord_loop_entrypoint
		except Exception:
			logging.exception("Failed to import Discord module")
			if hasattr(self, "_discord_loop_guard_lock"):
				with self._discord_loop_guard_lock:
					self._discord_loop_guard_running = False
					self.prefs.discord_active = False
			return

		discord_loop_entrypoint(self)

	# def discord_loop(self) -> None:
	# 	self.prefs.discord_active = True

	# 	try:
	# 		if not self.pctl.playing_ready():
	# 			return
	# 		asyncio.set_event_loop(asyncio.new_event_loop())

	# 		# logging.info("Attempting to connect to Discord...")
	# 		client_id = "954253873160286278"
	# 		RPC = Presence(client_id)
	# 		RPC.connect()

	# 		logging.info("Discord RPC connection successful.")
	# 		time.sleep(1)
	# 		start_time = time.time()
	# 		idle_time = Timer()

	# 		state = 0
	# 		index = -1
	# 		br = False
	# 		self.gui.discord_status = "Connected"
	# 		self.gui.update += 1
	# 		current_state = 0

	# 		while True:
	# 			while True:

	# 				current_index = self.pctl.playing_object().index
	# 				if self.pctl.playing_state == PlayingState.URL_STREAM:
	# 					current_index = self.radiobox.song_key

	# 				if current_state == 0 and self.pctl.playing_state in (PlayingState.PLAYING, PlayingState.URL_STREAM):
	# 					current_state = 1
	# 				elif current_state == 1 and self.pctl.playing_state not in (PlayingState.PLAYING, PlayingState.URL_STREAM):
	# 					current_state = 0
	# 					idle_time.set()

	# 				if state != current_state or index != current_index:
	# 					if self.pctl.a_time > 4 or current_state != 1:
	# 						state = current_state
	# 						index = current_index
	# 						break
	# 				if abs(start_time - (time.time() - self.pctl.playing_time)) > 1:
	# 					start_time = time.time() - self.pctl.playing_time
	# 				else:
	# 					break

	# 				if current_state == 0 and idle_time.get() > 13:
	# 					logging.info("Pause discord RPC...")
	# 					self.gui.discord_status = "Idle"
	# 					RPC.clear(self.pid)
	# 					# RPC.close()

	# 					while True:
	# 						if self.prefs.disconnect_discord:
	# 							break
	# 						if self.pctl.playing_state == PlayingState.PLAYING:
	# 							logging.info("Reconnect discord...")
	# 							RPC.connect()
	# 							self.gui.discord_status = "Connected"
	# 							break
	# 						time.sleep(1)

	# 					if not self.prefs.disconnect_discord:
	# 						continue

	# 				time.sleep(1)

	# 				if self.prefs.disconnect_discord:
	# 					RPC.clear(self.pid)
	# 					RPC.close()
	# 					self.prefs.disconnect_discord = False
	# 					self.gui.discord_status = "Not connected"
	# 					br = True
	# 					break

	# 			if br:
	# 				break

	# 			title = _("Unknown Track")
	# 			tr = self.pctl.playing_object()
	# 			if tr.artist and tr.title and self.pctl.playing_state == PlayingState.URL_STREAM:
	# 				title = tr.title + " | " + tr.artist
	# 			else:
	# 				title = tr.title
	# 			if len(title) > 150:
	# 				title = _("Unknown Track")

	# 			artist = tr.artist or _("Unknown Artist")

	# 			if self.pctl.playing_state == PlayingState.URL_STREAM and tr.album:
	# 				album = self.radiobox.loaded_station["title"]
	# 			else:
	# 				album = None if tr.album.lower() in (tr.title.lower(), tr.artist.lower()) else tr.album

	# 			if album and len(album) == 1:
	# 				album += " "

	# 			if state == 1:
	# 				#logging.info("PLAYING: " + title)
	# 				#logging.info(start_time)
	# 				url = self.get_album_art_url(self.pctl.playing_object())

	# 				large_image = "tauon-standard"
	# 				small_image = None
	# 				if url:
	# 					large_image = url
	# 					small_image = "tauon-standard"
	# 				RPC.update(
	# 					activity_type = ActivityType.LISTENING,
	# 					status_display_type = StatusDisplayType.STATE,
	# 					pid=self.pid,
	# 					**({"state": artist} if self.pctl.playing_state != PlayingState.URL_STREAM else {"state": album}),
	# 					details=title,
	# 					start=int(start_time),
	# 					**({"end": int(start_time + tr.length)} if self.pctl.playing_state != PlayingState.URL_STREAM else {}),
	# 					**({"large_text": album} if album and self.pctl.playing_state != PlayingState.URL_STREAM else {}),
	# 					large_image=large_image,
	# 					small_image=small_image)

	# 			else:
	# 				#logging.info("Discord RPC - Stop")
	# 				RPC.update(
	# 					activity_type = ActivityType.LISTENING,
	# 					pid=self.pid,
	# 					state="Idle",
	# 					large_image="tauon-standard")

	# 			time.sleep(2)

	# 			if self.prefs.disconnect_discord:
	# 				RPC.clear(self.pid)
	# 				RPC.close()
	# 				self.prefs.disconnect_discord = False
	# 				break

	# 	except Exception:
	# 		logging.exception("Error connecting to Discord - is Discord running?")
	# 		# self.show_message(_("Error connecting to Discord", mode='error')
	# 		self.gui.discord_status = _("Error - Discord not running?")
	# 		self.prefs.disconnect_discord = False

	# 	finally:
	# 		loop = asyncio.get_event_loop()
	# 		if not loop.is_closed():
	# 			loop.close()
	# 		self.prefs.discord_active = False

	#def open_donate_link() -> None:
	#	webbrowser.open("https://github.com/sponsors/Taiko2k", new=2, autoraise=True)

	def open_manual_link(self) -> None:
		webbrowser.open("https://tauonmusicbox.rocks/manual/overview/", new=2, autoraise=True)

	def stop_quick_add(self) -> None:
		self.pctl.quick_add_target = None

	def show_stop_quick_add(self, _: int) -> bool:
		return self.pctl.quick_add_target is not None

	def view_tracks(self) -> None:
		# if self.gui.show_playlist is False:
		# 	self.gui.show_playlist = True
		if self.prefs.album_mode:
			self.toggle_album_mode()
		if self.gui.combo_mode:
			self.exit_combo()
		if self.gui.rsp:
			self.toggle_side_panel()

	# def view_standard_full(self):
	# 	# if self.gui.show_playlist is False:
	# 	# 	self.gui.show_playlist = True
	# 	if self.prefs.album_mode:
	# 		self.toggle_album_mode()
	# 	if self.gui.combo_mode:
	# 		self.toggle_combo_view(off=True)
	# 	if not self.gui.rsp:
	# 		self.toggle_side_panel()
	# 	self.gui.update_layout = True
	# 	self.gui.rspw = self.window_size[0]

	def view_standard_meta(self) -> None:
		# if self.gui.show_playlist is False:
		# 	self.gui.show_playlist = True
		if self.prefs.album_mode:
			self.toggle_album_mode()

		if self.gui.combo_mode:
			self.exit_combo()

		if not self.gui.rsp:
			self.toggle_side_panel()

		self.gui.update_layout = True
		# self.gui.rspw = 80 + int(self.window_size[0] * 0.18)

	def view_standard(self) -> None:
		# if self.gui.show_playlist is False:
		# 	self.gui.show_playlist = True
		if self.prefs.album_mode:
			self.toggle_album_mode()
		if self.gui.combo_mode:
			self.exit_combo()
		if not self.gui.rsp:
			self.toggle_side_panel()

	def get_folder_list(self, index: int) -> list[int]:
		playlist: list[int] = []

		for item in self.pctl.default_playlist:
			if self.pctl.master_library[item].parent_folder_name == self.pctl.master_library[index].parent_folder_name and \
					self.pctl.master_library[item].album == self.pctl.master_library[index].album:
				playlist.append(item)
		return list(set(playlist))

	def gal_jump_select(self, up: bool = False, num: int = 1) -> None:
		old_selected = self.pctl.selected_in_playlist
		old_num = num

		if not self.pctl.default_playlist:
			return

		on = self.pctl.selected_in_playlist
		if on > len(self.pctl.default_playlist) - 1:
			on = 0
			self.pctl.selected_in_playlist = 0

		if up is False:
			while num > 0:
				while self.pctl.master_library[
					self.pctl.default_playlist[on]].parent_folder_name == self.pctl.master_library[
					self.pctl.default_playlist[self.pctl.selected_in_playlist]].parent_folder_name:
					on += 1

					if on > len(self.pctl.default_playlist) - 1:
						self.pctl.selected_in_playlist = old_selected
						return

				self.pctl.selected_in_playlist = on
				num -= 1
		else:
			if num > 1:
				if self.pctl.selected_in_playlist > len(self.pctl.default_playlist) - 1:
					self.pctl.selected_in_playlist = old_selected
					return

				alb = self.get_album_info(self.pctl.selected_in_playlist)
				if alb[1][0] in self.album_dex[:num]:
					self.pctl.selected_in_playlist = old_selected
					return

			while num > 0:
				alb = self.get_album_info(self.pctl.selected_in_playlist)

				if alb[1][0] > -1:
					on = alb[1][0] - 1

				self.pctl.selected_in_playlist = max(self.get_album_info(on)[1][0], 0)
				num -= 1

	def update_playlist_call(self) -> None:
		self.gui.update += 2
		self.gui.pl_update = 2

	def pl_is_mut(self, pl: int) -> bool:
		"""Returns True if specified playlist number is modifiable/not associated with a generator i think"""
		id = self.pctl.pl_to_id(pl)
		if id is None:
			return False
		return not (self.pctl.gen_codes.get(id) and "self" not in self.pctl.gen_codes[id])

	def clear_gen(self, id: int) -> None:
		del self.pctl.gen_codes[id]
		self.show_message(_("Okay, it's a normal playlist now."), mode="done")

	def clear_gen_ask(self, id: int) -> None:
		if "jelly\"" in self.pctl.gen_codes.get(id, ""):
			return
		if "spl\"" in self.pctl.gen_codes.get(id, ""):
			return
		if "tpl\"" in self.pctl.gen_codes.get(id, ""):
			return
		if "tar\"" in self.pctl.gen_codes.get(id, ""):
			return
		if "tmix\"" in self.pctl.gen_codes.get(id, ""):
			return
		self.gui.message_box_confirm_callback = self.clear_gen
		self.gui.message_box_no_callback = None
		self.gui.message_box_confirm_reference = (id,)
		self.show_message(_("You added tracks to a generator playlist. Do you want to clear the generator?"), mode="confirm")

	def _window_is_maximized(self) -> bool:
		flags = sdl3.SDL_GetWindowFlags(self.t_window)
		return bool(flags & sdl3.SDL_WINDOW_MAXIMIZED)

	def frame_pace(self) -> float:
		"""Delay to schedule the next frame of a continuously animating visual
		(delay_frame argument), paced to the window's display refresh rate.

		Slightly under one refresh period: when vsync is working the present
		absorbs the slack and frames stay locked to vblank, and when it isn't
		(driver override, occluded window) this still caps the loop near the
		refresh rate instead of letting it spin unbounded."""
		hz = 0.0
		mode = sdl3.SDL_GetCurrentDisplayMode(sdl3.SDL_GetDisplayForWindow(self.t_window))
		if mode:
			hz = mode.contents.refresh_rate
		if not 0 < hz < 1000:
			hz = 60.0
		return 0.85 / hz

	def _is_wayland_standalone_wm(self) -> bool:
		if not self.wayland:
			return False
		desktop = (self.desktop or "").lower()
		de_names = ("gnome", "kde", "plasma", "xfce", "cinnamon", "mate", "unity", "lxqt", "lxde", "budgie", "pantheon", "cosmic")
		return not any(name in desktop for name in de_names)

	def _get_wayland_wm_controller(self) -> Literal["sway", "hyprland"] | None:
		if os.environ.get("SWAYSOCK"):
			return "sway"
		if os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
			return "hyprland"
		return None

	def _run_wm_command(self, cmd: list[str]) -> str | None:
		binary = cmd[0]
		if shutil.which(binary) is None:
			if not self._wayland_wm_ipc_warned:
				logging.warning("Wayland mini-mode WM integration unavailable: missing %s", binary)
				self._wayland_wm_ipc_warned = True
			return None
		try:
			result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=0.8)
		except Exception:
			return None
		if result.returncode != 0:
			return None
		return result.stdout

	def _reset_mini_mode_wm_tracking(self) -> None:
		self.mini_mode_wm_forced_floating = False
		self.mini_mode_wm_was_floating = False

	def _get_sway_focused_floating_state(self) -> bool | None:
		tree_raw = self._run_wm_command(["swaymsg", "-t", "get_tree", "-r"])
		if not tree_raw:
			return None
		try:
			tree = json.loads(tree_raw)
		except json.JSONDecodeError:
			return None

		stack = [tree]
		while stack:
			node = stack.pop()
			if node.get("focused"):
				fstate = node.get("floating")
				return fstate in ("user_on", "auto_on")
			stack.extend(node.get("nodes", []))
			stack.extend(node.get("floating_nodes", []))
		return None

	def _get_hyprland_active_window(self) -> dict[str, object] | None:
		active_raw = self._run_wm_command(["hyprctl", "-j", "activewindow"])
		if not active_raw:
			return None
		try:
			active = json.loads(active_raw)
		except json.JSONDecodeError:
			return None
		if not isinstance(active, dict):
			return None
		return active


	def _wayland_window_is_floating(self) -> bool | None:
		if not self._is_wayland_standalone_wm():
			return None

		wm_controller = self._get_wayland_wm_controller()
		if wm_controller == "sway":
			return self._get_sway_focused_floating_state()

		if wm_controller == "hyprland":
			active = self._get_hyprland_active_window()
			if active is None:
				return None
			return bool(active.get("floating", False))

		return None

	def _set_wayland_mini_mode_window_state(self, entering: bool, width: int = 0, height: int = 0, reset_tracking: bool = True) -> None:
		if not self._is_wayland_standalone_wm():
			return
		if entering and reset_tracking:
			self._reset_mini_mode_wm_tracking()

		wm_controller = self._get_wayland_wm_controller()
		if wm_controller is None:
			if not entering:
				self._reset_mini_mode_wm_tracking()
			return

		if wm_controller == "sway":
			if entering:
				if reset_tracking:
					floating_state = self._get_sway_focused_floating_state()
					if floating_state is not None:
						self.mini_mode_wm_was_floating = floating_state

					if not self.mini_mode_wm_was_floating:
						self._run_wm_command(["swaymsg", "floating", "enable"])
						self.mini_mode_wm_forced_floating = True
				elif self.mini_mode_wm_forced_floating:
					self._run_wm_command(["swaymsg", "floating", "enable"])

				if width > 0 and height > 0:
					self._run_wm_command(["swaymsg", "resize", "set", "width", f"{width}px", "height", f"{height}px"])
			else:
				if self.mini_mode_wm_forced_floating and not self.mini_mode_wm_was_floating:
					self._run_wm_command(["swaymsg", "floating", "disable"])
				self._reset_mini_mode_wm_tracking()
			return

		if wm_controller == "hyprland":
			active = self._get_hyprland_active_window()
			if active is None:
				if not entering:
					self._reset_mini_mode_wm_tracking()
				return
			address = str(active.get("address", ""))
			if not address:
				if not entering:
					self._reset_mini_mode_wm_tracking()
				return

			if entering:
				if reset_tracking:
					self.mini_mode_wm_was_floating = bool(active.get("floating", False))
					if not self.mini_mode_wm_was_floating:
						self._run_wm_command(["hyprctl", "dispatch", "togglefloating", f"address:{address}"])
						self.mini_mode_wm_forced_floating = True
				elif self.mini_mode_wm_forced_floating and not bool(active.get("floating", False)):
					self._run_wm_command(["hyprctl", "dispatch", "togglefloating", f"address:{address}"])

				if width > 0 and height > 0:
					self._run_wm_command(["hyprctl", "dispatch", "resizeactive", "exact", str(width), str(height)])
			else:
				if self.mini_mode_wm_forced_floating and not self.mini_mode_wm_was_floating:
					self._run_wm_command(["hyprctl", "dispatch", "togglefloating", f"address:{address}"])
				self._reset_mini_mode_wm_tracking()

	def set_mini_mode(self) -> None:
		if self.gui.fullscreen:
			return

		self.inp.mouse_down = False
		self.inp.mouse_up = False
		self.inp.mouse_click = False

		is_wayland_standalone_wm = self._is_wayland_standalone_wm()
		is_floating_wayland_window = self._wayland_window_is_floating() if is_wayland_standalone_wm else None

		# Standalone Wayland WMs can report maximized flags while window is already floating.
		# Treat already-floating windows as non-maximized for mini-mode transitions.
		self.gui.mini_mode_return_maximized = self._window_is_maximized() and not bool(is_floating_wayland_window)
		if self.gui.mini_mode_return_maximized:
			sdl3.SDL_RestoreWindow(self.t_window)
			sdl3.SDL_SyncWindow(self.t_window)
			sdl3.SDL_PumpEvents()
			self.gui.maximized = False
			self.update_layout_do()

		if self.gui.mode == GuiMode.MAIN:
			self.old_window_position = get_window_position(self.t_window)
			saved_w = pointer(c_int(0))
			saved_h = pointer(c_int(0))
			sdl3.SDL_GetWindowSize(self.t_window, saved_w, saved_h)
			self.mini_mode_restore_size = (saved_w.contents.value, saved_h.contents.value)
			self.gui.save_size[0] = self.mini_mode_restore_size[0]
			self.gui.save_size[1] = self.mini_mode_restore_size[1]

		if self.prefs.mini_mode_on_top and not self.wayland:
			sdl3.SDL_SetWindowAlwaysOnTop(self.t_window, True)

		self.gui.mode = GuiMode.MINI
		self.gui.vis = 0
		self.gui.turbo = self.prefs.mini_mode_mode == MiniModeMode.SIGNAL
		self.gui.draw_vis4_top = False
		self.gui.level_update = self.prefs.mini_mode_mode == MiniModeMode.SIGNAL

		i_y = pointer(c_int(0))
		i_x = pointer(c_int(0))
		sdl3.SDL_GetWindowPosition(self.t_window, i_x, i_y)
		self.gui.save_position = (i_x.contents.value, i_y.contents.value)

		self.mini_mode.was_borderless = self.draw_border
		if not is_wayland_standalone_wm:
			sdl3.SDL_SetWindowBordered(self.t_window, False)

		size = (350, 429)
		if self.prefs.mini_mode_mode == MiniModeMode.MINI:
			size = (330, 330)
		if self.prefs.mini_mode_mode == MiniModeMode.LARGE:
			size = (420, 499)
		if self.prefs.mini_mode_mode == MiniModeMode.SQUARE_LARGE:
			size = (430, 430)
		if self.prefs.mini_mode_mode == MiniModeMode.TAB:
			size = (330, 80)
		if self.prefs.mini_mode_mode == MiniModeMode.SLATE:
			size = (350, 545)
			self.style_overlay.flush()
			self.thread_manager.ready("style")
		if self.prefs.mini_mode_mode == MiniModeMode.SIGNAL:
			size = (640, 460)

		self.logical_size[0] = size[0]
		self.logical_size[1] = size[1]

		min_size = (100, 80)
		sdl3.SDL_SetWindowMinimumSize(self.t_window, min_size[0], min_size[1])
		self._set_wayland_mini_mode_window_state(True, self.logical_size[0], self.logical_size[1])
		sdl3.SDL_SetWindowResizable(self.t_window, True)
		sdl3.SDL_SetWindowSize(self.t_window, self.logical_size[0], self.logical_size[1])
		if not is_wayland_standalone_wm:
			sdl3.SDL_SetWindowResizable(self.t_window, False)

		if self.mini_mode.save_position:
			sdl3.SDL_SetWindowPosition(self.t_window, self.mini_mode.save_position[0], self.mini_mode.save_position[1])

		self.gui.update_layout = True
		self.gui.update += 3

	def restore_full_mode(self) -> None:
		logging.info("RESTORE FULL")
		i_y = pointer(c_int(0))
		i_x = pointer(c_int(0))
		sdl3.SDL_GetWindowPosition(self.t_window, i_x, i_y)
		self.mini_mode.save_position = [i_x.contents.value, i_y.contents.value]

		if not self.mini_mode.was_borderless:
			sdl3.SDL_SetWindowBordered(self.t_window, True)

		restore_size = self.mini_mode_restore_size or (self.gui.save_size[0], self.gui.save_size[1])
		self.logical_size[0] = restore_size[0]
		self.logical_size[1] = restore_size[1]

		sdl3.SDL_SetWindowPosition(self.t_window, self.gui.save_position[0], self.gui.save_position[1])


		sdl3.SDL_SetWindowResizable(self.t_window, True)
		sdl3.SDL_SetWindowSize(self.t_window, self.logical_size[0], self.logical_size[1])
		sdl3.SDL_SetWindowAlwaysOnTop(self.t_window, False)

		# if self.macos:
		# 	sdl3.SDLSetWindowMinimumSize(self.t_window, 560, 330)
		# else:
		if self.logical_size[0] != self.window_size[0]:
			sdl3.SDL_SetWindowMinimumSize(self.t_window, 560, 330)
		else:
			sdl3.SDL_SetWindowMinimumSize(self.t_window, round(560 * self.gui.scale), round(330 * self.gui.scale))

		self.restore_ignore_timer.set()  # Hacky

		self.gui.mode = GuiMode.MAIN
		self._set_wayland_mini_mode_window_state(False)

		sdl3.SDL_SyncWindow(self.t_window)
		sdl3.SDL_PumpEvents()
		sdl3.SDL_GetWindowSize(self.t_window, i_x, i_y)
		self.window_size[0] = i_x.contents.value
		self.window_size[1] = i_y.contents.value
		self.logical_size[0] = i_x.contents.value
		self.logical_size[1] = i_y.contents.value

		self.inp.mouse_down = False
		self.inp.mouse_up = False
		self.inp.mouse_click = False

		if self.gui.mini_mode_return_maximized:
			sdl3.SDL_MaximizeWindow(self.t_window)
			time.sleep(0.05)
			sdl3.SDL_PumpEvents()
			sdl3.SDL_GetWindowSize(self.t_window, i_x, i_y)
			self.logical_size[0] = i_x.contents.value
			self.logical_size[1] = i_y.contents.value
			self.gui.maximized = True
		self.gui.mini_mode_return_maximized = False
		self.mini_mode_restore_size = None

		#logging.info(self.window_size)

		self.gui.update_layout = True
		if self.prefs.art_bg:
			self.thread_manager.ready("style")

	# def visit_radio_site_show_test(self, p):
	# 	return "website_url" in self.prefs.radio_urls[p] and self.prefs.radio_urls[p].["website_url"]

	def visit_radio_site_deco(self, station: RadioStation) -> Decorator:
		if station.website_url:
			return Decorator(self.colours.menu_text, self.colours.menu_background, None)
		return Decorator(self.colours.menu_text_disabled, self.colours.menu_background, None)

	def visit_radio_station_site_deco(self, item: tuple[int, RadioStation]) -> Decorator:
		return self.visit_radio_site_deco(item[1])

	def radio_saved_panel_test(self, _) -> bool:
		return self.radiobox.tab == 0

	def save_to_radios(self, station: RadioStation) -> None:
		self.pctl.radio_playlists[self.pctl.radio_playlist_viewing].stations.append(station)
		self.toast(_("Added station to: ") + self.pctl.radio_playlists[self.pctl.radio_playlist_viewing].name)

	def create_artist_pl(self, artist: str, replace: bool = False) -> None:
		source_pl = self.pctl.active_playlist_viewing
		this_pl = self.pctl.active_playlist_viewing

		if self.pctl.multi_playlist[source_pl].parent_playlist_id:
			new = self.pctl.id_to_pl(self.pctl.multi_playlist[source_pl].parent_playlist_id)
			if new is None:
				# The original playlist is now gone
				self.pctl.multi_playlist[source_pl].parent_playlist_id = 0
			else:
				source_pl = new
				# replace = True

		playlist = []

		for item in self.pctl.multi_playlist[source_pl].playlist_ids:
			track = self.pctl.get_track(item)
			if artist in (track.artist, track.album_artist):
				playlist.append(item)

		if replace:
			self.pctl.multi_playlist[this_pl].playlist_ids[:] = playlist[:]
			self.pctl.multi_playlist[this_pl].title = _("Artist: ") + artist
			if self.prefs.album_mode:
				self.reload_albums()

			# Transfer playing track back to original playlist
			if self.pctl.multi_playlist[this_pl].parent_playlist_id:
				new = self.pctl.id_to_pl(self.pctl.multi_playlist[this_pl].parent_playlist_id)
				tr = self.pctl.playing_object()
				if new is not None and tr and self.pctl.active_playlist_playing == this_pl:
					if tr.index not in self.pctl.multi_playlist[this_pl].playlist_ids and tr.index in self.pctl.multi_playlist[source_pl].playlist_ids:
						logging.info("Transfer back playing")
						self.pctl.active_playlist_playing = source_pl
						self.pctl.playlist_playing_position = self.pctl.multi_playlist[source_pl].playlist_ids.index(tr.index)

			self.pctl.gen_codes[self.pctl.pl_to_id(this_pl)] = "s\"" + self.pctl.multi_playlist[source_pl].title + "\" a\"" + artist + "\""
		else:
			self.pctl.multi_playlist.append(
				self.pl_gen(
					title=_("Artist: ") + artist,
					playlist_ids=playlist,
					hide_title=False,
					parent=self.pctl.pl_to_id(source_pl)))

			self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[source_pl].title + "\" a\"" + artist + "\""

			self.pctl.switch_playlist(len(self.pctl.multi_playlist) - 1)

	def aa_sort_alpha(self) -> None:
		self.prefs.artist_list_sort_mode = "alpha"
		self.artist_list_box.saves.clear()

	def aa_sort_popular(self) -> None:
		self.prefs.artist_list_sort_mode = "popular"
		self.artist_list_box.saves.clear()

	def aa_sort_play(self) -> None:
		self.prefs.artist_list_sort_mode = "play"
		self.artist_list_box.saves.clear()

	def toggle_artist_list_style(self) -> None:
		if self.prefs.artist_list_style == 1:
			self.prefs.artist_list_style = 2
		else:
			self.prefs.artist_list_style = 1

	def toggle_artist_list_threshold(self) -> None:
		if self.prefs.artist_list_threshold > 0:
			self.prefs.artist_list_threshold = 0
		else:
			self.prefs.artist_list_threshold = 4
		self.artist_list_box.saves.clear()

	def toggle_artist_list_threshold_deco(self) -> Decorator:
		if self.prefs.artist_list_threshold == 0:
			return Decorator(self.colours.menu_text, self.colours.menu_background, _("Filter Small Artists"))
		# save = self.artist_list_box.saves.get(self.pctl.multi_playlist[self.pctl.active_playlist_viewing].uuid_int)
		# if save and save[5] == 0:
		# 	return Decorator(self.colours.menu_text_disabled, self.colours.menu_background, _("Include All Artists"))
		return Decorator(self.colours.menu_text, self.colours.menu_background, _("Include All Artists"))

	def verify_discogs(self) -> bool:
		return len(self.prefs.discogs_pat) == 40

	def save_discogs_artist_thumb(self, artist: str, filepath: str) -> None:
		logging.info("Searching discogs for artist image...")

		# Make artist name url safe
		artist = artist.replace("/", "").replace("\\", "").replace(":", "")

		# Search for Discogs artist id
		url = "https://api.discogs.com/database/search"
		r = requests.get(url, params={"query": artist, "type": "artist", "token": self.prefs.discogs_pat}, headers={"User-Agent": self.t_agent}, timeout=10)
		id = r.json()["results"][0]["id"]

		# Search artist info, get images
		url = "https://api.discogs.com/artists/" + str(id)
		r = requests.get(url, headers={"User-Agent": self.t_agent}, params={"token": self.prefs.discogs_pat}, timeout=10)
		images = r.json()["images"]

		# Respect rate limit
		rate_remaining = r.headers["X-Discogs-Ratelimit-Remaining"]
		if int(rate_remaining) < 30:
			time.sleep(5)

		# Find a square image in list of images
		for image in images:
			if image["height"] == image["width"]:
				logging.info("Found square")
				url = image["uri"]
				break
		else:
			url = images[0]["uri"]

		response = urllib.request.urlopen(url, context=self.tls_context)
		im = Image.open(response)

		width, height = im.size
		if width > height:
			delta = width - height
			left = int(delta / 2)
			upper = 0
			right = height + left
			lower = height
		else:
			delta = height - width
			left = 0
			upper = int(delta / 2)
			right = width
			lower = width + upper

		im = im.crop((left, upper, right, lower))
		im.save(filepath, "JPEG", quality=90)
		im.close()
		logging.info("Found artist image from Discogs")

	def save_fanart_artist_thumb(self, mbid: str, filepath: str, preview: bool = False) -> None:
		logging.info("Searching fanart.tv for image...")
		#logging.info("mbid is " + mbid)
		r = requests.get("https://webservice.fanart.tv/v3/music/" + mbid + "?api_key=" + self.prefs.fatvap, timeout=5)
		#logging.info(r.json())
		thumblink = r.json()["artistthumb"][0]["url"]
		if preview:
			thumblink = thumblink.replace("/fanart/music", "/preview/music")

		response = urllib.request.urlopen(thumblink, timeout=10, context=self.tls_context)
		info = response.info()

		t = io.BytesIO()
		t.seek(0)
		t.write(response.read())
		l = 0
		t.seek(0, 2)
		l = t.tell()
		t.seek(0)

		if info.get_content_maintype() == "image" and l > 1000:
			f = open(filepath, "wb")
			f.write(t.read())
			f.close()

			if self.prefs.fanart_notify:
				self.prefs.fanart_notify = False
				self.show_message(
					_("Notice: Artist image sourced from fanart.tv"),
					_("They encourage you to contribute at {link}").format(link="https://fanart.tv"), mode="link")
			logging.info("Found artist thumbnail from fanart.tv")

	def queue_pause_deco(self) -> Decorator:
		if self.pctl.pause_queue:
			return Decorator(self.colours.menu_text, self.colours.menu_background, _("Resume Queue"))
		return Decorator(self.colours.menu_text, self.colours.menu_background, _("Pause Queue"))

	# def finish_current_deco(self) -> Decorator:
	# 	colour = self.colours.menu_text
	# 	line = "Finish Playing Album"
	# 	if self.pctl.playing_object() is None:
	# 		colour = self.colours.menu_text_disabled
	# 	if self.pctl.force_queue and pctl.force_queue[0].album_stage == 1:
	# 		colour = self.colours.menu_text_disabled
	# 	return Decorator(self.colour, self.colours.menu_background, line)

	def art_metadata_overlay(self, right: float, bottom: float, showc: list[tuple[int, int, int, int, str]]) -> None:
		if not showc:
			return

		padding = 6 * self.gui.scale

		if not self.inp.key_shift_down:
			line = ""
			if showc[0] == 1:
				line += "E "
			elif showc[0] == 2:
				line += "N "
			else:
				line += "F "

			line += str(showc[2] + 1) + "/" + str(showc[1])

			y = bottom - 40 * self.gui.scale

			tag_width = self.ddt.get_text_w(line, 12) + 12 * self.gui.scale
			self.ddt.rect_a((right - (tag_width + padding), y), (tag_width, 18 * self.gui.scale), ColourRGBA(8, 8, 8, 255))
			self.ddt.text(((right) - (6 * self.gui.scale + padding), y, 1), line, ColourRGBA(200, 200, 200, 255), 12, bg=ColourRGBA(30, 30, 30, 255))
		else:  # Extended metadata
			line = ""
			if showc[0] == 1:
				line += "Embedded"
			elif showc[0] == 2:
				line += "Network"
			else:
				line += "File"

			y = bottom - 76 * self.gui.scale

			tag_width = self.ddt.get_text_w(line, 12) + 12 * self.gui.scale
			self.ddt.rect_a((right - (tag_width + padding), y), (tag_width, 18 * self.gui.scale), ColourRGBA(8, 8, 8, 255))
			self.ddt.text(((right) - (6 * self.gui.scale + padding), y, 1), line, ColourRGBA(200, 200, 200, 255), 12, bg=ColourRGBA(30, 30, 30, 255))

			y += 18 * self.gui.scale

			line = ""
			line += showc[4]
			line += " " + str(showc[3][0]) + "×" + str(showc[3][1])

			tag_width = self.ddt.get_text_w(line, 12) + 12 * self.gui.scale
			self.ddt.rect_a((right - (tag_width + padding), y), (tag_width, 18 * self.gui.scale), ColourRGBA(8, 8, 8, 255))
			self.ddt.text(((right) - (6 * self.gui.scale + padding), y, 1), line, ColourRGBA(200, 200, 200, 255), 12, bg=ColourRGBA(30, 30, 30, 255))

			y += 18 * self.gui.scale

			line = ""
			line += str(showc[2] + 1) + "/" + str(showc[1])

			tag_width = self.ddt.get_text_w(line, 12) + 12 * self.gui.scale
			self.ddt.rect_a((right - (tag_width + padding), y), (tag_width, 18 * self.gui.scale), ColourRGBA(8, 8, 8, 255))
			self.ddt.text(((right) - (6 * self.gui.scale + padding), y, 1), line, ColourRGBA(200, 200, 200, 255), 12, bg=ColourRGBA(30, 30, 30, 255))

	def artist_dl_deco(self) -> Decorator:
		if self.artist_info_box.status == "Ready":
			return Decorator(self.colours.menu_text_disabled, self.colours.menu_background, None)
		return Decorator(self.colours.menu_text, self.colours.menu_background, None)

	def station_browse(self) -> None:
		self.radiobox.active = True
		self.radiobox.edit_mode = False
		self.radiobox.add_mode = False
		self.radiobox.center = True
		self.radiobox.tab = 1

	def add_station(self) -> None:
		self.radiobox.active = True
		self.radiobox.edit_mode = True
		self.radiobox.add_mode = True
		self.radiobox.radio_field.text = ""
		self.radiobox.radio_field_title.text = ""
		self.radiobox.station_editing = None
		self.radiobox.center = True

	def rename_station(self, item: tuple[int, RadioStation]) -> None:
		station = item[1]
		self.radiobox.active = True
		self.radiobox.center = False
		self.radiobox.edit_mode = True
		self.radiobox.add_mode = False
		self.radiobox.radio_field.text = station.stream_url
		self.radiobox.radio_field_title.text = station.title if station.title is not None else ""
		self.radiobox.station_editing = station

	def remove_station(self, item: tuple[int, RadioStation]) -> None:
		index = item[0]
		del self.pctl.radio_playlists[self.pctl.radio_playlist_viewing].stations[index]

	def dismiss_dl(self) -> None:
		self.dl_mon.ready.clear()
		self.dl_mon.done.update(self.dl_mon.watching)
		self.dl_mon.watching.clear()

	def download_img(self, link: str, target_dir: str, track: TrackClass) -> None:
		try:
			response = urllib.request.urlopen(link, context=self.tls_context)
			info = response.info()
			if info.get_content_maintype() == "image":
				if info.get_content_subtype() == "jpeg":
					save_target = os.path.join(target_dir, "image.jpg")
					with open(save_target, "wb") as f:
						f.write(response.read())
					# self.clear_img_cache()
					self.clear_track_image_cache(track)

				elif info.get_content_subtype() == "png":
					save_target = os.path.join(target_dir, "image.png")
					with open(save_target, "wb") as f:
						f.write(response.read())
					# self.clear_img_cache()
					self.clear_track_image_cache(track)
				else:
					self.show_message(_("Image types other than PNG or JPEG are currently not supported"), mode="warning")
			else:
				self.show_message(_("The link does not appear to refer to an image file."), mode="warning")
			self.gui.image_downloading = False

		except Exception as e:
			logging.exception("Image download failed")
			self.show_message(_("Image download failed."), str(e), mode="warning")
			self.gui.image_downloading = False

	def display_you_heart(self, x: int, yy: int, just: int = 0) -> None:
		rect = [x - 1 * self.gui.scale, yy - 4 * self.gui.scale, 15 * self.gui.scale, 17 * self.gui.scale]
		self.gui.heart_fields.append(rect)
		self.fields.add(rect, self.update_playlist_call)
		if self.coll(rect) and not self.gui.track_box:
			self.gui.pl_update += 1
			w = self.ddt.get_text_w(_("You"), 13)
			xx = (x - w) - 5 * self.gui.scale

			if just == 1:
				xx += w + 15 * self.gui.scale

			ty = yy - 28 * self.gui.scale
			tx = xx
			if ty < self.gui.panelY + 5 * self.gui.scale:
				ty = self.gui.panelY + 5 * self.gui.scale
				tx -= 20 * self.gui.scale

		#	self.ddt.rect_r((xx - 1 * self.gui.scale, yy - 26 * self.gui.scale - 1 * self.gui.scale, w + 10 * self.gui.scale + 2 * self.gui.scale, 19 * self.gui.scale + 2 * self.gui.scale), [50, 50, 50, 255], True)
			self.ddt.rect((tx - 5 * self.gui.scale, ty, w + 20 * self.gui.scale, 24 * self.gui.scale), ColourRGBA(15, 15, 15, 255))
			self.ddt.rect((tx - 5 * self.gui.scale, ty, w + 20 * self.gui.scale, 24 * self.gui.scale), ColourRGBA(35, 35, 35, 255))
			self.ddt.text((tx + 5 * self.gui.scale, ty + 4 * self.gui.scale), _("You"), ColourRGBA(250, 250, 250, 255), 13, bg=ColourRGBA(15, 15, 15, 255))

		self.gui.heart_row_icon.render(x, yy, ColourRGBA(244, 100, 100, 255))

	def display_friend_heart(self, x: int, yy: int, name: str, just: int = 0) -> None:
		self.gui.heart_row_icon.render(x, yy, self.heart_colours.get(name))

		rect = [x - 1, yy - 4, 15 * self.gui.scale, 17 * self.gui.scale]
		self.gui.heart_fields.append(rect)
		self.fields.add(rect, self.update_playlist_call)
		if self.coll(rect) and not self.gui.track_box:
			self.gui.pl_update += 1
			w = self.ddt.get_text_w(name, 13)
			xx = (x - w) - 5 * self.gui.scale

			if just == 1:
				xx += w + 15 * self.gui.scale

			ty = yy - 28 * self.gui.scale
			tx = xx
			if ty < self.gui.panelY + 5 * self.gui.scale:
				ty = self.gui.panelY + 5 * self.gui.scale
				tx -= 20 * self.gui.scale

			self.ddt.rect((tx - 5 * self.gui.scale, ty, w + 20 * self.gui.scale, 24 * self.gui.scale), ColourRGBA(15, 15, 15, 255))
			self.ddt.rect((tx - 5 * self.gui.scale, ty, w + 20 * self.gui.scale, 24 * self.gui.scale), ColourRGBA(35, 35, 35, 255))
			self.ddt.text((tx + 5 * self.gui.scale, ty + 4 * self.gui.scale), name, ColourRGBA(250, 250, 250, 255), 13, bg=ColourRGBA(15, 15, 15, 255))

	def reload_scale(self, skip_render: bool = False) -> None:
		auto_scale(self.bag)

		scale = self.prefs.scale_want

		self.gui.scale = scale
		self.ddt.scale = self.gui.scale
		self.prime_fonts()
		self.ddt.clear_text_cache()
		if not skip_render:
			scale_assets(tauon=self, bag=self.bag, gui=self.gui, scale_want=scale, force=True)
		self.img_slide_update_gall(self.album_mode_art_size)

		for item in WhiteModImageAsset.assets:
			item.reload()
		for item in LoadImageAsset.assets:
			item.reload()
		for menu in Menu.instances:
			menu.rescale()

		self.bottom_bar1.__init__(self)
		self.bottom_bar_ao1.__init__(self)
		self.top_panel.__init__(self)
		self.view_box.__init__(self, reload=True)
		self.queue_box.recalc()
		self.playlist_box.recalc()

	def update_layout_do(self) -> None:
		window_size = self.window_size
		prefs = self.prefs
		dirs = self.dirs
		ddt = self.ddt
		gui = self.gui
		if prefs.scale_want != gui.scale:
			logging.info("Reload scale")
			self.reload_scale()

		w = window_size[0]
		h = window_size[1]

		if gui.switch_showcase_off:
			ddt.force_gray = False
			gui.switch_showcase_off = False
			self.exit_combo(restore=True)

		if self.draw_max_button and prefs.force_hide_max_button:
			self.draw_max_button = False

		if gui.theme_name != prefs.theme_name:
			gui.reload_theme = True
			prefs.theme = get_theme_number(dirs, prefs.theme_name)
			#logging.info("Config reload theme...")

		# Restore in case of error
		if gui.rspw < 30 * gui.scale:
			gui.rspw = 100 * gui.scale

		# Lock right side panel to full size if fully extended -----
		if prefs.side_panel_layout == 0 and not prefs.album_mode:
			max_w = round(
				((window_size[1] - gui.panelY - gui.panelBY - 17 * gui.scale) * gui.art_max_ratio_lock) + 17 * gui.scale)
			# 17 here is the art box inset value

			if not prefs.album_mode and gui.rspw > max_w - 12 * gui.scale and gui.side_drag:
				gui.rsp_full_lock = True
		# ----------------------------------------------------------

		# Auto shrink left side panel --------------
		pl_width = window_size[0]
		pl_width_a = pl_width
		if gui.rsp:
			pl_width_a = pl_width - gui.rspw
			pl_width -= gui.rspw - 300 * gui.scale  # More sensitivity for compact with rsp for better visual balancing

		if pl_width < 900 * gui.scale and not gui.hide_tracklist_in_gallery:
			gui.lspw = 180 * gui.scale

			if pl_width < 700 * gui.scale:
				gui.lspw = 150 * gui.scale

			if prefs.left_panel_mode == "artist list" and prefs.artist_list_style == 1:
				gui.compact_artist_list = True
				gui.lspw = 75 * gui.scale
				if gui.force_side_on_drag:
					gui.lspw = 180 * gui.scale
		else:
			gui.lspw = 220 * gui.scale
			gui.compact_artist_list = False
			if prefs.left_panel_mode == "artist list":
				gui.lspw = 230 * gui.scale

		if gui.lsp and prefs.left_panel_mode == "folder view":
			gui.lspw = 260 * gui.scale
			max_insets = 0
			for item in self.tree_view_box.rows:
				max_insets = max(item[2], max_insets)

			p = (pl_width_a * 0.15) - round(200 * gui.scale)
			if gui.hide_tracklist_in_gallery:
				p = ((window_size[0] - gui.lspw) * 0.15) - round(170 * gui.scale)

			p = min(round(200 * gui.scale), p)
			if p > 0:
				gui.lspw += p
			if max_insets > 1:
				gui.lspw = max(gui.lspw, 260 * gui.scale + round(15 * gui.scale) * max_insets)

		# -----

		# Set bg art strength according to setting ----
		if prefs.art_bg_stronger == 3:
			prefs.art_bg_opacity = 29
		elif prefs.art_bg_stronger == 2:
			prefs.art_bg_opacity = 19
		else:
			prefs.art_bg_opacity = 10

		# -----

		# Adjust for for compact window sizes ----
		if (prefs.always_art_header or (w < 600 * gui.scale and not gui.rsp and prefs.art_in_top_panel)) and not prefs.album_mode:
			gui.top_bar_mode2 = True
			gui.panelY = round(100 * gui.scale)
			gui.playlist_top = gui.panelY + (8 * gui.scale)
			gui.playlist_top_bk = gui.playlist_top
		else:
			gui.top_bar_mode2 = False
			gui.panelY = round(30 * gui.scale)
			gui.playlist_top = gui.panelY + (8 * gui.scale)
			gui.playlist_top_bk = gui.playlist_top

		gui.show_playlist = True
		if w < 750 * gui.scale and prefs.album_mode:
			gui.show_playlist = False

		# Set bio panel size according to setting
		if prefs.bio_large:
			gui.artist_panel_height = 320 * gui.scale
			if window_size[0] < 600 * gui.scale:
				gui.artist_panel_height = 200 * gui.scale
		else:
			gui.artist_panel_height = 200 * gui.scale
			if window_size[0] < 600 * gui.scale:
				gui.artist_panel_height = 150 * gui.scale

		# Trigger artist bio reload if panel size has changed
		if gui.artist_info_panel:
			if gui.last_artist_panel_height != gui.artist_panel_height:
				self.artist_info_box.get_data(self.artist_info_box.artist_on)
			gui.last_artist_panel_height = gui.artist_panel_height

		# if w / h == 16 / 9:
		#     logging.info("YEP")
		# elif w / h < 16 / 9:
		#     logging.info("too low")
		# else:
		#     logging.info("too high")
		#logging.info((w, h))

		# input.mouse_click = False

		if prefs.spec2_colour_mode == 0:
			prefs.spec2_base = [10, 10, 100]
			prefs.spec2_multiply = [0.5, 1, 1]
		elif prefs.spec2_colour_mode == 1:
			prefs.spec2_base = [10, 10, 10]
			prefs.spec2_multiply = [2, 1.2, 5]
		# elif prefs.spec2_colour_mode == 2:
		#     prefs.spec2_base = [10, 100, 10]
		#     prefs.spec2_multiply = [1, -1, 0.4]

		gui.draw_vis4_top = False
		mini_signal_vis = gui.mode == GuiMode.MINI and prefs.mini_mode_mode == MiniModeMode.SIGNAL

		if ((gui.combo_mode and gui.showcase_mode and prefs.showcase_vis)
				or (gui.custom_mode and gui.vis4_in_widget)) \
				and gui.mode != GuiMode.MINI and prefs.backend == Backend.PHAZOR:
			gui.vis = 4
			gui.turbo = True
		elif mini_signal_vis:
			gui.vis = 2 if prefs.backend == Backend.PHAZOR else 0
			gui.turbo = True
		elif gui.vis_want == 0:
			gui.turbo = False
			gui.vis = 0
		else:
			gui.vis = gui.vis_want
			if gui.vis > 0:
				gui.turbo = True

		# The spectrogram widget feeds from its own C path (get_spectrum_hires,
		# separate FFT buffers) so it runs alongside whichever gui.vis was chosen
		# above rather than replacing it — it just needs the vis thread active.
		if gui.custom_mode and gui.spectrogram_in_widget \
				and gui.mode != GuiMode.MINI and prefs.backend == Backend.PHAZOR:
			gui.turbo = True

		# Disable vis when in compact view
		if ((gui.mode == GuiMode.MINI and not mini_signal_vis) or gui.top_bar_mode2):  # or prefs.backend == Backend.GSTREAMER:
			if not gui.combo_mode:
				gui.vis = 0
				gui.turbo = False

		if gui.mode == GuiMode.MAIN:
			if not gui.maximized and not gui.lowered:
				gui.save_size[0] = self.logical_size[0]
				gui.save_size[1] = self.logical_size[1]

			self.bottom_bar1.update()

			# if system != "Windows":
			# 	if draw_border:
			# 		gui.panelY = 30 * gui.scale + 3 * gui.scale
			# 		self.top_panel.ty = 3 * gui.scale
			# 	else:
			# 		gui.panelY = 30 * gui.scale
			# 		self.top_panel.ty = 0

			if gui.set_bar and gui.set_mode:
				gui.playlist_top = gui.playlist_top_bk + gui.set_height - 6 * gui.scale
			else:
				gui.playlist_top = gui.playlist_top_bk

			if gui.artist_info_panel:
				gui.playlist_top += gui.artist_panel_height

			gui.offset_extra = 0
			if self.draw_border and not prefs.left_window_control:
				offset = 61 * gui.scale
				if not self.draw_min_button:
					offset -= 35 * gui.scale
				if self.draw_max_button:
					offset += 33 * gui.scale
				if gui.macstyle:
					offset = 24
					if self.draw_min_button:
						offset += 20
					if self.draw_max_button:
						offset += 20
					offset = round(offset * gui.scale)
				gui.offset_extra = offset

			gui.album_v_slide_value = round(50 * gui.scale)
			if gui.gallery_show_text:
				gui.album_h_gap = 30 * gui.scale
				gui.album_v_gap = 66 * gui.scale
			else:
				gui.album_h_gap = 30 * gui.scale
				gui.album_v_gap = 25 * gui.scale

			if prefs.thin_gallery_borders:
				if gui.gallery_show_text:
					gui.album_h_gap = 20 * gui.scale
					gui.album_v_gap = 55 * gui.scale
				else:
					gui.album_h_gap = 17 * gui.scale
					gui.album_v_gap = 15 * gui.scale

				gui.album_v_slide_value = round(45 * gui.scale)

			if prefs.increase_gallery_row_spacing:
				gui.album_v_gap = round(gui.album_v_gap * 1.3)

			gui.gallery_scroll_field_left = window_size[0] - round(40 * gui.scale)

			# gui.spec_rect[0] = window_size[0] - gui.offset_extra - 90
			gui.spec1_rec.x = round(window_size[0] - gui.offset_extra - 90 * gui.scale)

			# gui.spec_x = window_size[0] - gui.offset_extra - 90

			gui.spec2_rec.x = round(window_size[0] - gui.spec2_rec.w - 10 * gui.scale - gui.offset_extra)

			gui.scroll_hide_box = (1, gui.panelY, 28 * gui.scale, window_size[1] - gui.panelBY - gui.panelY)

			# Tracklist row size and text positioning ---------------------------------
			gui.playlist_row_height = prefs.playlist_row_height
			gui.row_font_size = prefs.playlist_font_size  # 13

			gui.playlist_text_offset = round(gui.playlist_row_height * 0.55) + 4 - 13 * gui.scale

			if gui.scale != 1:
				real_font_px = ddt.f_dict[gui.row_font_size][2]
				# gui.playlist_text_offset = (round(gui.playlist_row_height - real_font_px) / 2) - ddt.get_y_offset("AbcD", gui.row_font_size, 100) + round(1.3 * gui.scale)
				if gui.scale < 1.3:
					gui.playlist_text_offset = round(((gui.playlist_row_height - real_font_px) / 2) - 1.9 * gui.scale)
				elif gui.scale < 1.5:
					gui.playlist_text_offset = round(((gui.playlist_row_height - real_font_px) / 2) - 1.3 * gui.scale)
				elif gui.scale < 1.75:
					gui.playlist_text_offset = round(((gui.playlist_row_height - real_font_px) / 2) - 1.1 * gui.scale)
				elif gui.scale < 2.3:
					gui.playlist_text_offset = round(((gui.playlist_row_height - real_font_px) / 2) - 1.5 * gui.scale)
				else:
					gui.playlist_text_offset = round(((gui.playlist_row_height - real_font_px) / 2) - 1.8 * gui.scale)

			gui.playlist_text_offset += prefs.tracklist_y_text_offset

			gui.pl_title_real_height = round(gui.playlist_row_height * 0.55) + 4 - 12

			# -------------------------------------------------------------------------
			gui.playlist_view_length = int(
				(window_size[1] - gui.panelBY - gui.playlist_top - 12 * gui.scale) // gui.playlist_row_height)

			box_r = gui.rspw / (window_size[1] - gui.panelBY - gui.panelY)

			if gui.art_aspect_ratio > 1.01:
				gui.art_unlock_ratio = True
				gui.art_max_ratio_lock = max(gui.art_aspect_ratio, gui.art_max_ratio_lock)


			#logging.info("Avaliabe: " + str(box_r))
			elif box_r <= 1:
				gui.art_unlock_ratio = False
				gui.art_max_ratio_lock = 1

			if gui.side_drag and self.inp.key_shift_down:
				gui.art_unlock_ratio = True
				gui.art_max_ratio_lock = 5

			gui.rspw = gui.pref_rspw
			if prefs.album_mode:
				gui.rspw = gui.pref_gallery_w

			# Limit the right side panel width to height of area
			if gui.rsp and prefs.side_panel_layout == 0:
				if prefs.album_mode:
					pass
				elif not gui.art_unlock_ratio:
					if gui.rsp_full_lock and not gui.side_drag:
						gui.rspw = window_size[0]

					gui.rspw = min(gui.rspw, window_size[1] - gui.panelY - gui.panelBY)

			# Determine how wide the playlist need to be
			gui.rsp_on_left = bool(gui.rsp and prefs.side_panel_left and not prefs.album_mode)
			gui.lsp_x = 0
			gui.rsp_x = window_size[0] - gui.rspw
			gui.rsp_split_x = gui.rsp_x

			gui.plw = window_size[0]
			gui.playlist_left = 0
			if gui.lsp:
				gui.plw -= gui.lspw
				gui.playlist_left += gui.lspw
			if gui.rsp and gui.rsp_on_left:
				gui.rsp_x = gui.playlist_left
				gui.rsp_split_x = gui.rsp_x + gui.rspw
				gui.plw -= gui.rspw
				gui.playlist_left += gui.rspw
			elif gui.rsp:
				gui.plw -= gui.rspw

			# Shrink side panel if playlist gets too small
			if window_size[0] > 100 and not gui.hide_tracklist_in_gallery and gui.plw < 300 and gui.rsp:
				l = 0
				if gui.lsp:
					l = gui.lspw

				gui.rspw = max(window_size[0] - l - 300, 110)
						# if prefs.album_mode and window_size[0] > 750 * gui.scale:
						#     gui.pref_gallery_w = gui.rspw

			# Determine how wide the playlist need to be (again)
			gui.rsp_on_left = bool(gui.rsp and prefs.side_panel_left and not prefs.album_mode)
			gui.lsp_x = 0
			gui.rsp_x = window_size[0] - gui.rspw
			gui.rsp_split_x = gui.rsp_x

			gui.plw = window_size[0]
			gui.playlist_left = 0
			if gui.lsp:
				gui.plw -= gui.lspw
				gui.playlist_left += gui.lspw
			if gui.rsp and gui.rsp_on_left:
				gui.rsp_x = gui.playlist_left
				gui.rsp_split_x = gui.rsp_x + gui.rspw
				gui.plw -= gui.rspw
				gui.playlist_left += gui.rspw
			elif gui.rsp:
				gui.plw -= gui.rspw

			if window_size[0] < 630 * gui.scale:
				gui.compact_bar = True
			else:
				gui.compact_bar = False

			gui.pl_update = 1

			# Tracklist sizing ----------------------------------------------------
			left = gui.playlist_left
			width = gui.plw

			center_mode = True
			if gui.lsp or gui.rsp or gui.set_mode:
				center_mode = False

			if gui.set_mode and window_size[0] < 600:
				center_mode = False

			gui.highlight_left = 0
			highlight_width = width

			inset_left = gui.highlight_left + 23 * gui.scale
			inset_width = highlight_width - 32 * gui.scale

			if gui.lsp and not gui.rsp:
				inset_width -= 10 * gui.scale

			if gui.lsp:
				inset_left -= 10 * gui.scale
				inset_width += 10 * gui.scale

			if gui.rsp_on_left:
				inset_left -= 11 * gui.scale
				inset_width += 6 * gui.scale

			if center_mode:
				if gui.set_mode:
					gui.highlight_left = int(pow((window_size[0] / gui.scale * 0.005), 2) * gui.scale)
				else:
					gui.highlight_left = int(pow((window_size[0] / gui.scale * 0.01), 2) * gui.scale)

				if window_size[0] < 600 * gui.scale:
					gui.highlight_left = 3 * gui.scale

				highlight_width -= gui.highlight_left * 2
				inset_left = gui.highlight_left + 18 * gui.scale
				inset_width = highlight_width - 25 * gui.scale

			if window_size[0] < 600 and gui.lsp:
				inset_width = highlight_width - 18 * gui.scale

			gui.tracklist_center_mode = center_mode
			gui.tracklist_inset_left = inset_left
			gui.tracklist_inset_width = inset_width
			gui.tracklist_highlight_left = gui.highlight_left
			gui.tracklist_highlight_width = highlight_width

			if prefs.album_mode and gui.hide_tracklist_in_gallery:
				gui.show_playlist = False
				gui.rspw = window_size[0] - 20 * gui.scale
				if gui.lsp:
					gui.rspw -= gui.lspw

			# --------------------------------------------------------------------

			if window_size[0] > gui.max_window_tex or window_size[1] > gui.max_window_tex:
				while window_size[0] > gui.max_window_tex:
					gui.max_window_tex += 1000
				while window_size[1] > gui.max_window_tex:
					gui.max_window_tex += 1000

				gui.tracklist_texture_rect = sdl3.SDL_FRect(0, 0, gui.max_window_tex, gui.max_window_tex)
				renderer = self.renderer
				sdl3.SDL_DestroyTexture(gui.tracklist_texture)
				sdl3.SDL_RenderClear(renderer)
				gui.tracklist_texture = sdl3.SDL_CreateTexture(
					renderer, sdl3.SDL_PIXELFORMAT_ARGB8888, sdl3.SDL_TEXTUREACCESS_TARGET,
					gui.max_window_tex,
					gui.max_window_tex)

				sdl3.SDL_SetRenderTarget(renderer, gui.tracklist_texture)
				sdl3.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0)
				sdl3.SDL_RenderClear(renderer)
				sdl3.SDL_SetTextureBlendMode(gui.tracklist_texture, sdl3.SDL_BLENDMODE_BLEND)

				# sdl3.SDL_SetRenderTarget(renderer, gui.main_texture)
				# sdl3.SDL_RenderClear(renderer)

				sdl3.SDL_DestroyTexture(gui.main_texture)
				gui.main_texture = sdl3.SDL_CreateTexture(
					renderer, sdl3.SDL_PIXELFORMAT_ARGB8888, sdl3.SDL_TEXTUREACCESS_TARGET,
					gui.max_window_tex,
					gui.max_window_tex)
				sdl3.SDL_SetTextureBlendMode(gui.main_texture, sdl3.SDL_BLENDMODE_BLEND)
				sdl3.SDL_SetRenderTarget(renderer, gui.main_texture)
				sdl3.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0)
				sdl3.SDL_SetRenderTarget(renderer, gui.main_texture)
				sdl3.SDL_RenderClear(renderer)

				sdl3.SDL_DestroyTexture(gui.main_texture_overlay_temp)
				gui.main_texture_overlay_temp = sdl3.SDL_CreateTexture(
					renderer, sdl3.SDL_PIXELFORMAT_ARGB8888,
					sdl3.SDL_TEXTUREACCESS_TARGET, gui.max_window_tex,
					gui.max_window_tex)
				sdl3.SDL_SetTextureBlendMode(gui.main_texture_overlay_temp, sdl3.SDL_BLENDMODE_BLEND)
				sdl3.SDL_SetRenderTarget(renderer, gui.main_texture_overlay_temp)
				sdl3.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0)
				sdl3.SDL_SetRenderTarget(renderer, gui.main_texture_overlay_temp)
				sdl3.SDL_RenderClear(renderer)

			self.update_set()

		if prefs.art_bg:
			self.thread_manager.ready("style")

	def test_show_add_home_music(self) -> None:
		self.gui.add_music_folder_ready = True

		if self.music_directory is None:
			self.gui.add_music_folder_ready = False
			return

		music_path = os.path.normpath(str(self.music_directory))

		for item in self.pctl.multi_playlist:
			if any(os.path.normpath(path) == music_path for path in item.last_folder):
				self.gui.add_music_folder_ready = False
				break

	def active_menu(self) -> Menu | None:
		"""The context menu currently being shown, if any."""
		for menu in Menu.instances:
			if menu.active and Menu.switch == menu.id:
				return menu
		return None

	def menu_popup_for_window(self, window_id: int):
		"""Return the menu popup (main or submenu) owning window_id, else None."""
		for popup in (self.menu_popup, self.submenu_popup):
			if popup is not None and popup.visible and popup.window is not None and popup.window_id() == window_id:
				return popup
		return None

	def _destroy_secondary(self, popup) -> None:
		"""Destroy a SecondaryWindow, dropping its icon textures from the caches.

		Destroying (not hiding) removes the window as an OS event target so it
		can't keep an event grab; the per-renderer asset textures must be dropped
		first so nothing holds a dangling handle a future renderer could collide
		with.
		"""
		if popup is None:
			return
		if popup.renderer is not None:
			self._forget_renderer_textures(popup.renderer)
		popup.destroy()

	def close_submenu_popup(self) -> None:
		"""Destroy just the submenu popup window (the main menu stays open)."""
		if self.submenu_popup is None:
			return
		if self.active_pointer_window is self.submenu_popup:
			self.active_pointer_window = self.menu_popup
		self._destroy_secondary(self.submenu_popup)
		self.submenu_popup = None

	def close_menu_popup(self) -> None:
		"""Destroy both menu popup windows and reset main input."""
		if self.menu_popup is None and self.submenu_popup is None:
			return
		self._destroy_secondary(self.submenu_popup)
		self.submenu_popup = None
		self._destroy_secondary(self.menu_popup)
		self.menu_popup = None
		self.active_pointer_window = None
		self.refresh_main_mouse_position()

	def _forget_renderer_textures(self, renderer) -> None:
		from tauon.t_modules.t_window import renderer_key
		key = renderer_key(renderer)
		for asset in (*LoadImageAsset.assets, *WhiteModImageAsset.assets):
			alt = getattr(asset, "_alt_textures", None)
			if alt is not None:
				alt.pop(key, None)

	def refresh_main_mouse_position(self) -> None:
		"""Re-sync inp.mouse_position to the real cursor location in the main window.

		While a menu popup is open the cursor is over the popup, so the main
		window receives no motion events and inp.mouse_position goes stale. When
		the menu closes by clicking an item (no main-window motion follows) the
		main UI would keep that stale position until the mouse next moves. The
		global cursor position is focus-independent, so (global - main window
		origin) scaled points->pixels gives the correct main-window position
		regardless of where the (possibly compositor-repositioned) popup was.
		"""
		gx, gy = ctypes.c_float(0), ctypes.c_float(0)
		sdl3.SDL_GetGlobalMouseState(ctypes.byref(gx), ctypes.byref(gy))
		wx, wy = ctypes.c_int(0), ctypes.c_int(0)
		sdl3.SDL_GetWindowPosition(self.t_window, ctypes.byref(wx), ctypes.byref(wy))
		logical = self.bag.logical_size
		scale = (self.window_size[0] / logical[0]) if logical[0] else 1.0
		self.inp.mouse_position[0] = int((gx.value - wx.value) * scale)
		self.inp.mouse_position[1] = int((gy.value - wy.value) * scale)

	def draw_popup_menus(self) -> None:
		"""Render context menus, in their own popup window or inline.

		Replaces the inline `for instance in Menu.instances: instance.render()`
		pass. Each menu's render() is still called every frame (it drives click
		handling and close logic and is a no-op when inactive). A menu that fits
		in the window (menu.use_popup is False) is drawn inline in the main
		window as before; one that would spill outside is pointed at its own
		popup window(s) instead.
		"""
		from tauon.t_modules.t_window import SecondaryWindow

		menu = self.active_menu()

		# Inline menus (and the no-menu case) render straight into the main
		# window; any popup windows from a previous popup menu are torn down.
		if menu is None or not menu.use_popup:
			for instance in Menu.instances:
				instance.render()
			self.close_menu_popup()  # no-op if nothing is open
			return

		# While a menu is popped out the cursor is over the popup window, so
		# main-window motion events stop and inp.mouse_position would freeze.
		# Track the real cursor (in main-window coords) from the global pointer
		# so menu-item callbacks that read inp.mouse_position - e.g. positioning
		# the rename box - get a live value. The menu itself hit-tests against
		# the popup-local pointer, so this does not affect menu interaction.
		self.refresh_main_mouse_position()

		if self.menu_popup is None:
			self.menu_popup = SecondaryWindow(self, focusable=False)
			self.active_pointer_window = self.menu_popup

		popup = self.menu_popup
		w, h = menu.popup_size()

		# Bottom-anchored menus (e.g. the bottom-panel playback menu) open upward:
		# the anchor marks the menu's bottom edge, so the window top is anchor - h.
		main_top_y = menu.popup_anchor[1] - h if menu.popup_bottom_anchor else menu.popup_anchor[1]

		if not popup.show(w, h, menu.popup_anchor[0], main_top_y):
			# Popup unavailable (creation failed): fall back to drawing the menu
			# inline in the main window, anchored where it was requested.
			menu.popup_window = None
			menu.pos = [menu.popup_anchor[0], main_top_y]
			for instance in Menu.instances:
				instance.render()
			return

		# Active submenu: render it first, into its own window beside the parent
		# item, so a click on a submenu item is consumed before the main menu's
		# close logic resets the click flag. sub_active/sub_y_position reflect the
		# previous frame's main render (one-frame lag, as before).
		if menu.sub_active > -1:
			if self.submenu_popup is None:
				self.submenu_popup = SecondaryWindow(self, focusable=False)
			sub = self.submenu_popup
			sw, sh = menu.submenu_size()
			sx = menu.popup_anchor[0] + menu.w
			sy = main_top_y + menu.sub_y_position
			if sub.show(sw, sh, int(sx), int(sy)):
				sub.begin_frame()
				menu.popup_window = sub
				menu.render_submenu()
				menu.popup_window = None
				sub.end_frame()
		elif self.submenu_popup is not None:
			self.close_submenu_popup()

		# Main column.
		popup.begin_frame()
		menu.popup_window = popup
		for instance in Menu.instances:
			instance.render()
		menu.popup_window = None
		popup.end_frame()

		# The menu may have closed itself during render() (item clicked / esc).
		if not menu.active:
			self.close_menu_popup()

	def is_level_zero(self, include_menus: bool = True) -> bool:
		if include_menus:
			for menu in Menu.instances:
				if menu.active:
					return False

		return not self.gui.rename_folder_box \
			and not self.gui.track_box \
			and not self.rename_track_box.active \
			and not self.radiobox.active \
			and not self.pref_box.enabled \
			and not self.gui.quick_search_mode \
			and not self.gui.rename_playlist_box \
			and not self.search_over.active \
			and not self.milk_choose.active \
			and not self.gui.box_over \
			and not self.trans_edit_box.active

	def get_radio_art(self) -> None:
		if self.radiobox.loaded_url in self.radiobox.websocket_source_urls:
			return
		if "ggdrasil" in self.radiobox.playing_title:
			time.sleep(3)
			url = "https://yggdrasilradio.net/data.php?"
			response = requests.get(url, timeout=10)
			if response.status_code == 200:
				lines = response.content.decode().split("|")
				if len(lines) > 11 and lines[11]:
					art_id = lines[11].strip().strip("*")
					art_url = "https://yggdrasilradio.net/images/albumart/" + art_id
					art_response = requests.get(art_url, timeout=10)
					if art_response.status_code == 200:
						if self.pctl.radio_image_bin:
							self.pctl.radio_image_bin.close()
							self.pctl.radio_image_bin = None
						self.pctl.radio_image_bin = io.BytesIO(art_response.content)
						self.pctl.radio_image_bin.seek(0)
						self.radiobox.dummy_track.art_url_key = "ok"
				self.pctl.update_tag_history()
		elif "gensokyoradio.net" in self.radiobox.loaded_url:
			response = requests.get("https://gensokyoradio.net/api/station/playing/", timeout=10)

			if response.status_code == 200:
				d = json.loads(response.text)
				song_info = d.get("SONGINFO")
				if song_info:
					self.radiobox.dummy_track.artist = song_info.get("ARTIST", "")
					self.radiobox.dummy_track.title = song_info.get("TITLE", "")
					self.radiobox.dummy_track.album = song_info.get("ALBUM", "")

				misc = d.get("MISC")
				if misc:
					art = misc.get("ALBUMART")
					if art:
						art_url = "https://gensokyoradio.net/images/albums/500/" + art
						art_response = requests.get(art_url, timeout=10)
						if art_response.status_code == 200:
							if self.pctl.radio_image_bin:
								self.pctl.radio_image_bin.close()
								self.pctl.radio_image_bin = None
							self.pctl.radio_image_bin = io.BytesIO(art_response.content)
							self.pctl.radio_image_bin.seek(0)
							self.radiobox.dummy_track.art_url_key = "ok"
				self.pctl.update_tag_history()

		elif "radio.plaza.one" in self.radiobox.loaded_url:
			time.sleep(3)
			logging.info("Fetching plaza art")
			response = requests.get("https://api.plaza.one/status", timeout=10)
			if response.status_code == 200:
				d = json.loads(response.text)
				if "song" in d:
					tr = d["song"]["length"] - d["song"]["position"]
					tr += 1
					tr = max(tr, 10)
					self.pctl.radio_poll_timer.force_set(tr * -1)

					if "artist" in d["song"]:
						self.radiobox.dummy_track.artist = d["song"]["artist"]
					if "title" in d["song"]:
						self.radiobox.dummy_track.title = d["song"]["title"]
					if "album" in d["song"]:
						self.radiobox.dummy_track.album = d["song"]["album"]
					if "artwork_src" in d["song"]:
						art_url = d["song"]["artwork_src"]
						art_response = requests.get(art_url, timeout=10)
						if art_response.status_code == 200:
							if self.pctl.radio_image_bin:
								self.pctl.radio_image_bin.close()
								self.pctl.radio_image_bin = None
							self.pctl.radio_image_bin = io.BytesIO(art_response.content)
							self.pctl.radio_image_bin.seek(0)
							self.radiobox.dummy_track.art_url_key = "ok"
					self.pctl.update_tag_history()

		# Failure
		elif self.pctl.radio_image_bin:
			self.pctl.radio_image_bin.close()
			self.pctl.radio_image_bin = None

		self.gui.clear_image_cache_next += 1

	def auto_name_pl(self, target_pl: int) -> None:
		if not self.pctl.multi_playlist[target_pl].playlist_ids:
			return

		albums:  list[str] = []
		artists: list[str] = []
		parents: list[str] = []

		track = None

		for index in self.pctl.multi_playlist[target_pl].playlist_ids:
			track = self.pctl.get_track(index)
			albums.append(track.album)
			if track.album_artist:
				artists.append(track.album_artist)
			else:
				artists.append(track.artist)
			parents.append(track.parent_folder_path)

		nt = ""
		artist = ""

		if track:
			artist = track.artist
			if track.album_artist:
				artist = track.album_artist

		if track and albums and albums[0] and albums.count(albums[0]) == len(albums):
			nt = artist + " - " + track.album
		elif track and artists and artists[0] and artists.count(artists[0]) == len(artists):
			nt = artists[0]

		else:
			nt = os.path.basename(commonprefix(parents))

		self.pctl.multi_playlist[target_pl].title = nt

	def get_object(self, index: int) -> TrackClass:
		return self.pctl.master_library[index]

	def update_title_do(self) -> None:
		if self.pctl.playing_state != PlayingState.STOPPED:
			if len(self.pctl.track_queue) > 0:
				line = self.pctl.master_library[self.pctl.track_queue[self.pctl.queue_step]].artist + " - " + \
					self.pctl.master_library[self.pctl.track_queue[self.pctl.queue_step]].title
				# line += "   : :   Tauon Music Box"
				line = line.encode("utf-8")
				sdl3.SDL_SetWindowTitle(self.t_window, line)
		else:
			line = "Tauon Music Box"
			line = line.encode("utf-8")
			sdl3.SDL_SetWindowTitle(self.t_window, line)

	def open_encode_out(self) -> None:
		if not self.prefs.encoder_output.exists():
			self.prefs.encoder_output.mkdir()
		if self.windows:
			subprocess.Popen(["explorer", self.prefs.encoder_output])
		elif self.macos:
			subprocess.Popen(["open", self.prefs.encoder_output])
		else:
			subprocess.Popen(["xdg-open", self.prefs.encoder_output])

	def g_open_encode_out(self, _a, _b, _c) -> None:
		self.open_encode_out()

	def hide_set_bar(self) -> None:
		self.gui.set_bar = False
		self.gui.update_layout = True
		self.gui.pl_update = 1

	def show_set_bar(self) -> None:
		self.gui.set_bar = True
		self.gui.update_layout = True
		self.gui.pl_update = 1

	def force_album_view(self) -> None:
		self.toggle_album_mode(force_on=True)

	def enter_combo(self) -> None:
		if not self.gui.combo_mode:
			self.gui.combo_was_album = self.prefs.album_mode
			self.gui.showcase_mode = False
			self.gui.radio_view = False
			if self.prefs.album_mode:
				self.toggle_album_mode()
			if self.gui.rsp:
				self.gui.rsp = False
			self.gui.combo_mode = True
			self.gui.update_layout = True

	def exit_combo(self, restore: bool = False) -> None:
		if self.prefs.shuffle_lock and not restore:
			self.toggle_shuffle_layout()
			return
		if self.gui.combo_mode:
			if self.gui.combo_was_album and restore:
				self.force_album_view()
			self.gui.showcase_mode = False
			self.gui.radio_view = False
			if self.prefs.prefer_side:
				self.gui.rsp = True
			self.gui.update_layout = True
			self.gui.combo_mode = False
			self.gui.was_radio = False

	def enter_showcase_view(self, track_id: int | None = None, timed_lyrics_edit: bool = False) -> None:
		if not self.gui.combo_mode:
			self.enter_combo()
			self.gui.was_radio = False
		self.gui.timed_lyrics_edit_view = timed_lyrics_edit
		self.gui.showcase_mode = True
		self.gui.radio_view = False
		if track_id is None or self.pctl.playing_object() is None or self.pctl.playing_object().index == track_id:
			pass
		else:
			self.gui.force_showcase_index = track_id
		self.inp.mouse_click = False
		self.gui.update_layout = True

	def enter_radio_view(self) -> None:
		if not self.gui.combo_mode:
			self.enter_combo()
		self.gui.showcase_mode = False
		self.gui.radio_view = True
		self.inp.mouse_click = False
		self.gui.update_layout = True

	def enter_timed_lyrics_edit(self, track: TrackClass) -> None:
		self.enter_showcase_view(track.index, timed_lyrics_edit=True)

	def standard_size(self) -> None:
		self.prefs.album_mode = False
		self.gui.rsp = True
		self.window_size = self.window_default_size
		sdl3.SDL_SetWindowSize(self.t_window, c_int(self.logical_size[0]), c_int(self.logical_size[1]))

		self.gui.rspw = 80 + int(self.window_size[0] * 0.18)
		self.gui.update_layout = True
		self.album_mode_art_size = 130
		# self.clear_img_cache()

	def path_stem_to_playlist(self, path: str, title: str) -> None:
		"""Used with gallery power bar"""
		playlist = []

		# Hack for networked tracks
		if path.lstrip("/") == title:
			for item in self.pctl.multi_playlist[self.pctl.active_playlist_viewing].playlist_ids:
				if title == os.path.basename(self.pctl.master_library[item].parent_folder_path):
					playlist.append(item)
		else:
			for item in self.pctl.multi_playlist[self.pctl.active_playlist_viewing].playlist_ids:
				if path in self.pctl.master_library[item].parent_folder_path:
					playlist.append(item)

		self.pctl.multi_playlist.append(self.pl_gen(
			title=os.path.basename(title).upper(),
			playlist_ids=copy.deepcopy(playlist),
			hide_title=False))

		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "s\"" + self.pctl.multi_playlist[self.pctl.active_playlist_viewing].title + "\" f\"" + path + "\""

		self.pctl.switch_playlist(len(self.pctl.multi_playlist) - 1)

	def activate_info_box(self) -> None:
		self.fader.rise()
		self.pref_box.enabled = True

	def activate_radio_box(self) -> None:
		self.radiobox.active = True
		self.radiobox.radio_field.clear()
		self.radiobox.radio_field_title.clear()

	def new_playlist_colour_callback(self) -> ColourRGBA:
		if self.gui.radio_view:
			return ColourRGBA(120, 90, 245, 255)
		return ColourRGBA(237, 80, 221, 255)

	def new_playlist_deco(self) -> Decorator:
		text = _("New Radio List") if self.gui.radio_view else _("New Playlist")
		return Decorator(self.colours.menu_text, self.colours.menu_background, text)

	def clean_db_show_test(self, _: int) -> bool:
		return self.gui.suggest_clean_db

	def clean_db_fast(self) -> None:
		keys = set(self.pctl.master_library.keys())
		for pl in self.pctl.multi_playlist:
			keys -= set(pl.playlist_ids)
		for item in keys:
			self.pctl.purge_track(item, fast=True)
		self.show_message(_("Done! {N} old items were removed.").format(N=len(keys)), mode="done")
		self.gui.suggest_clean_db = False

	def clean_db_deco(self) -> Decorator:
		return Decorator(self.colours.menu_text, ColourRGBA(30, 150, 120, 255), _("Clean Database!"))

	def show_import_music(self, _: int) -> bool:
		return self.gui.add_music_folder_ready

	def import_music(self) -> None:
		pl = self.pl_gen(_("Music"))
		pl.last_folder = [str(self.music_directory)]
		self.pctl.multi_playlist.append(pl)
		load_order = LoadClass()
		load_order.target = str(self.music_directory)
		load_order.playlist = pl.uuid_int
		self.load_orders.append(load_order)
		self.pctl.switch_playlist(len(self.pctl.multi_playlist) - 1)
		self.gui.add_music_folder_ready = False

	def clip_aar_al(self, index: int) -> None:
		if self.pctl.master_library[index].album_artist == "":
			line = self.pctl.master_library[index].artist + " - " + self.pctl.master_library[index].album
		else:
			line = self.pctl.master_library[index].album_artist + " - " + self.pctl.master_library[index].album
		sdl3.SDL_SetClipboardText(line.encode("utf-8"))

	def ser_gen_thread(self, tr: TrackClass) -> None:
		s_artist = tr.artist
		s_title = tr.title

		if s_artist in self.prefs.lyrics_subs:
			s_artist = self.prefs.lyrics_subs[s_artist]
		if s_title in self.prefs.lyrics_subs:
			s_title = self.prefs.lyrics_subs[s_title]

		line = genius(s_artist, s_title, return_url=True)

		r = requests.head(line, timeout=10)

		if r.status_code != 404:
			webbrowser.open(line, new=2, autoraise=True)
			self.gui.message_box = False
		else:
			line = "https://genius.com/search?q=" + urllib.parse.quote(f"{s_artist} {s_title}")
			webbrowser.open(line, new=2, autoraise=True)
			self.gui.message_box = False

	def ser_gen(self, ref: MenuTrackRef, get_lyrics: bool = False) -> None:
		tr = self.pctl.get_track(ref.track_id)
		if len(tr.title) < 1:
			return

		self.show_message(_("Searching..."))

		shoot = threading.Thread(target=self.ser_gen_thread, args=[tr])
		shoot.daemon = True
		shoot.start()

	def ser_wiki(self, ref: MenuTrackRef) -> None:
		index = ref.track_id
		if len(self.pctl.master_library[index].artist) < 2:
			return
		line = "https://en.wikipedia.org/wiki/Special:Search?search=" + urllib.parse.quote(self.pctl.master_library[index].artist)
		webbrowser.open(line, new=2, autoraise=True)

	def clip_ar_tr(self, ref: MenuTrackRef) -> None:
		index = ref.track_id
		line = self.pctl.master_library[index].artist + " - " + self.pctl.master_library[index].title
		sdl3.SDL_SetClipboardText(line.encode("utf-8"))

	def tidal_copy_album(self, ref: MenuTrackRef) -> None:
		index = ref.track_id
		t = self.pctl.master_library.get(index)
		if t and t.file_ext == "TIDAL":
			id = t.tidal_album
			if id:
				url = "https://listen.tidal.com/album/" + str(id)
				copy_to_clipboard(url)

	def is_tidal_track(self, ref: MenuTrackRef) -> bool:
		return self.pctl.master_library[ref.track_id].file_ext == "TIDAL"

	def drop_tracks_to_new_playlist(self, track_list: list[int], _hidden: bool = False) -> None:
		pl = self.new_playlist(switch=False)
		albums = []
		artists = []
		for item in track_list:
			albums.append(self.pctl.get_track(self.pctl.default_playlist[item]).album)
			artists.append(self.pctl.get_track(self.pctl.default_playlist[item]).artist)
			self.pctl.multi_playlist[pl].playlist_ids.append(self.pctl.default_playlist[item])

		if len(track_list) > 1:
			if len(albums) > 0 and albums.count(albums[0]) == len(albums):
				track = self.pctl.get_track(self.pctl.default_playlist[track_list[0]])
				artist = track.artist
				if track.album_artist:
					artist = track.album_artist
				self.pctl.multi_playlist[pl].title = artist + " - " + albums[0][:50]

		elif len(track_list) == 1 and artists:
			self.pctl.multi_playlist[pl].title = artists[0]

		if self.tree_view_box.dragging_name:
			self.pctl.multi_playlist[pl].title = self.tree_view_box.dragging_name
		self.dropped_playlist = pl
		self.pctl.notify_database_changed()

	def queue_deco(self) -> Decorator:
		line_colour = self.colours.menu_text if len(self.pctl.force_queue) > 0 else self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, None)

	def gstreamer_test(self, _) -> bool:
		# return True
		return self.prefs.backend == Backend.GSTREAMER

	def _build_search_results(
		self,
		query_text: str,
		*,
		all_folders: bool = False,
		cancel_check: Callable[[], bool] | None = None,
	) -> list[list[int | str | None]]:
		search_magic_local = search_magic
		search_magic_any_local = search_magic_any
		genre_correct_local = genre_correct
		get_year_from_string_local = get_year_from_string
		use_sep_genre_multi = self.prefs.sep_genre_multi
		search_string_cache = self.search_string_cache
		search_dia_string_cache = self.search_dia_string_cache
		search_field_cache = self.search_field_cache
		search_dia_field_cache = self.search_dia_field_cache
		pctl = self.pctl
		master_library = pctl.master_library
		multi_playlist = pctl.multi_playlist
		temp_results: list[list[int | str | None] | None] = []

		artists = {}
		albums = {}
		genres = {}
		metas = {}
		composers = {}
		years = {}
		tracks = set()

		br = 0
		o_text = query_text.lower().replace("-", "")
		if o_text in ("the", "and"):
			return []

		dia_mode = o_text.isascii()

		artist_mode = False
		if o_text.startswith("artist "):
			o_text = o_text[7:]
			artist_mode = True

		album_mode = False
		if o_text.startswith("album "):
			o_text = o_text[6:]
			album_mode = True

		composer_mode = False
		if o_text.startswith("composer "):
			o_text = o_text[9:]
			composer_mode = True

		year_mode = False
		if o_text.startswith("year "):
			o_text = o_text[5:]
			year_mode = True

		cn_mode = False
		if self.use_cc and CJK_SEARCH_PATTERN.search(o_text):
			s2t, t2s = self._get_opencc_converters()
			if s2t and t2s:
				t_cn = s2t.convert(o_text)
				s_cn = t2s.convert(o_text)
				cn_mode = True

		s_text = o_text
		searched = set()
		s_text_nospace = s_text.replace(" ", "")

		for playlist in multi_playlist:
			for track in playlist.playlist_ids:
				if track in searched:
					continue
				searched.add(track)

				if cn_mode:
					s_text = o_text
					s_text_nospace = s_text.replace(" ", "")
					cache_string = search_string_cache.get(track)
					if cache_string:
						if search_magic_any_local(s_text, cache_string):
							pass
						elif search_magic_any_local(t_cn, cache_string):
							s_text = t_cn
							s_text_nospace = s_text.replace(" ", "")
						elif search_magic_any_local(s_cn, cache_string):
							s_text = s_cn
							s_text_nospace = s_text.replace(" ", "")

				if dia_mode:
					cache_string = search_dia_string_cache.get(track)
					if cache_string is not None and not search_magic_any_local(s_text, cache_string):
						continue
				else:
					cache_string = search_string_cache.get(track)
					if cache_string is not None and not search_magic_any_local(s_text, cache_string):
						continue

				t = master_library[track]
				fields = search_field_cache.get(track)
				if fields is None:
					title = t.title.lower().replace("-", "")
					artist = t.artist.lower().replace("-", "")
					album_artist = t.album_artist.lower().replace("-", "")
					composer = t.composer.lower().replace("-", "")
					date = t.date.lower().replace("-", "")
					album = t.album.lower().replace("-", "")
					genre = t.genre.lower().replace("-", "")
					genre_nospace = genre.replace(" ", "")
					filename = t.filename.lower().replace("-", "")
					sartist = (t.artist_sort if t.artist_sort is not None else "").lower()
					stem_raw = os.path.dirname(t.parent_folder_path)
					stem_search = stem_raw.lower().replace("-", "")
					lyrics = t.lyrics.lower().replace("-", "") + " " + t.synced.lower().replace("-", "")
					search_field_cache[track] = (
						title,
						artist,
						album_artist,
						composer,
						date,
						album,
						genre,
						genre_nospace,
						filename,
						sartist,
						stem_search,
						stem_raw,
						lyrics,
					)
				else:
					title, artist, album_artist, composer, date, album, genre, genre_nospace, filename, sartist, stem_search, stem_raw, lyrics = fields

				if cache_string is None:
					if not dia_mode:
						search_string_cache[track] = title + artist + album_artist + composer + date + album + genre + sartist + filename + stem_search + lyrics

					if cn_mode:
						cache_string = search_string_cache.get(track)
						if cache_string:
							if search_magic_any_local(s_text, cache_string):
								pass
							elif search_magic_any_local(t_cn, cache_string):
								s_text = t_cn
								s_text_nospace = s_text.replace(" ", "")
							elif search_magic_any_local(s_cn, cache_string):
								s_text = s_cn
								s_text_nospace = s_text.replace(" ", "")

				if dia_mode:
					dia_fields = search_dia_field_cache.get(track)
					if dia_fields is None:
						d_title = unidecode(title)
						d_artist = unidecode(artist)
						d_album_artist = unidecode(album_artist)
						d_composer = unidecode(composer)
						d_album = unidecode(album)
						d_filename = unidecode(filename)
						d_sartist = unidecode(sartist)
						d_lyrics = unidecode(lyrics)
						search_dia_field_cache[track] = (
							d_title,
							d_artist,
							d_album_artist,
							d_composer,
							d_album,
							d_filename,
							d_sartist,
							d_lyrics,
						)
					else:
						d_title, d_artist, d_album_artist, d_composer, d_album, d_filename, d_sartist, d_lyrics = dia_fields

					title = d_title
					artist = d_artist
					album_artist = d_album_artist
					composer = d_composer
					album = d_album
					filename = d_filename
					sartist = d_sartist
					lyrics = d_lyrics

					if cache_string is None:
						search_dia_string_cache[track] = title + artist + album_artist + composer + date + album + genre + sartist + filename + stem_search + lyrics

				if len(s_text) > 2 and s_text in stem_search:
					if stem_raw in metas:
						metas[stem_raw] += 2
					else:
						temp_results.append([5, stem_raw, track, playlist.uuid_int, 0])
						metas[stem_raw] = 2

				if s_text_nospace in genre_nospace:
					if "/" in genre or "," in genre or ";" in genre:
						for split in genre.replace(";", "/").replace(",", "/").split("/"):
							if s_text_nospace in split.replace(" ", ""):
								split = genre_correct_local(split)
								if use_sep_genre_multi:
									split += "+"
								if split in genres:
									genres[split] += 3
								else:
									temp_results.append([3, split, track, playlist.uuid_int, 0])
									genres[split] = 1
									if split.replace(" ", "") == genre_nospace:
										genres[split] += 10000
					else:
						name = genre_correct_local(t.genre)
						if name in genres:
							genres[name] += 3
						else:
							temp_results.append([3, name, track, playlist.uuid_int, 0])
							genres[name] = 1
							if s_text_nospace == genre_nospace:
								genres[name] += 10000

				if s_text in composer:
					if t.composer in composers:
						composers[t.composer] += 2
					else:
						temp_results.append([6, t.composer, track, playlist.uuid_int, 0])
						composers[t.composer] = 2

				if s_text in date:
					year = get_year_from_string_local(date)
					if year:
						if year in years:
							years[year] += 1
						else:
							temp_results.append([7, year, track, playlist.uuid_int, 0])
							years[year] = 1000

				if search_magic_local(s_text, title + " " + artist + " " + filename + " " + album + " " + sartist + " " + album_artist):
					if t.artists:
						for a in t.artists:
							a_lower = a.lower()
							if search_magic_local(s_text, a_lower):
								value = 1
								if a_lower.startswith(s_text):
									value = 5

								if a in artists:
									artists[a] += value
								else:
									temp_results.append([0, a, track, playlist.uuid_int, 0])
									artists[a] = value

								if t.album in albums:
									albums[t.album] += 1
								else:
									temp_results.append([1, t.album, track, playlist.uuid_int, 0])
									albums[t.album] = 1

					elif search_magic_local(s_text, artist + sartist):
						value = 1
						if artist.startswith(s_text):
							value = 10

						if t.artist in artists:
							artists[t.artist] += value
						else:
							temp_results.append([0, t.artist, track, playlist.uuid_int, 0])
							artists[t.artist] = value

						if t.album in albums:
							albums[t.album] += 1
						else:
							temp_results.append([1, t.album, track, playlist.uuid_int, 0])
							albums[t.album] = 1

					elif search_magic_local(s_text, album_artist):
						value = 1
						if t.album_artist.startswith(s_text):
							value = 5

						if t.album_artist in artists:
							artists[t.album_artist] += value
						else:
							temp_results.append([0, t.album_artist, track, playlist.uuid_int, 0])
							artists[t.album_artist] = value

						if t.album in albums:
							albums[t.album] += 1
						else:
							temp_results.append([1, t.album, track, playlist.uuid_int, 0])
							albums[t.album] = 1

					if s_text in album:
						value = 1
						if s_text == album:
							value = 3

						if t.album in albums:
							albums[t.album] += value
						else:
							temp_results.append([1, t.album, track, playlist.uuid_int, 0])
							albums[t.album] = value

					if search_magic_local(s_text, artist + sartist) or search_magic_local(s_text, album):
						if t.album in albums:
							albums[t.album] += 3
						else:
							temp_results.append([1, t.album, track, playlist.uuid_int, 0])
							albums[t.album] = 3

					elif search_magic_any_local(s_text, artist + sartist) and search_magic_any_local(s_text, album):
						if t.album in albums:
							albums[t.album] += 3
						else:
							temp_results.append([1, t.album, track, playlist.uuid_int, 0])
							albums[t.album] = 3

					if s_text in title:
						if t not in tracks:
							value = 50
							if s_text == title:
								value = 200
							temp_results.append([2, t.title, track, playlist.uuid_int, value])
							tracks.add(t)
					elif t not in tracks:
						temp_results.append([2, t.title, track, playlist.uuid_int, 1])
						tracks.add(t)
				else:
					val = search_magic_beefy(s_text, lyrics)
					if val > 90:
						if t not in tracks:
							temp_results.append([9, t.title, track, playlist.uuid_int, val-100])
							tracks.add(t)

				br += 1
				if br > 800:
					time.sleep(0.005)  # Throttle thread
					br = 0
					if cancel_check and cancel_check():
						break

		if artist_mode:
			for i in reversed(range(len(temp_results))):
				if temp_results[i][0] != 0:
					del temp_results[i]

		elif album_mode:
			for i in reversed(range(len(temp_results))):
				if temp_results[i][0] != 1:
					del temp_results[i]

		elif composer_mode:
			for i in reversed(range(len(temp_results))):
				if temp_results[i][0] != 6:
					del temp_results[i]

		elif year_mode:
			for i in reversed(range(len(temp_results))):
				if temp_results[i][0] != 7:
					del temp_results[i]

		for i, item in enumerate(temp_results):
			if item[0] == 0:
				temp_results[i][4] = artists[item[1]]
			if item[0] == 1:
				temp_results[i][4] = albums[item[1]]
			if item[0] == 3:
				temp_results[i][4] = genres[item[1]]
			if item[0] == 5:
				temp_results[i][4] = metas[item[1]]
				if not all_folders and metas[item[1]] < 42:
					temp_results[i] = None
			if item[0] == 6:
				temp_results[i][4] = composers[item[1]]
			if item[0] == 7:
				temp_results[i][4] = years[item[1]]
			# 8 is playlists

		temp_results[:] = [item for item in temp_results if item is not None]
		results = sorted(temp_results, key=lambda x: x[4], reverse=True)

		i = 0
		for playlist in multi_playlist:
			if search_magic_local(s_text, playlist.title.lower()):
				item = [8, playlist.title, None, playlist.uuid_int, 100000]
				results.insert(0, item)
				i += 1
				if i > 3:
					break

		return results

	def _get_opencc_converters(self):
		"""Return thread-local OpenCC converters to avoid re-instantiation in hot paths."""
		if not self.use_cc:
			return None, None

		s2t = getattr(self._opencc_local, "s2t", None)
		t2s = getattr(self._opencc_local, "t2s", None)
		if s2t and t2s:
			return s2t, t2s

		try:
			s2t = opencc.OpenCC("s2t")
			t2s = opencc.OpenCC("t2s")
		except Exception:
			logging.exception("Failed to initialize OpenCC converters")
			self.use_cc = False
			return None, None

		self._opencc_local.s2t = s2t
		self._opencc_local.t2s = t2s
		return s2t, t2s

	def regenerate_playlist(self, pl: int = -1, silent: bool = False, id: int | None = None) -> None:
		if id is None and pl == -1:
			return

		if id is None:
			id = self.pctl.pl_to_id(pl)

		if pl == -1:
			pl = self.pctl.id_to_pl(id)
			if pl is None:
				return

		source_playlist = self.pctl.multi_playlist[pl].playlist_ids

		string = self.pctl.gen_codes.get(id)
		if not string:
			if not silent:
				self.show_message(_("This playlist has no generator"))
			return

		cmds, quotes, inquote = parse_generator(string)

		if inquote:
			self.gui.gen_code_errors = "close"
			return

		playlist = []
		selections: list[list[int]] = []
		errors = False
		selections_searched = 0
		search_results_cache: dict[tuple[str, bool], list[list[int | str | None]]] = {}

		def is_source_type(code: str | None) -> bool:
			return \
				code is None or \
				code == "" or \
				code.startswith((
					"self", "jelly", "plex", "tau", "air", "sal",
					"tpl", "tfa", "tft", "tar", "tmix"))

		def get_search_results(query: str, *, all_folders: bool = False) -> list[list[int | str | None]]:
			key = (query, all_folders)
			cached = search_results_cache.get(key)
			if cached is not None:
				return cached
			results = self._build_search_results(query, all_folders=all_folders)
			search_results_cache[key] = results
			return results

		def parse_minutes(value: str) -> float | None:
			if not value:
				return None
			try:
				return float(value) * 60
			except ValueError:
				return None

		#logging.info(cmds)
		#logging.info(quotes)

		self.pctl.regen_in_progress = True

		for i, cm in enumerate(cmds):
			quote = quotes[i]

			if cm.startswith("\"") and (cm.endswith((">", "<"))):
				cm_found = False

				for col in self.column_names:
					if quote.lower() == col.lower() or _(quote).lower() == col.lower():
						cm_found = True

						if cm[-1] == ">":
							self.sort_ass(0, invert=False, custom_list=playlist, custom_name=col)
						elif cm[-1] == "<":
							self.sort_ass(0, invert=True, custom_list=playlist, custom_name=col)
						break
				if cm_found:
					continue

			elif cm == "self":
				selections.append(self.pctl.multi_playlist[pl].playlist_ids)

			elif cm == "auto":
				pass

			elif cm.startswith("tpl\""):
				playlist.extend(self.tidal.playlist(quote, return_list=True))

			elif cm == "tfa":
				playlist.extend(self.tidal.fav_albums(return_list=True))

			elif cm == "tft":
				playlist.extend(self.tidal.fav_tracks(return_list=True))

			elif cm.startswith("tar\""):
				playlist.extend(self.tidal.artist(quote, return_list=True))

			elif cm.startswith("tmix\""):
				playlist.extend(self.tidal.mix(quote, return_list=True))

			elif cm == "plex":
				if not self.plex.scanning:
					playlist.extend(self.plex.get_albums(return_list=True))

			elif cm.startswith("jelly\""):
				if not self.jellyfin.scanning:
					playlist.extend(self.jellyfin.get_playlist(quote, return_list=True))

			elif cm == "jelly":
				if not self.jellyfin.scanning:
					playlist.extend(self.jellyfin.ingest_library(return_list=True))

			elif cm == "tau":
				if not self.tau.processing:
					playlist.extend(self.tau.get_playlist(self.pctl.multi_playlist[pl].title, return_list=True))

			elif cm == "air":
				if not self.subsonic.scanning:
					playlist.extend(self.subsonic.get_music3(return_list=True))

			elif cm == "a":
				if not selections and not selections_searched:
					for plist in self.pctl.multi_playlist:
						code = self.pctl.gen_codes.get(plist.uuid_int)
						if is_source_type(code):
							selections.append(plist.playlist_ids)

				temp = []
				for selection in selections:
					temp += selection

				playlist += list(OrderedDict.fromkeys(temp))
				selections.clear()

			elif cm == "cue":
				for i in reversed(range(len(playlist))):
					tr = self.pctl.get_track(playlist[i])
					if not tr.is_cue:
						del playlist[i]
				playlist = list(OrderedDict.fromkeys(playlist))

			elif cm == "today":
				d = datetime.date.today()
				for i in reversed(range(len(playlist))):
					tr = self.pctl.get_track(playlist[i])
					if tr.date[5:7] != f"{d:%m}" or tr.date[8:10] != f"{d:%d}":
						del playlist[i]
				playlist = list(OrderedDict.fromkeys(playlist))

			elif cm.startswith("com\""):
				for i in reversed(range(len(playlist))):
					tr = self.pctl.get_track(playlist[i])
					if quote not in tr.comment:
						del playlist[i]
				playlist = list(OrderedDict.fromkeys(playlist))

			elif cm.startswith("ext"):
				value = quote.upper()
				if value:
					if not selections:
						for plist in self.pctl.multi_playlist:
							selections.append(plist.playlist_ids)

					temp = []
					for selection in selections:
						for track in selection:
							tr = self.pctl.get_track(track)
							if tr.file_ext == value:
								temp.append(track)

					playlist += list(OrderedDict.fromkeys(temp))

			elif cm == "ypa":
				playlist = self.year_sort(0, playlist)

			elif cm == "tn":
				self.sort_track_2(0, playlist)

			elif cm == "ia>":
				playlist = self.gen_last_imported_folders(0, playlist)

			elif cm == "ia<":
				playlist = self.gen_last_imported_folders(0, playlist, reverse=False)

			elif cm == "m>":
				playlist = self.gen_last_modified(0, playlist)

			elif cm == "m<":
				playlist = self.gen_last_modified(0, playlist, reverse=False)

			elif cm in ("ly", "lyrics"):
				playlist = self.gen_lyrics(0, playlist)

			elif cm in ("l", "love", "loved"):
				playlist = self.gen_love(0, playlist)

			elif cm == "clr":
				selections.clear()

			elif cm in ("rv", "reverse"):
				playlist = self.gen_reverse(0, playlist)

			elif cm == "rva":
				playlist = self.gen_folder_reverse(0, playlist)

			elif cm == "rata>":

				playlist = self.gen_folder_top_rating(0, custom_list=playlist)

			elif cm == "rat>":
				def rat_key(track_id: int) -> int:
					return self.star_store.get_rating(track_id)

				playlist = sorted(playlist, key=rat_key, reverse=True)

			elif cm == "rat<":
				def rat_key(track_id: int) -> int:
					return self.star_store.get_rating(track_id)

				playlist = sorted(playlist, key=rat_key)

			elif cm[:4] == "rat=":
				value = cm[4:]
				try:
					value = float(value) * 2
					temp = []
					for item in playlist:
						if value == self.star_store.get_rating(item):
							temp.append(item)
					playlist = temp
				except Exception:
					logging.exception("Failed to get rating")
					errors = True

			elif cm[:4] == "rat<":
				value = cm[4:]
				try:
					value = float(value) * 2
					temp = []
					for item in playlist:
						if value > self.star_store.get_rating(item):
							temp.append(item)
					playlist = temp
				except Exception:
					logging.exception("Failed to get rating")
					errors = True

			elif cm[:4] == "rat>":
				value = cm[4:]
				try:
					value = float(value) * 2
					temp = []
					for item in playlist:
						if value < self.star_store.get_rating(item):
							temp.append(item)
					playlist = temp
				except Exception:
					logging.exception("Failed to get rating")
					errors = True

			elif cm == "rat":
				temp = []
				for item in playlist:
					# tr = pctl.get_track(item)
					if self.star_store.get_rating(item) > 0:
						temp.append(item)
				playlist = temp

			elif cm == "norat":
				temp = []
				for item in playlist:
					if self.star_store.get_rating(item) == 0:
						temp.append(item)
				playlist = temp

			elif cm == "d>":
				playlist = self.gen_sort_len(0, custom_list=playlist)

			elif cm == "d<":
				playlist = self.gen_sort_len(0, custom_list=playlist)
				playlist = list(reversed(playlist))

			elif cm[:2] == "d<":
				value = parse_minutes(cm[2:])
				if value is not None:
					for i in reversed(range(len(playlist))):
						tr = self.pctl.get_track(playlist[i])
						if not value > tr.length:
							del playlist[i]

			elif cm[:2] == "d>":
				value = parse_minutes(cm[2:])
				if value is not None:
					for i in reversed(range(len(playlist))):
						tr = self.pctl.get_track(playlist[i])
						if not value < tr.length:
							del playlist[i]

			elif cm == "path":
				self.sort_path_pl(0, custom_list=playlist)

			elif cm == "pa>":
				playlist = self.gen_folder_top(0, custom_list=playlist)

			elif cm == "pa<":
				playlist = self.gen_folder_top(0, custom_list=playlist)
				playlist = self.gen_folder_reverse(0, playlist)

			elif cm == "ad>":
				playlist = self.gen_folder_duration(0, custom_list=playlist)

			elif cm == "ad<":
				playlist = self.gen_folder_duration(0, custom_list=playlist, reverse=False)

			elif cm[:3] == "ad>":
				value = parse_minutes(cm[3:])
				if value is not None:
					temp: list[int] = []
					for album, duration in self.gen_folder_duration(0, get_sets=True, custom_list=playlist) or []:
						if duration > value:
							temp.extend(album)
					playlist = temp

			elif cm[:3] == "ad<":
				value = parse_minutes(cm[3:])
				if value is not None:
					temp: list[int] = []
					for album, duration in self.gen_folder_duration(0, get_sets=True, custom_list=playlist) or []:
						if duration < value:
							temp.extend(album)
					playlist = temp

			elif cm in ("pt>", "pc>"):
				playlist = self.gen_top_100(0, custom_list=playlist)

			elif cm in ("pt<", "pc<"):
				playlist = self.gen_top_100(0, custom_list=playlist)
				playlist = list(reversed(playlist))

			elif cm[:3] == "pt>":
				value = cm[3:]
				if value and value.isdigit():
					value = int(value)
					for i in reversed(range(len(playlist))):
						t_time = self.star_store.get(playlist[i])
						if t_time < value:
							del playlist[i]

			elif cm[:3] == "pt<":
				value = cm[3:]
				if value and value.isdigit():
					value = int(value)
					for i in reversed(range(len(playlist))):
						t_time = self.star_store.get(playlist[i])
						if t_time > value:
							del playlist[i]

			elif cm[:3] == "pc>":
				value = cm[3:]
				if value and value.isdigit():
					value = int(value)
					for i in reversed(range(len(playlist))):
						t_time = self.star_store.get(playlist[i])
						tr = self.pctl.get_track(playlist[i])
						# Use the same formula as for calculating Play count
						if tr.length < 1 or not value < t_time / (tr.length - 1):
							del playlist[i]

			elif cm[:3] == "pc<":
				value = cm[3:]
				if value and value.isdigit():
					value = int(value)
					for i in reversed(range(len(playlist))):
						t_time = self.star_store.get(playlist[i])
						tr = self.pctl.get_track(playlist[i])
						if tr.length > 0:
							# Use the same formula as for calculating Play count
							if not value > t_time / (tr.length - 1):
								del playlist[i]

			elif cm == "y<":
				playlist = self.gen_sort_date(0, False, playlist)

			elif cm == "y>":
				playlist = self.gen_sort_date(0, True, playlist)

			elif cm[:2] == "y=":
				value = cm[2:]
				if value:
					temp = []
					for item in playlist:
						if value in self.pctl.master_library[item].date:
							temp.append(item)
					playlist = temp

			elif cm[:3] == "y>=":
				value = cm[3:]
				if value and value.isdigit():
					value = int(value)
					temp = []
					for item in playlist:
						if self.pctl.master_library[item].date[:4].isdigit() and int(
								self.pctl.master_library[item].date[:4]) >= value:
							temp.append(item)
					playlist = temp

			elif cm[:3] == "y<=":
				value = cm[3:]
				if value and value.isdigit():
					value = int(value)
					temp = []
					for item in playlist:
						if self.pctl.master_library[item].date[:4].isdigit() and int(
								self.pctl.master_library[item].date[:4]) <= value:
							temp.append(item)
					playlist = temp

			elif cm[:2] == "y>":
				value = cm[2:]
				if value and value.isdigit():
					value = int(value)
					temp = []
					for item in playlist:
						if self.pctl.master_library[item].date[:4].isdigit() and int(self.pctl.master_library[item].date[:4]) > value:
							temp.append(item)
					playlist = temp

			elif cm[:2] == "y<":
				value = cm[2:]
				if value and value.isdigit():
					value = int(value)
					temp = []
					for item in playlist:
						if self.pctl.master_library[item].date[:4].isdigit() and int(self.pctl.master_library[item].date[:4]) < value:
							temp.append(item)
					playlist = temp

			elif cm in ("st", "rt", "r"):
				random.shuffle(playlist)

			elif cm in ("sf", "rf", "ra", "sa"):
				playlist = self.gen_folder_shuffle(0, custom_list=playlist)

			elif cm.startswith("n"):
				value = cm[1:]
				if value.isdigit():
					playlist = playlist[:int(value)]

			# SEARCH FOLDER
			elif cm.startswith("p\"") and len(cm) > 3:

				if not selections:
					for plist in self.pctl.multi_playlist:
						code = self.pctl.gen_codes.get(plist.uuid_int)
						if is_source_type(code):
							selections.append(plist.playlist_ids)

				results = get_search_results(quote, all_folders=True)
				found_name = ""
				for result in results:
					if result[0] == 5:
						found_name = result[1]
						break

				if not found_name or not isinstance(found_name, str):
					logging.info("No folder search result found")
					continue

				playlist += self.search_over.click_meta(found_name, get_list=True, search_lists=selections)

			# SEARCH GENRE
			elif (cm.startswith(('g"', 'gm"', 'g="'))) and len(cm) > 3:
				if not selections:
					for plist in self.pctl.multi_playlist:
						code = self.pctl.gen_codes.get(plist.uuid_int)
						if is_source_type(code):
							selections.append(plist.playlist_ids)

				g_search = quote.lower().replace("-", "")
				results = get_search_results(g_search)
				found_name = ""

				if cm.startswith("g=\""):
					for result in results:
						if result[0] == 3 and isinstance(result[1], str) and result[1].lower().replace("-", "") == g_search:
							found_name = result[1]
							break
				elif cm.startswith("g\"") or not self.prefs.sep_genre_multi:
					for result in results:
						if result[0] == 3 and isinstance(result[1], str):
							found_name = result[1]
							break
				elif cm.startswith("gm\""):
					for result in results:
						if result[0] == 3 and isinstance(result[1], str) and result[1].endswith("+"):
							found_name = result[1]
							break

				if not found_name:
					logging.warning("No genre search result found")
					continue

				playlist += self.search_over.click_genre(found_name, get_list=True, search_lists=selections)

			# SEARCH ARTIST
			elif cm.startswith("a\"") and len(cm) > 3 and cm != "auto":
				if not selections:
					for plist in self.pctl.multi_playlist:
						code = self.pctl.gen_codes.get(plist.uuid_int)
						if is_source_type(code):
							selections.append(plist.playlist_ids)

				results = get_search_results("artist " + quote)
				found_name = ""
				for result in results:
					if result[0] == 0:
						found_name = result[1]
						break

				if not found_name or not isinstance(found_name, str):
					logging.warning("No artist search result found")
					continue

				playlist += self.search_over.click_artist(found_name, get_list=True, search_lists=selections)

			elif cm.startswith("ff\""):
				for i in reversed(range(len(playlist))):
					tr = self.pctl.get_track(playlist[i])
					line = f"{tr.title} {tr.artist} {tr.album} {tr.fullpath} {tr.composer} {tr.comment} {tr.album_artist}".lower()

					if self.prefs.diacritic_search and all([ord(c) < 128 for c in quote]):
						line = str(unidecode(line))

					if not search_magic(quote.lower(), line):
						del playlist[i]

				playlist = list(OrderedDict.fromkeys(playlist))

			elif cm.startswith("fx\""):
				for i in reversed(range(len(playlist))):
					tr = self.pctl.get_track(playlist[i])
					line = " ".join(
						[tr.title, tr.artist, tr.album, tr.fullpath, tr.composer, tr.comment, tr.album_artist]).lower()
					if self.prefs.diacritic_search and all([ord(c) < 128 for c in quote]):
						line = str(unidecode(line))

					if search_magic(quote.lower(), line):
						del playlist[i]

			elif cm.startswith(('find"', 'f"', 'fs"')):
				if not selections:
					for plist in self.pctl.multi_playlist:
						code = self.pctl.gen_codes.get(plist.uuid_int)
						if is_source_type(code):
							selections.append(plist.playlist_ids)

				cooldown = 0
				dones = {}
				for selection in selections:
					for track_id in selection:
						if track_id not in dones:
							tr = self.pctl.get_track(track_id)

							if cm.startswith("fs\""):
								line = f"{tr.title}|{tr.artist}|{tr.album}|{tr.fullpath}|{tr.composer}|{tr.comment}|{tr.album_artist}".lower()
								if quote.lower() in line:
									playlist.append(track_id)
							else:
								line = f"{tr.title} {tr.artist} {tr.album} {tr.fullpath} {tr.composer} {tr.comment} {tr.album_artist}".lower()

								# if prefs.diacritic_search and all([ord(c) < 128 for c in quote]):
								#	 line = str(unidecode(line))

								if search_magic(quote.lower(), line):
									playlist.append(track_id)

							cooldown += 1
							if cooldown > 300:
								time.sleep(0.005)
								cooldown = 0

							dones[track_id] = None

				playlist = list(OrderedDict.fromkeys(playlist))

			elif cm.startswith(('s"', 'px"')):
				pl_name = quote
				target = None
				for p in self.pctl.multi_playlist:
					if p.title.lower() == pl_name.lower():
						target = p.playlist_ids
						break
				else:
					for p in self.pctl.multi_playlist:
						#logging.info(p.title.lower())
						#logging.info(pl_name.lower())
						if p.title.lower().startswith(pl_name.lower()):
							target = p.playlist_ids
							break
				if target is None:
					logging.warning(f"not found: {pl_name}")
					logging.warning("Target playlist not found")
					if cm.startswith("s\""):
						selections_searched += 1
					errors = "playlist"
					continue

				if cm.startswith("s\""):
					selections_searched += 1
					selections.append(target)
				elif cm.startswith("px\""):
					playlist[:] = [x for x in playlist if x not in target]
			else:
				errors = True

		self.gui.gen_code_errors = errors
		if not playlist and not errors:
			self.gui.gen_code_errors = "empty"

		# Eliminate duplicates
		if playlist:
			playlist = list(dict.fromkeys(playlist))
		if self.gui.rename_playlist_box and (not playlist or cmds.count("a") > 1):
			pass
		else:
			source_playlist[:] = playlist[:]

		self.tree_view_box.clear_target_pl(0, id)
		self.pctl.regen_in_progress = False
		self.gui.pl_update = 1
		self.reload()
		self.dropped_playlist = pl
		self.pctl.notify_database_changed()

		#logging.info(cmds)

	def make_auto_sorting(self, pl: int) -> None:
		self.pctl.gen_codes[self.pctl.pl_to_id(pl)] = "self a path tn ypa auto"
		self.show_message(
			_("OK. This playlist will automatically sort on import from now on"),
			_("You remove or edit this behavior by going \"Misc...\" > \"Edit generator...\""), mode="done")

	def jellyfin_show_test(self, _: int) -> bool:
		return bool(self.prefs.jelly_password and self.prefs.jelly_username)

	def upload_jellyfin_playlist(self, pl: int) -> None:
		if self.jellyfin.scanning:
			return
		shooter(self.jellyfin.upload_playlist, [pl])

	def regen_playlist_async(self, pl: int) -> None:
		if self.pctl.regen_in_progress:
			self.show_message(_("A regen is already in progress..."))
			return
		shoot_dl = threading.Thread(target=self.regenerate_playlist, args=([pl]))
		shoot_dl.daemon = True
		shoot_dl.start()

	def forget_pl_import_folder(self, pl: int) -> None:
		self.pctl.multi_playlist[pl].last_folder = []

	def remove_duplicates(self, pl: int) -> None:
		playlist = []

		for item in self.pctl.multi_playlist[pl].playlist_ids:
			if item not in playlist:
				playlist.append(item)

		removed = len(self.pctl.multi_playlist[pl].playlist_ids) - len(playlist)
		if not removed:
			self.show_message(_("No duplicates were found"))
		else:
			self.show_message(_("{N} duplicates removed").format(N=removed), mode="done")

		self.pctl.multi_playlist[pl].playlist_ids[:] = playlist[:]

	def start_quick_add(self, pl: int) -> None:
		self.pctl.quick_add_target = self.pctl.pl_to_id(pl)
		self.show_message(
			_("You can now add/remove albums to this playlist by right clicking in gallery of any playlist"),
			_("To exit this mode, click \"Disengage\" from main MENU"))

	def prep_gal(self) -> None:
		self.albums = []
		folder = ""

		for index in self.pctl.default_playlist:
			if folder != self.pctl.master_library[index].parent_folder_name:
				self.albums.append([index, 0])
				folder = self.pctl.master_library[index].parent_folder_name

	def pl_gen(self,
		title:        str = "Default",
		playing:      int = 0,
		playlist_ids: list[int] | None = None,
		position:     int = 0,
		hide_title:   bool = False,
		selected:     int = 0,
		parent:       int = 0,
		hidden:       bool = False,
		notify:       bool = True, # Allows us to generate initial playlist before worker thread is ready
		playlist_file:str = "",
		auto_export:  bool = False,
		auto_import:  bool = False,
		relative_export: bool = False,
		export_type:  str = "xspf",
		file_size:    int = 0,

	) -> TauonPlaylist:
		"""Generate a TauonPlaylist

		Creates a default playlist when called without parameters
		"""
		if playlist_ids is None:
			playlist_ids = []
		if notify:
			self.pctl.notify_database_changed()

		#return copy.deepcopy([title, playing, playlist, position, hide_title, selected, uid_gen(), [], hidden, False, parent, False])
		return TauonPlaylist(title=title, playing=playing, playlist_ids=playlist_ids, position=position, hide_title=hide_title, selected=selected, uuid_int=uid_gen(), last_folder=[], hidden=hidden, locked=False, parent_playlist_id=parent, persist_time_positioning=False, playlist_file=playlist_file, file_size=file_size, auto_export=auto_export, auto_import=auto_import, export_type=export_type, relative_export=relative_export)

	def open_uri(self, uri: str) -> None:
		logging.info("OPEN URI")
		load_order = LoadClass()

		for w in range(len(self.pctl.multi_playlist)):
			if self.pctl.multi_playlist[w].title == "Default":
				load_order.playlist = self.pctl.multi_playlist[w].uuid_int
				break
		else:
			logging.warning("'Default' playlist not found, generating a new one!")
			self.pctl.multi_playlist.append(self.pl_gen())
			load_order.playlist = self.pctl.multi_playlist[len(self.pctl.multi_playlist) - 1].uuid_int
			self.pctl.switch_playlist(len(self.pctl.multi_playlist) - 1)

		load_order.target = str(urllib.parse.unquote(uri)).replace("file:///", "/").replace("\r", "")

		if self.gui.auto_play_import is False:
			load_order.play = True
			self.gui.auto_play_import = True

		self.load_orders.append(copy.deepcopy(load_order))
		self.gui.update += 1

	def toast(self, text: str, duration: float = 1) -> None:
		self.gui.mode_toast_text = text
		self.gui.toast_length = duration
		self.toast_mode_timer.set()
		self.gui.frame_callback_list.append(TestTimer(duration + 0.2))

	def set_artist_preview(self, path: str, artist: str, x: int, y: int) -> None:
		m = min(round(500 * self.gui.scale), self.window_size[1] - (self.gui.panelY + self.gui.panelBY + 50 * self.gui.scale))
		self.artist_preview_render.load(path, box_size=(m, m))
		self.artist_preview_render.show = True
		ah = self.artist_preview_render.size[1]
		ay = round(y) - (ah // 2)
		if ay < self.gui.panelY + 20 * self.gui.scale:
			ay = self.gui.panelY + round(20 * self.gui.scale)
		if ay + ah > self.window_size[1] - (self.gui.panelBY + 5 * self.gui.scale):
			ay = self.window_size[1] - (self.gui.panelBY + ah + round(5 * self.gui.scale))
		self.gui.preview_artist = artist
		self.gui.preview_artist_location = (x + 15 * self.gui.scale, ay)

	def get_artist_preview(self, artist: str, x: int, y: int) -> None:
		# self.show_message(_("Loading artist image..."))

		self.gui.preview_artist_loading = artist
		self.artist_info_box.get_data(artist, force_dl=True)
		path = self.artist_info_box.get_data(artist, get_img_path=True)
		if not path:
			self.show_message(_("No artist image found."))
			if not self.prefs.enable_fanart_artist and not self.verify_discogs():
				self.show_message(_("No artist image found."), _("No providers are enabled in settings!"), mode="warning")
			self.gui.preview_artist_loading = ""
			return
		self.set_artist_preview(path, artist, x, y)
		self.gui.message_box = False
		self.gui.preview_artist_loading = ""

	def update_set(self) -> None:
		"""This is used to scale columns when windows is resized or items added/removed"""
		wid = self.gui.plw - round(self.gui.pl_st_left * self.gui.scale)
		if self.gui.tracklist_center_mode:
			wid = self.gui.tracklist_highlight_width - round(self.gui.pl_st_left * self.gui.scale)

		total = 0
		for item in self.gui.pl_st:
			if item[2] is False:
				total += item[1]
			else:
				wid -= item[1]

		wid = max(75, wid)

		for i in range(len(self.gui.pl_st)):
			if self.gui.pl_st[i][2] is False and total:
				self.gui.pl_st[i][1] = round((self.gui.pl_st[i][1] / total) * wid)  # + 1

	def auto_size_columns(self) -> None:
		fixed_n = 0

		wid = self.gui.plw - round(self.gui.pl_st_left * self.gui.scale)
		if self.gui.tracklist_center_mode:
			wid = self.gui.tracklist_highlight_width - round(self.gui.pl_st_left * self.gui.scale)

		total = wid
		for item in self.gui.pl_st:

			if item[2]:
				fixed_n += 1

			if item[0] == "Lyrics":
				item[1] = round(50 * self.gui.scale)
				total  -= round(50 * self.gui.scale)

			if item[0] == "Rating":
				item[1] = round(80 * self.gui.scale)
				total  -= round(80 * self.gui.scale)

			if item[0] == "Album Art":
				item[1] = round(72 * self.gui.scale)
				total  -= round(72 * self.gui.scale)

			if item[0] == "Starline":
				item[1] = round(78 * self.gui.scale)
				total  -= round(78 * self.gui.scale)

			if item[0] == "Time" or item[0] == "ID":
				item[1] = round(58 * self.gui.scale)
				total  -= round(58 * self.gui.scale)

			if item[0] == "Codec":
				item[1] = round(58 * self.gui.scale)
				total  -= round(58 * self.gui.scale)

			if item[0] == "P" or item[0] == "S" or item[0] == "#":
				item[1] = round(32 * self.gui.scale)
				total  -= round(32 * self.gui.scale)

			if item[0] == "Date":
				item[1] = round(55 * self.gui.scale)
				total  -= round(55 * self.gui.scale)

			if item[0] == "Bitrate" or item[0] == "=/=":
				item[1] = round(67 * self.gui.scale)
				total  -= round(67 * self.gui.scale)

			if item[0] == "❤":
				item[1] = round(27 * self.gui.scale)
				total  -= round(27 * self.gui.scale)

		vr = len(self.gui.pl_st) - fixed_n

		if vr > 0 and total > 50:
			space = round(total / vr)

			for item in self.gui.pl_st:
				if not item[2]:
					item[1] = space

		self.gui.pl_update += 1
		self.update_set()

	def set_colour(self, colour: ColourRGBA) -> None:
		sdl3.SDL_SetRenderDrawColor(self.renderer, colour.r, colour.g, colour.b, colour.a)

	# 2025-02-02 - commented out as it was not used
	#def advance_theme() -> None:
	#	prefs.theme += 1
	#	gui.reload_theme = True

	def reload_metadata(self, input: int | list[TrackClass], keep_star: bool = True) -> None:
		# vacuum_playtimes(index)
		# return
		self.todo = []

		if isinstance(input, list):
			self.todo = input
		else:
			for k in self.pctl.default_playlist:
				if self.pctl.master_library[input].parent_folder_path == self.pctl.master_library[k].parent_folder_path:
					self.todo.append(self.pctl.master_library[k])

		for i in reversed(range(len(self.todo))):
			if self.todo[i].is_cue:
				del self.todo[i]

		for track in self.todo:
			self.search_string_cache.pop(track.index, None)
			self.search_dia_string_cache.pop(track.index, None)
			self.search_field_cache.pop(track.index, None)
			self.search_dia_field_cache.pop(track.index, None)

			#logging.info('Reloading Metadata for ' + track.filename)
			if keep_star:
				self.to_scan.append(track.index)
			else:
				# if keep_star:
				# 	star = self.star_store.full_get(track.index)
				# 	self.star_store.remove(track.index)

				self.pctl.master_library[track.index] = self.tag_scan(track)

				# if keep_star:
				# 	if star is not None and (star.playtime > 0 or star.flags or star.rating > 0):
				# 		self.star_store.merge(track.index, star)

				self.pctl.notify_database_changed()

		self.gui.pl_update += 1
		self.thread_manager.ready("worker")

	def menu_reload_metadata(self, ref: MenuTrackRef) -> None:
		self.reload_metadata(ref.track_id)

	def edit_generator_box(self, index: int) -> None:
		self.rename_playlist(index, generator=True)

	def pin_playlist_toggle(self, pl: int) -> None:
		self.pctl.multi_playlist[pl].hidden ^= True

	def pl_pin_deco(self, pl: int) -> Decorator:
		# if pctl.multi_playlist[pl].hidden == True and self.tab_menu.pos[1] >
		if self.pctl.multi_playlist[pl].hidden is True:
			return Decorator(self.colours.menu_text, self.colours.menu_background, _("Pin"))
		return Decorator(self.colours.menu_text, self.colours.menu_background, _("Unpin"))

	def pl_lock_deco(self, pl: int) -> Decorator:
		if self.pctl.multi_playlist[pl].locked is True:
			return Decorator(self.colours.menu_text, self.colours.menu_background, _("Unlock"))
		return Decorator(self.colours.menu_text, self.colours.menu_background, _("Lock"))

	def view_pl_is_locked(self, _) -> bool:
		return self.pctl.multi_playlist[self.pctl.active_playlist_viewing].locked

	def pl_is_locked(self, pl: int) -> bool:
		if not self.pctl.multi_playlist:
			return False
		return self.pctl.multi_playlist[pl].locked

	def lock_playlist_toggle(self, pl: int) -> None:
		self.pctl.multi_playlist[pl].locked ^= True

	def lock_colour_callback(self) -> ColourRGBA | None:
		if self.pctl.multi_playlist[self.gui.tab_menu_pl].locked:
			if self.colours.lm:
				return ColourRGBA(230, 180, 60, 255)
			return ColourRGBA(240, 190, 10, 255)
		return None

	def reload_metadata_selection(self) -> None:
		self.pctl.cargo = []
		for item in self.gui.shift_selection:
			self.pctl.cargo.append(self.pctl.default_playlist[item])

		for k in self.pctl.cargo:
			if self.pctl.master_library[k].is_cue is False:
				self.to_scan.append(k)
		self.thread_manager.ready("worker")

	def editor(self, index: int | None) -> None:
		todo: list[int] = []
		obs: list[TrackClass] = []

		if self.inp.key_shift_down and index is not None:
			todo = [index]
			obs = [self.pctl.master_library[index]]
		elif index is None:
			for item in self.gui.shift_selection:
				todo.append(self.pctl.default_playlist[item])
				obs.append(self.pctl.master_library[self.pctl.default_playlist[item]])
			if len(todo) > 0:
				index = todo[0]
		else:
			for k in self.pctl.default_playlist:
				if self.pctl.master_library[index].parent_folder_path == self.pctl.master_library[k].parent_folder_path:
					if self.pctl.master_library[k].is_cue is False:
						todo.append(k)
						obs.append(self.pctl.master_library[k])

		# Keep copy of play times
		old_stars: list[TrackClass | tuple[str, str, str] | StarRecord | None] = []
		for track in todo:
			item = []
			item.append(self.pctl.get_track(track))
			item.append(self.star_store.key(track))
			item.append(self.star_store.full_get(track))
			old_stars.append(item)

		file_line = ""
		for track in todo:
			file_line += ' "'
			file_line += self.pctl.master_library[track].fullpath
			file_line += '"'

		if self.windows:
			file_line = file_line.replace("/", "\\")

		prefix = ""
		app = self.prefs.tag_editor_target

		if self.windows and app:
			if app[0] != '"':
				app = '"' + app
			if app[-1] != '"':
				app = app + '"'

		app_switch = ""

		prefix = self.launch_prefix

		ok = whicher(self.prefs.tag_editor_target, self.flatpak_mode)

		if not ok:
			self.show_message(_("Tag editor app does not appear to be installed."), mode="warning")

			if self.flatpak_mode:
				self.show_message(
					_("App not found on host OR insufficient Flatpak permissions."),
					_(" For details, see {link}").format(link="https://github.com/Taiko2k/Tauon/wiki/Flatpak-Extra-Steps"),
					mode="bubble")

			return

		if "picard" in self.prefs.tag_editor_target:
			app_switch = " --d "

		line = prefix + app + app_switch + file_line

		self.show_message(
			self.prefs.tag_editor_name + " launched.", "Fields will be updated once application is closed.", mode="arrow")
		self.gui.update = 1

		complete = subprocess.run(shlex.split(line), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)

		if "picard" in self.prefs.tag_editor_target:
			r = complete.stderr.decode()
			for line in r.split("\n"):
				if "file._rename" in line and " Moving file " in line:
					a, b = line.split(" Moving file ")[1].split(" => ")
					a = a.strip("'").strip('"')
					b = b.strip("'").strip('"')

					for track in todo:
						if self.pctl.master_library[track].fullpath == a:
							self.pctl.master_library[track].fullpath = b
							self.pctl.master_library[track].filename = os.path.basename(b)
							logging.info("External Edit: File rename detected.")
							logging.info(f"    Renaming: {a}")
							logging.info(f"          To: {b}")
							break
					else:
						logging.warning("External Edit: A file rename was detected but track was not found.")

		self.gui.message_box = False
		self.reload_metadata(obs, keep_star=False)

		# Re apply playtime data in case file names change
		for item in old_stars:
			old_key: tuple[str, str, str] = item[1]
			old_value: StarRecord | None = item[2]

			if not old_value:  # ignore if there was no old playcount metadata
				continue

			new_key = self.star_store.object_key(item[0])
			new_value = self.star_store.full_get(item[0].index)

			if old_key == new_key:
				continue

			if new_value is None:
				new_value = StarRecord()

			new_value.playtime += old_value.playtime

			if old_key in self.star_store.db:
				del self.star_store.db[old_key]

			self.star_store.db[new_key] = new_value

		self.gui.pl_update = 1
		self.gui.update = 1
		self.pctl.notify_database_changed()

	def launch_editor(self, ref: MenuTrackRef) -> bool | None:
		if self.snap_mode:
			self.show_message(_("Sorry, this feature isn't (yet) available with Snap."))
			return None

		if self.launch_editor_disable_test(ref):
			self.show_message(_("Cannot edit tags of a network track."))
			return None

		mini_t = threading.Thread(target=self.editor, args=[ref.track_id])
		mini_t.daemon = True
		mini_t.start()

	def launch_editor_selection_disable_test(self, _ref: object) -> bool:
		for position in self.gui.shift_selection:
			if self.pctl.get_track(self.pctl.default_playlist[position]).is_network:
				return True
		return False

	def launch_editor_selection(self, ref: object) -> None:
		if self.launch_editor_selection_disable_test(ref):
			self.show_message(_("Cannot edit tags of a network track."))
			return

		mini_t = threading.Thread(target=self.editor, args=[None])
		mini_t.daemon = True
		mini_t.start()

	def edit_deco(self, _ref: object) -> Decorator:
		if self.inp.key_shift_down or self.inp.key_shiftr_down:
			return Decorator(self.colours.menu_text, self.colours.menu_background, self.prefs.tag_editor_name + " (Single track)")
		return Decorator(self.colours.menu_text, self.colours.menu_background, _("Edit with ") + self.prefs.tag_editor_name)

	def launch_editor_disable_test(self, ref: MenuTrackRef) -> bool:
		return self.pctl.get_track(ref.track_id).is_network

	def show_lyrics_menu(self, ref: MenuTrackRef) -> None:
		self.gui.track_box = False
		self.enter_showcase_view(track_id=ref.track_id)
		self.inp.mouse_click = False

	def show_message(self, line1: str, line2: str ="", line3: str = "", mode: str = "info") -> None:
		self.gui.message_box = True
		self.gui.message_text = line1
		self.gui.message_mode = mode
		self.gui.message_subtext = line2
		self.gui.message_subtext2 = line3
		self.message_box_min_timer.set()
		match mode:
			case "done" | "confirm" | "arrow" | "download" | "bubble" | "link":
				logging.debug(f"Message: {line1} {line2} {line3}")
			case "info":
				logging.info(f"Message: {line1} {line2} {line3}")
			case "warning":
				logging.warning(f"Message: {line1} {line2} {line3}")
			case "error":
				logging.error(f"Message: {line1} {line2} {line3}")
			case _:
				logging.error(f"Unknown mode '{mode}' for message: {line1} {line2} {line3}")
		self.gui.update = 1

	def start_remote(self) -> None:
		if not self.web_running:
			self.web_thread = threading.Thread(
				target=webserve2, args=[self.pctl, self.album_art_gen, self])
			self.web_thread.daemon = True
			self.web_thread.start()
			self.web_running = True

	def download_ffmpeg(self, _x) -> None:
		"""Download FFmpeg portable binary to User Data dir, supports x64 Windows and Linux"""
		self.preset_download_box.start(
			title=_("Downloading FFmpeg"),
			status=_("Starting download..."),
			cancel_detail=_("FFmpeg will not be installed."),
		)

		def go() -> None:
			if self.windows:
				url = "https://github.com/GyanD/codexffmpeg/releases/download/8.0.1/ffmpeg-8.0.1-essentials_build.zip"
				sha = "e2aaeaa0fdbc397d4794828086424d4aaa2102cef1fb6874f6ffd29c0b88b673"
			elif not self.macos:
				url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-03-02-13-06/ffmpeg-n8.0.1-66-g27b8d1a017-linux64-gpl-8.0.tar.xz"
				sha = "e6f31af6c1ef49ceec8361185d70283ea8ad6c8ceed652d5dee66ec174a11eea"
			with io.BytesIO() as f:
				try:
					with requests.get(url, stream=True, timeout=1800) as r: # Windows ffmpeg is 101MB, Linux 130MB, give it half an hour in case someone is willing to suffer it on a slow connection
						r.raise_for_status()
						dl = 0
						total_bytes = int(r.headers.get("Content-Length", 0))
						if self.windows:
							fallback_mb = 101
						elif not self.macos:
							fallback_mb = 130
						total_mb = round(total_bytes / 1000 / 1000) if total_bytes else fallback_mb

						for data in r.iter_content(chunk_size=65536):
							if self.preset_download_box.cancel_requested:
								self.preset_download_box.complete(
									status=_("FFmpeg download cancelled"),
									detail=_("FFmpeg was not installed."),
								)
								return
							if not data:
								continue
							dl += len(data)
							f.write(data)
							mb = dl / 1000 / 1000
							if total_bytes:
								progress = min(0.95, dl / total_bytes * 0.95)
							else:
								progress = min(0.95, mb / total_mb * 0.95)
							detail = f"{mb:.1f} / {total_mb:.1f} MB"
							self.preset_download_box.update(progress, _("Downloading FFmpeg..."), detail)
				except Exception as e:
					logging.exception("Download failed")
					self.preset_download_box.fail(_("Download failed"), f"{type(e).__name__}: {e}")
					return

				f.seek(0)
				self.preset_download_box.update(0.96, _("Verifying download..."))
				checksum = hashlib.sha256(f.read()).hexdigest()
				if checksum != sha:
					self.preset_download_box.fail(_("Download completed but checksum failed"))
					logging.error(f"Checksum was {checksum} but expected {sha}")
					return
				if self.preset_download_box.cancel_requested:
					self.preset_download_box.complete(
						status=_("FFmpeg download cancelled"),
						detail=_("FFmpeg was not installed."),
					)
					return
				self.preset_download_box.update(0.98, _("Extracting FFmpeg..."))
				f.seek(0)
				if self.windows:
					z = zipfile.ZipFile(f, mode="r")
					with z.open("ffmpeg-8.0.1-essentials_build/bin/ffmpeg.exe") as exe, (self.user_directory / "ffmpeg.exe").open("wb") as file:
						file.write(exe.read())

					with z.open("ffmpeg-8.0.1-essentials_build/bin/ffprobe.exe") as exe, (self.user_directory / "ffprobe.exe").open("wb") as file:
						file.write(exe.read())
				elif not self.macos:
					import tarfile

					output_dir = self.user_directory
					wanted = {"ffmpeg", "ffprobe"}

					with tarfile.open(fileobj=f, mode="r:xz") as tar:
						for member in tar.getmembers():
							if self.preset_download_box.cancel_requested:
								self.preset_download_box.complete(
									status=_("FFmpeg download cancelled"),
									detail=_("FFmpeg was not installed."),
								)
								return
							filename = Path(member.name).name

							if filename in wanted and member.isfile():
								member.name = filename  # strip directory structure
								tar.extract(member, path=output_dir)


			self.preset_download_box.complete(
				status=_("FFmpeg fetch complete"),
				toast_text=_("Download completed"),
			)

		shooter(go)

	def draw_rating_widget(
		self,
		x: int,
		y: int,
		n_track: TrackClass,
		album: bool = False,
		allow_input: bool = True,
		hint_only: bool = False,
	) -> None:
		rat = self.album_star_store.get_rating(n_track) if album else self.star_store.get_rating(n_track.index)
		display_rat = 0 if hint_only else rat

		rect = (x - round(5 * self.gui.scale), y - round(4 * self.gui.scale), round(80 * self.gui.scale), round(24 * self.gui.scale))
		boundary = 3 * self.gui.scale
		tracklist_top = self.gui.playlist_top + boundary
		tracklist_bottom = self.window_size[1] - self.gui.panelBY - boundary
		input_in_bounds = tracklist_top < self.inp.mouse_position[1] <= tracklist_bottom
		if allow_input and input_in_bounds:
			self.gui.heart_fields.append(rect)

		if allow_input and input_in_bounds and self.coll(rect) and (self.inp.mouse_click or (self.is_level_zero() and not self.inp.quick_drag)):
			self.gui.pl_update = 2
			pp = self.inp.mouse_position[0] - x

			if pp < 5 * self.gui.scale:
				rat = 0
			elif pp > 70 * self.gui.scale:
				rat = 10
			else:
				rat = pp // (self.gui.star_row_icon.w // 2)

			if self.inp.mouse_click:
				rat = min(rat, 10)
				if album:
					self.album_star_store.set_rating(n_track, rat)
				else:
					self.star_store.set_rating(n_track.index, rat, write=True)
				display_rat = rat

		# bg = self.colours.grey(40)
		bg = ColourRGBA(255, 255, 255, 17)
		fg = self.colours.grey(210)

		if self.gui.tracklist_bg_is_light:
			bg = ColourRGBA(0, 0, 0, 25)
			fg = self.colours.grey(70)

		playtime_stars = 0
		if self.prefs.rating_playtime_stars and not album and (hint_only or display_rat == 0):
			playtime_stars = star_count3(self.star_store.get(n_track.index), n_track.length)
			if self.gui.tracklist_bg_is_light:
				fg2 = alpha_blend(ColourRGBA(0, 0, 0, 70), self.ddt.text_background_colour)
			else:
				fg2 = alpha_blend(ColourRGBA(255, 255, 255, 50), self.ddt.text_background_colour)

		for ss in range(5):
			xx = x + ss * self.gui.star_row_icon.w

			if playtime_stars:
				if playtime_stars - 1 < ss * 2:
					self.gui.star_row_icon.render(xx, y, bg)
				elif playtime_stars - 1 == ss * 2:
					self.gui.star_row_icon.render(xx, y, bg)
					self.gui.star_half_row_icon.render(xx, y, fg2)
				else:
					self.gui.star_row_icon.render(xx, y, fg2)
			elif display_rat - 1 < ss * 2:
				self.gui.star_row_icon.render(xx, y, bg)
			elif display_rat - 1 == ss * 2:
				self.gui.star_row_icon.render(xx, y, bg)
				self.gui.star_half_row_icon.render(xx, y, fg)
			else:
				self.gui.star_row_icon.render(xx, y, fg)

	def standard_view_deco(self) -> Decorator:
		if self.prefs.album_mode or self.gui.combo_mode or not self.gui.rsp:
			line_colour = self.colours.menu_text
		else:
			line_colour = self.colours.menu_text_disabled
		return Decorator(line_colour, self.colours.menu_background, None)

	# def gallery_only_view(self) -> None:
	# 	if self.gui.show_playlist is False:
	# 		return
	# 	if not self.prefs.album_mode:
	# 		self.toggle_album_mode()
	# 	self.gui.show_playlist = False
	# 	self.gui.update_layout = True
	# 	self.gui.rspw = window_size[0]
	# 	self.gui.album_playlist_width = self.gui.playlist_width
	# 	#self.gui.playlist_width = -19

	def toggle_library_mode(self) -> None:
		if self.gui.set_mode:
			self.gui.set_mode = False
			# self.gui.set_bar = False
		else:
			self.gui.set_mode = True
			# self.gui.set_bar = True
		self.gui.update_layout = True

	def library_deco(self) -> Decorator:
		tc = self.colours.menu_text
		if self.gui.combo_mode or (self.gui.show_playlist is False and self.prefs.album_mode):
			tc = self.colours.menu_text_disabled

		if self.gui.set_mode:
			return Decorator(tc, self.colours.menu_background, _("Disable Columns"))
		return Decorator(tc, self.colours.menu_background, _("Enable Columns"))

	def break_deco(self) -> Decorator:
		tex = self.colours.menu_text
		if self.gui.combo_mode or (self.gui.show_playlist is False and self.prefs.album_mode):
			tex = self.colours.menu_text_disabled
		if not self.prefs.break_enable:
			tex = self.colours.menu_text_disabled

		if not self.pctl.multi_playlist[self.pctl.active_playlist_viewing].hide_title:
			return Decorator(tex, self.colours.menu_background, _("Disable Title Breaks"))
		return Decorator(tex, self.colours.menu_background, _("Enable Title Breaks"))

	def toggle_playlist_break(self) -> None:
		self.pctl.multi_playlist[self.pctl.active_playlist_viewing].hide_title ^= 1
		self.gui.pl_update = 1

	def pl_toggle_playlist_break(self, ref) -> None:
		self.pctl.multi_playlist[ref].hide_title ^= 1
		self.gui.pl_update = 1

	def transcode_single(self, item: list[tuple[int, str]], manual_directory: Path | None = None, manual_name: str | None = None) -> None:
		if manual_directory is not None:
			codec = "opus"
			output = manual_directory
			track = item
			self.core_use += 1
			bitrate = 48
		else:
			track = item[0]
			codec   = self.prefs.transcode_codec
			output  = self.prefs.encoder_output / item[1]
			bitrate = self.prefs.transcode_bitrate

		t = self.pctl.master_library[track]

		path = t.fullpath
		cleanup = False

		if t.is_network:
			while self.dl_use > 1:
				time.sleep(0.2)
			self.dl_use += 1
			try:
				url, params = self.pctl.get_url(t)
				assert url
				path = os.path.join(tmp_cache_dir(), str(t.index))
				if os.path.exists(path):
					os.remove(path)
				logging.info("Downloading file...")
				with requests.get(url, params=params, timeout=60) as response, open(path, "wb") as out_file:
					out_file.write(response.content)
				logging.info("Download complete")
				cleanup = True
			except Exception:
				logging.exception("Error downloading file")
			self.dl_use -= 1

		if not os.path.isfile(path):
			self.show_message(_("Encoding warning: Missing one or more files"))
			self.core_use -= 1
			return

		out_line = encode_track_name(t)

		target_out = str(output / f"output{track}.{codec}")

		command = str(self.get_ffmpeg()) + " "

		if not t.is_cue:
			command += '-i "'
		else:
			command += "-ss " + str(t.start_time)
			command += " -t " + str(t.length)

			command += ' -i "'

		command += path.replace('"', '\\"')

		command += '" '
		if self.pctl.master_library[track].is_cue:
			if t.title:
				command += '-metadata title="' + t.title.replace('"', "").replace("'", "") + '" '
			if t.artist:
				command += '-metadata artist="' + t.artist.replace('"', "").replace("'", "") + '" '
			if t.album:
				command += '-metadata album="' + t.album.replace('"', "").replace("'", "") + '" '
			if t.track_number:
				command += '-metadata track="' + str(t.track_number).replace('"', "").replace("'", "") + '" '
			if t.date:
				command += '-metadata year="' + str(t.date).replace('"', "").replace("'", "") + '" '

		if codec != "flac":
			command += " -b:a " + str(bitrate) + "k -vn "

		command += '"' + target_out.replace('"', '\\"') + '"'

		# logging.info(shlex.split(command))
		startupinfo = None
		if self.windows:
			startupinfo = subprocess.STARTUPINFO()
			startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW

		if not self.windows:
			command = shlex.split(command)

		subprocess.call(command, stdout=subprocess.PIPE, shell=False, startupinfo=startupinfo)

		logging.info("FFmpeg finished")
		if codec == "opus" and self.prefs.transcode_opus_as:
			codec = "ogg"

		# logging.info(target_out)

		if manual_name is None:
			final_out = output / (out_line + "." + codec)
			final_name = out_line + "." + codec
			os.rename(target_out, final_out)
		else:
			final_out = output / (manual_name + "." + codec)
			final_name = manual_name + "." + codec
			os.rename(target_out, final_out)

		if self.prefs.transcode_inplace and not t.is_network and not t.is_cue:
			logging.info("MOVE AND REPLACE!")
			if os.path.isfile(final_out) and os.path.getsize(final_out) > 1000:
				new_name = os.path.join(t.parent_folder_path, final_name)
				logging.info(new_name)
				shutil.move(final_out, new_name)

				old_key  = self.star_store.key(track)
				old_star = self.star_store.full_get(track)

				try:
					send2trash(self.pctl.master_library[track].fullpath)
				except Exception:
					logging.exception("File trash error")

				if os.path.isfile(self.pctl.master_library[track].fullpath):
					try:
						os.remove(self.pctl.master_library[track].fullpath)
					except Exception:
						logging.exception("File delete error")

				self.pctl.master_library[track].fullpath = new_name
				self.pctl.master_library[track].file_ext = codec.upper()

				# Update and merge playtimes
				new_key = self.star_store.key(track)
				if old_star and (new_key != old_key):

					new_star = self.star_store.full_get(track)
					if new_star is None:
						new_star = StarRecord()

					new_star.playtime += old_star.playtime
					if old_star.rating > 0 and new_star.rating == 0:
						new_star.rating = old_star.rating

					if old_key in self.star_store.db:
						del self.star_store.db[old_key]

					self.star_store.db[new_key] = new_star

		self.gui.transcoding_batch_done += 1
		if cleanup:
			os.remove(path)
		self.core_use -= 1
		self.gui.update += 1

	def cue_scan(self, content: str, tn: TrackClass) -> int | None:
		# Get length from backend

		lasttime = tn.length

		content = content.replace("\r", "")
		content = content.split("\n")

		#logging.info(content)

		cued: list[int] = []

		LENGTH = 0
		PERFORMER = ""
		TITLE = ""
		START = 0
		DATE = ""
		ALBUM = ""
		GENRE = ""
		MAIN_PERFORMER = ""

		for LINE in content:
			if 'TITLE "' in LINE:
				ALBUM = LINE[7:len(LINE) - 2]

			if 'PERFORMER "' in LINE:
				while LINE[0] != "P":
					LINE = LINE[1:]

				MAIN_PERFORMER = LINE[11:len(LINE) - 2]

			if "REM DATE" in LINE:
				DATE = LINE[9:len(LINE) - 1]

			if "REM GENRE" in LINE:
				GENRE = LINE[10:len(LINE) - 1]

			if "TRACK " in LINE:
				break

		for LINE in reversed(content):
			if len(LINE) > 100:
				return 1
			if "INDEX 01 " in LINE:
				temp = ""
				pos = len(LINE)
				pos -= 1
				while LINE[pos] != ":":
					pos -= 1
					if pos < 8:
						break

				START = int(LINE[pos - 2:pos]) + (int(LINE[pos - 5:pos - 3]) * 60)
				LENGTH = int(lasttime) - START
				lasttime = START

			elif 'PERFORMER "' in LINE:
				switch = 0
				for i in range(len(LINE)):
					if switch == 1 and LINE[i] == '"':
						break
					if switch == 1:
						PERFORMER += LINE[i]
					if LINE[i] == '"':
						switch = 1

			elif 'TITLE "' in LINE:

				switch = 0
				for i in range(len(LINE)):
					if switch == 1 and LINE[i] == '"':
						break
					if switch == 1:
						TITLE += LINE[i]
					if LINE[i] == '"':
						switch = 1

			elif "TRACK " in LINE:

				pos = 0
				while LINE[pos] != "K":
					pos += 1
					if pos > 15:
						return 1
				TN = LINE[pos + 2:pos + 4]

				TN = int(TN)

				# try:
				#     bitrate = audio.info.bitrate
				# except Exception:
				#     logging.exception("Failed to set audio bitrate")
				#     bitrate = 0

				if PERFORMER == "":
					PERFORMER = MAIN_PERFORMER

				nt = copy.deepcopy(tn)

				nt.cue_sheet = ""
				nt.is_embed_cue = True

				nt.index = self.pctl.master_count
				# nt.fullpath = filepath.replace('\\', '/')
				# nt.filename = filename
				# nt.parent_folder_path = os.path.dirname(filepath.replace('\\', '/'))
				# nt.parent_folder_name = os.path.splitext(os.path.basename(filepath))[0]
				# nt.file_ext = os.path.splitext(os.path.basename(filepath))[1][1:].upper()
				if MAIN_PERFORMER:
					nt.album_artist = MAIN_PERFORMER
				if PERFORMER:
					nt.artist = PERFORMER
				if GENRE:
					nt.genre = GENRE
				nt.title = TITLE
				nt.length = LENGTH
				# nt.bitrate = source_track.bitrate
				if ALBUM:
					nt.album = ALBUM
				if DATE:
					nt.date = DATE.replace('"', "")
				nt.track_number = TN
				nt.start_time = START
				nt.is_cue = True
				nt.size = 0  # source_track.size
				# nt.samplerate = source_track.samplerate
				if TN == 1:
					nt.size = os.path.getsize(nt.fullpath)

				self.pctl.master_library[self.pctl.master_count] = nt

				cued.append(self.pctl.master_count)
				# loaded_paths_cache[filepath.replace('\\', '/')] = self.pctl.master_count
				# self.added.append(self.pctl.master_count)

				self.pctl.master_count += 1
				LENGTH = 0
				PERFORMER = ""
				TITLE = ""
				START = 0
				TN = 0

		self.added += reversed(cued)

		# bag.cue_list.append(filepath)
		return None

	def get_album_from_first_track(self, track_position: int, track_id: int | None = None, pl_number: int | None = None, pl_id: int | None = None) -> list[int]:
		if pl_number is None:
			pl_number = self.pctl.id_to_pl(pl_id) if pl_id else self.pctl.active_playlist_viewing

		playlist = self.pctl.multi_playlist[pl_number].playlist_ids

		if track_id is None:
			track_id = playlist[track_position]

		if playlist[track_position] != track_id:
			return []

		tracks = []
		album_parent_path = self.pctl.get_track(track_id).parent_folder_path

		i = track_position

		while i < len(playlist):
			if self.pctl.get_track(playlist[i]).parent_folder_path != album_parent_path:
				break

			tracks.append(playlist[i])
			i += 1

		return tracks

	def love_deco(self) -> Decorator:
		if self.love(False):
			return Decorator(self.colours.menu_text, self.colours.menu_background, _("Un-Love Track"))
		if self.pctl.playing_state in (PlayingState.PLAYING, PlayingState.PAUSED):
			return Decorator(self.colours.menu_text, self.colours.menu_background, _("Love Track"))
		return Decorator(self.colours.menu_text_disabled, self.colours.menu_background, _("Love Track"))

	def bar_love(self, notify: bool = False) -> None:
		shoot_love = threading.Thread(target=self.love, args=[True, None, False, notify])
		shoot_love.daemon = True
		shoot_love.start()

	def bar_love_notify(self) -> None:
		self.bar_love(notify=True)

	def select_love(self, notify: bool = False) -> None:
		selected = self.pctl.selected_in_playlist
		playlist = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].playlist_ids
		if -1 < selected < len(playlist):
			track_id = playlist[selected]

			shoot_love = threading.Thread(target=self.love, args=[True, track_id, False, notify])
			shoot_love.daemon = True
			shoot_love.start()

	def locate_artist(self) -> None:
		track = self.pctl.playing_object()
		if not track:
			return

		artist = track.artist
		if track.album_artist:
			artist = track.album_artist

		block_starts = []
		current = False
		for i in range(len(self.pctl.default_playlist)):
			track = self.pctl.get_track(self.pctl.default_playlist[i])
			if current is False:
				if artist in (track.artist, track.album_artist) or (track.artists is not None and artist in track.artists):
					block_starts.append(i)
					current = True
			elif (artist not in (track.artist, track.album_artist)) and not (
					track.artists is not None and artist in track.artists):
				current = False

		if block_starts:
			next = False
			for start in block_starts:

				if next:
					self.pctl.selected_in_playlist = start
					self.pctl.playlist_view_position = start
					self.gui.shift_selection.clear()
					break

				if self.pctl.selected_in_playlist == start:
					next = True
					continue

			else:
				self.pctl.selected_in_playlist = block_starts[0]
				self.pctl.playlist_view_position = block_starts[0]
				self.gui.shift_selection.clear()

			self.tree_view_box.show_track(self.pctl.get_track(self.pctl.default_playlist[self.pctl.selected_in_playlist]))
		else:
			self.show_message(_("No exact matching artist could be found in this playlist"))

		logging.debug("Position changed by artist locate")
		self.gui.pl_update += 1

	def goto_album(self, playlist_no: int, down: bool = False, force: bool = False) -> list | int | None:
		logging.debug("Position set by album locate")

		if self.core_timer.get() < 0.5:
			return None

		# ----
		w = self.gui.rspw
		if self.window_size[0] < 750 * self.gui.scale:
			w = self.window_size[0] - 20 * self.gui.scale
			if self.gui.lsp:
				w -= self.gui.lspw
		area_x = w + 38 * self.gui.scale
		row_len = int((area_x - self.gui.album_h_gap) / (self.album_mode_art_size + self.gui.album_h_gap))
		# Keep in step with render_gallery: the Gallery: Compact widget forces
		# the row length (set around this call by CustomLayout.gallery_locate).
		if self.gui.gallery_forced_row_len:
			row_len = self.gui.gallery_forced_row_len
		self.gui.last_row = row_len
		# ----

		px = 0
		row = 0
		re = 0

		for i in range(len(self.album_dex)):
			if i == len(self.album_dex) - 1:
				re = i
				break
			if self.album_dex[i + 1] - 1 > playlist_no - 1:
				re = i
				break
			row += 1
			if row > row_len - 1:
				row = 0
				px += self.album_mode_art_size + self.gui.album_v_gap

		# If the album is within the view port already, dont jump to it
		# (unless we really want to with force)
		if not force and self.gui.album_scroll_px + self.gui.album_v_slide_value < px < self.gui.album_scroll_px + self.window_size[1]:
			# Dont chance the view since its already in the view port
			# But if the album is just out of view on the bottom, bring it into view on to bottom row
			if self.window_size[1] > (self.album_mode_art_size + self.gui.album_v_gap) * 2:
				while not self.gui.album_scroll_px - 20 < px + (self.album_mode_art_size + self.gui.album_v_gap + 3) < self.gui.album_scroll_px + \
					self.window_size[1] - 40:
					self.gui.album_scroll_px += 1
		else:
			# Set the view to the calculated position
			self.gui.album_scroll_px = px
			self.gui.album_scroll_px -= self.gui.album_v_slide_value

			self.gui.album_scroll_px = max(self.gui.album_scroll_px, 0 - self.gui.album_v_slide_value)

		if len(self.album_dex) > 0:
			return self.album_dex[re]
		return 0

		self.gui.update += 1 # TODO(Martin): WTF Unreachable??
		return None

	def toggle_album_mode(self, force_on: bool = False) -> None:
		self.gui.gall_tab_enter = False

		if self.prefs.album_mode is True:
			self.prefs.album_mode = False
			# self.gui.album_playlist_width = self.gui.playlist_width
			# self.gui.old_album_pos = self.gui.album_scroll_px
			self.gui.rspw = self.gui.pref_rspw
			self.gui.rsp = self.prefs.prefer_side
			self.gui.album_tab_mode = False
		else:
			self.prefs.album_mode = True
			if self.gui.combo_mode:
				self.exit_combo()

			self.gui.rsp = True
			self.gui.rspw = self.gui.pref_gallery_w

		space = self.window_size[0] - self.gui.rspw
		if self.gui.lsp:
			space -= self.gui.lspw

		if self.prefs.album_mode and self.gui.set_mode and len(self.gui.pl_st) > 6 and space < 600 * self.gui.scale:
			self.gui.set_mode = False
			self.gui.pl_update = True
			self.gui.update_layout = True

		self.reload_albums(quiet=True)

		# if self.pctl.active_playlist_playing == self.pctl.active_playlist_viewing:
		# 	self.goto_album(self.pctl.playlist_playing_position)

		if self.prefs.album_mode and self.pctl.selected_in_playlist < len(self.pctl.playing_playlist()):
			self.goto_album(self.pctl.selected_in_playlist)

	def toggle_gallery_keycontrol(self, always_exit: bool = False) -> None:
		if self.is_level_zero():
			if not self.prefs.album_mode:
				self.toggle_album_mode()
				self.gui.gall_tab_enter = True
				self.gui.album_tab_mode = True
				self.show_in_gal(self.pctl.selected_in_playlist, silent=True)
			elif self.gui.gall_tab_enter or always_exit:
				# Exit gallery and tab mode
				self.toggle_album_mode()
			else:
				self.gui.album_tab_mode ^= True
				if self.gui.album_tab_mode:
					self.show_in_gal(self.pctl.selected_in_playlist, silent=True)

	def check_auto_update_okay(self, code: str, pl: int | None = None) -> bool:
		try:
			cmds = shlex.split(code)
		except Exception:
			logging.exception("Malformed generator code!")
			return False
		return "auto" in cmds or (
			self.prefs.always_auto_update_playlists and
			self.pctl.active_playlist_playing != pl and
			"sf"     not in cmds and
			"rf"     not in cmds and
			"ra"     not in cmds and
			"sa"     not in cmds and
			"st"     not in cmds and
			"rt"     not in cmds and
			"plex"   not in cmds and
			"jelly"  not in cmds and
			"tau"    not in cmds and
			"air"    not in cmds and
			"sal"    not in cmds and
			"slt"    not in cmds and
			"spl\""  not in code and
			"tpl\""  not in code and
			"tar\""  not in code and
			"tmix\"" not in code and
			"r"      not in cmds)

	def rename_playlist(self, index, generator: bool = False) -> None:
		self.gui.rename_playlist_box = True
		self.rename_playlist_box.done_callback = None
		self.rename_playlist_box.edit_generator = False
		self.rename_playlist_box.playlist_index = index
		self.rename_playlist_box.x = self.inp.mouse_position[0]
		self.rename_playlist_box.y = self.inp.mouse_position[1]

		if generator:
			self.rename_playlist_box.y = self.window_size[1] // 2 - round(200 * self.gui.scale)
			self.rename_playlist_box.x = self.window_size[0] // 2 - round(250 * self.gui.scale)

		self.rename_playlist_box.y = min(self.rename_playlist_box.y, round(350 * self.gui.scale))

		if self.rename_playlist_box.y < self.gui.panelY:
			self.rename_playlist_box.y = self.gui.panelY + 10 * self.gui.scale

		if self.gui.radio_view:
			self.rename_text_area.set_text(self.pctl.radio_playlists[index].name)
		else:
			self.rename_text_area.set_text(self.pctl.multi_playlist[index].title)
		self.rename_text_area.highlight_all()
		self.gui.gen_code_errors = False

		if generator:
			self.rename_playlist_box.toggle_edit_gen()

	def gen_power2(self) -> list[PowerTag]:
		tags = {}  # [tag name]: (first position, number of times we saw it)
		tag_list = []

		last = "a"
		noise = 0

		def key(tag):
			return tags[tag][1]

		for position in self.album_dex:
			index = self.pctl.default_playlist[position]
			track = self.pctl.get_track(index)

			crumbs = track.parent_folder_path.split("/")

			for i, b in enumerate(crumbs):
				if i > 0 and (track.artist in b and track.artist):
					tag = crumbs[i - 1]

					if tag != last:
						noise += 1
					last = tag

					if tag in tags:
						tags[tag][1] += 1
					else:
						tags[tag] = [position, 1, "/".join(crumbs[:i])]
						tag_list.append(tag)
					break

		if noise > len(self.album_dex) / 2:
			#logging.info("Playlist is too noisy for power bar.")
			return []

		tag_list_sort = sorted(tag_list, key=key, reverse=True)

		max_tags = round((self.window_size[1] - self.gui.panelY - self.gui.panelBY - 10) // 30 * self.gui.scale)

		tag_list_sort = tag_list_sort[:max_tags]

		for i in reversed(range(len(tag_list))):
			if tag_list[i] not in tag_list_sort:
				del tag_list[i]

		h: list[PowerTag] = []

		for tag in tag_list:
			if tags[tag][1] > 2:
				t = PowerTag()
				t.path = tags[tag][2]
				t.name = tag.upper()
				t.position = tags[tag][0]
				h.append(t)

		cc = random.random()
		cj = 0.03
		if len(h) < 5:
			cj = 0.11

		cj = 0.5 / max(len(h), 2)

		for item in h:
			item.colour = hsl_to_rgb(cc, 0.8, 0.7)
			cc += cj

		return h

	def reload_albums(self, quiet: bool = False, return_playlist: int = -1, custom_list: list[int] | None = None) -> list[int]:
		if self.cm_clean_db:
			# Doing reload while things are being removed may cause crash
			return []

		dex = []
		current_folder = ""
		current_album = ""
		current_artist = ""
		current_date = ""
		current_title = ""

		if custom_list is not None:
			playlist = custom_list
		else:
			target_pl_no = self.pctl.active_playlist_viewing
			if return_playlist > -1:
				target_pl_no = return_playlist

			playlist = self.pctl.multi_playlist[target_pl_no].playlist_ids

		for i in range(len(playlist)):
			tr = self.pctl.master_library[playlist[i]]

			split = False
			if i == 0:
				split = True
			elif tr.parent_folder_path != current_folder and tr.date and tr.date != current_date:
				split = True
			elif self.prefs.gallery_combine_disc and "Disc" in tr.album and "Disc" in current_album and tr.album.split("Disc")[0].rstrip(" ") == current_album.split("Disc")[0].rstrip(" "):
				split = False
			elif self.prefs.gallery_combine_disc and "CD" in tr.album and "CD" in current_album and tr.album.split("CD")[0].rstrip() == current_album.split("CD")[0].rstrip():
				split = False
			elif self.prefs.gallery_combine_disc and "cd" in tr.album and "cd" in current_album and tr.album.split("cd")[0].rstrip() == current_album.split("cd")[0].rstrip():
				split = False
			elif tr.album and tr.album == current_album and self.prefs.gallery_combine_disc:
				split = False
			elif tr.parent_folder_path != current_folder or current_title != tr.parent_folder_name:
				split = True

			if split:
				dex.append(i)
				current_folder = tr.parent_folder_path
				current_title = tr.parent_folder_name
				current_album = tr.album
				current_date = tr.date
				current_artist = tr.artist

		if return_playlist > -1 or custom_list is not None:
			return dex

		# In-place so aliases stay live (pctl.album_dex is this same list —
		# purge_track clears it through that name to invalidate the index)
		self.album_dex[:] = dex
		self.album_dex_pl_uuid = self.pctl.multi_playlist[target_pl_no].uuid_int
		self.album_info_cache.clear()
		self.gui.update += 2
		self.gui.pl_update = 1
		self.gui.update_layout = True

		if not quiet:
			self.goto_album(self.pctl.playlist_playing_position)

		# Generate POWER BAR
		self.gui.power_bar = self.gen_power2()
		self.gui.pt = 0
		return []

	def reload_backend(self) -> None:
		self.gui.backend_reloading = True
		logging.info("Reload backend...")
		wait = 0
		pre_state = self.pctl.stop(True)

		while self.pctl.playerCommandReady:
			time.sleep(0.01)
			wait += 1
			if wait > 20:
				break
		if self.thread_manager.player_lock.locked():
			try:
				self.thread_manager.player_lock.release()
			except RuntimeError as e:
				if str(e) == "release unlocked lock":
					logging.error("RuntimeError: Attempted to release already unlocked player_lock")  # noqa: TRY400
				else:
					logging.exception("Unknown RuntimeError trying to release player_lock")
			except Exception:
				logging.exception("Unknown error trying to release player_lock")

		self.pctl.playerCommand = "unload"
		self.pctl.playerCommandReady = True

		wait = 0
		while self.pctl.playerCommand != "done":
			time.sleep(0.01)
			wait += 1
			if wait > 200:
				break

		self.thread_manager.ready_playback()

		if pre_state == 1:
			self.pctl.revert()
		self.gui.backend_reloading = False

	def gen_chart(self) -> None:
		try:
			topchart = t_topchart.TopChart(self)

			tracks = []

			source_tracks = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].playlist_ids

			if self.prefs.topchart_sorts_played:
				source_tracks = self.gen_folder_top(0, custom_list=source_tracks)
				dex = self.reload_albums(quiet=True, custom_list=source_tracks)
			else:
				dex = self.reload_albums(quiet=True, return_playlist=self.pctl.active_playlist_viewing)

			for item in dex:
				tracks.append(self.pctl.get_track(source_tracks[item]))

			cascade = False
			if self.prefs.chart_cascade:
				cascade = (
					(self.prefs.chart_c1, self.prefs.chart_c2, self.prefs.chart_c3),
					(self.prefs.chart_d1, self.prefs.chart_d2, self.prefs.chart_d3))

			path = topchart.generate(
				tracks, self.prefs.chart_bg, self.prefs.chart_rows, self.prefs.chart_columns, self.prefs.chart_text,
				self.prefs.chart_font, self.prefs.chart_tile, cascade)

		except Exception:
			logging.exception("There was an error generating the chart")
			self.gui.generating_chart = False
			self.show_message(_("There was an error generating the chart"), _("Sorry!"), mode="error")
			return

		self.gui.generating_chart = False

		if path:
			self.open_file(path)
		else:
			self.show_message(_("There was an error generating the chart"), _("Sorry!"), mode="error")
			return

		self.show_message(_("Chart generated"), mode="done")

	def notify_song_fire(self, notification, delay: float, id) -> None:
		time.sleep(delay)
		notification.show()
		if id is None:
			return

		time.sleep(8)
		if id == self.gui.notify_main_id:
			notification.close()

	#def get_backend_time(self, path):
	#	self.pctl.time_to_get = path

	#	self.pctl.playerCommand = "time"
	#	self.pctl.playerCommandReady = True

	#	while self.pctl.playerCommand != "done":
	#		time.sleep(0.005)

	#	return self.pctl.time_to_get

	def get_love(self, track_object: TrackClass) -> bool:
		star = self.star_store.full_get(track_object.index)
		if star is None:
			return False

		return star.loved

	def get_love_index(self, index: int) -> bool:
		star = self.star_store.full_get(index)
		if star is None:
			return False

		return star.loved

	def get_love_timestamp_index(self, index: int) -> float:
		star = self.star_store.full_get(index)
		if star is None:
			return 0
		return star.loved_timestamp

	def maloja_get_scrobble_counts(self) -> None:
		if self.lastfm.scanning_scrobbles is True or not self.prefs.maloja_url:
			return

		url = self.prefs.maloja_url
		if not url.endswith("/"):
			url += "/"
		url += "apis/mlj_1/scrobbles"
		self.lastfm.scanning_scrobbles = True
		try:
			r = requests.get(url, timeout=10)

			if r.status_code != 200:
				self.show_message(_("There was an error with the Maloja server"), r.text, mode="warning")
				self.lastfm.scanning_scrobbles = False
				return
		except Exception:
			logging.exception("There was an error reaching the Maloja server")
			self.show_message(_("There was an error reaching the Maloja server"), mode="warning")
			self.lastfm.scanning_scrobbles = False
			return

		try:
			data = json.loads(r.text)
			l = data["list"]

			counts: dict[tuple[str, tuple[str, ...]], int] = {}

			for item in l:
				artists = item.get("artists")
				title = item.get("title")
				if title and artists:
					key = (title, tuple(artists))
					c = counts.get(key, 0)
					counts[key] = c + 1

			touched: list[int] = []

			for key, value in counts.items():
				title, artists = key
				artists = [x.lower() for x in artists]
				title = title.lower()
				for track in self.pctl.master_library.values():
					if track.artist.lower() in artists and track.title.lower() == title:
						if track.index in touched:
							track.lfm_scrobbles += value
						else:
							track.lfm_scrobbles = value
							touched.append(track.index)
			self.show_message(_("Scanning scrobbles complete"), mode="done")

		except Exception:
			logging.exception("There was an error parsing the data")
			self.show_message(_("There was an error parsing the data"), mode="warning")

		self.gui.pl_update += 1
		self.lastfm.scanning_scrobbles = False
		self.bg_save()

	def maloja_scrobble(self, track: TrackClass, timestamp: int = int(time.time())) -> bool | None:
		url = self.prefs.maloja_url

		if not track.artist or not track.title:
			return None

		if not url.endswith("/newscrobble"):
			if not url.endswith("/"):
				url += "/"
			url += "apis/mlj_1/newscrobble"

		d = {}
		d["artists"] = [track.artist] # let Maloja parse/fix artists
		d["title"] = track.title

		if track.album:
			d["album"] = track.album
		if track.album_artist:
			d["albumartists"] = [track.album_artist] # let Maloja parse/fix artists

		d["length"] = int(track.length)
		d["time"] = timestamp
		d["key"] = self.prefs.maloja_key

		try:
			r = requests.post(url, json=d, timeout=10)
			if r.status_code != 200:
				self.show_message(_("There was an error submitting data to Maloja server"), r.text, mode="warning")
				return False
		except Exception:
			logging.exception("There was an error submitting data to Maloja server")
			self.show_message(_("There was an error submitting data to Maloja server"), mode="warning")
			return False
		return True

	def get_network_thumbnail_url(self, track_object: TrackClass) -> str | None:
		if track_object.file_ext == "TIDAL":
			return track_object.art_url_key
		if track_object.file_ext == "PLEX":
			url = self.plex.resolve_thumbnail(track_object.art_url_key)
			assert url is not None
			return url
		#if track_object.file_ext == "JELY":
		#	url = jellyfin.resolve_thumbnail(track_object.art_url_key)
		#	assert url is not None
		#	assert url
		#	return url
		if track_object.file_ext == "TAU":
			url = self.tau.resolve_picture(track_object.art_url_key)
			assert url
			return url
		return None

	def jellyfin_get_playlists_thread(self) -> None:
		if self.jellyfin.scanning:
			self.inp.mouse_click = False
			self.show_message(_("Job already in progress!"))
			return
		self.jellyfin.scanning = True
		shoot_dl = threading.Thread(target=self.jellyfin.get_playlists)
		shoot_dl.daemon = True
		shoot_dl.start()

	def jellyfin_get_library_thread(self) -> None:
		self.pref_box.close()
		save_prefs(bag=self.bag)
		if self.jellyfin.scanning:
			self.inp.mouse_click = False
			self.show_message(_("Job already in progress!"))
			return

		self.jellyfin.scanning = True
		shoot_dl = threading.Thread(target=self.jellyfin.ingest_library)
		shoot_dl.daemon = True
		shoot_dl.start()

	def plex_cancel_two_factor(self) -> None:
		self.plex.two_factor_required = False
		self.text_plex_2fa.text = ""
		self.plex.connected = False
		self.plex.resource = None
		self.plex.scanning = False
		self.gui.update += 1

	def plex_get_album_thread(self) -> None:
		if self.plex.scanning:
			self.inp.mouse_click = False
			self.show_message(_("Already scanning!"))
			return

		# If the user has 2FA enabled, Plex may require a verification code.
		# In that case we keep the settings window open and prompt for the code.
		if self.plex.two_factor_required:
			code = self.text_plex_2fa.text.strip()
			if not code:
				self.show_message(_("Enter two-factor code"), mode="warning")
				return
			if not self.plex.connect(code=code):
				return
			self.text_plex_2fa.text = ""
		elif not self.plex.connect():
			return

		self.pref_box.close()
		save_prefs(bag=self.bag)
		self.plex.scanning = True

		shoot_dl = threading.Thread(target=self.plex.get_albums)
		shoot_dl.daemon = True
		shoot_dl.start()

	def sub_get_album_thread(self) -> None:
		self.pref_box.close()
		save_prefs(bag=self.bag)
		if self.subsonic.scanning:
			self.inp.mouse_click = False
			self.show_message(_("Already scanning!"))
			return
		self.subsonic.scanning = True

		shoot_dl = threading.Thread(target=self.subsonic.get_music3)
		shoot_dl.daemon = True
		shoot_dl.start()

	def track_number_process(self, line: str) -> str:
		line = str(line).split("/", 1)[0].lstrip("0")
		if self.prefs.dd_index and len(line) == 1:
			return "0" + line
		return line

	def tag_scan(self, nt: TrackClass) -> TrackClass | None:
		"""This function takes a track object and scans metadata for it. (Filepath needs to be set)"""
		if nt.is_embed_cue:
			return nt
		if nt.is_network or not nt.fullpath:
			return None
		EasyID3.RegisterTextKey("lyrics","USLT")
		try:
			try:
				nt.modified_time = os.path.getmtime(nt.fullpath)
				nt.found = True
			except FileNotFoundError:
				logging.error("File not found when executing getmtime!")  # noqa: TRY400
				nt.found = False
				return nt
			except Exception:
				logging.exception("Unknown error executing getmtime!")
				nt.found = False
				return nt

			for _mk in _MISC_TO_FIELD.values():
				setattr(nt, _mk, None)
			nt.file_ext = os.path.splitext(os.path.basename(nt.fullpath))[1][1:].upper()

			if nt.file_ext.lower() in self.formats.GME and self.gme:
				emu = ctypes.c_void_p()
				track_info = ctypes.POINTER(GMETrackInfo)()
				err = self.gme.gme_open_file(nt.fullpath.encode("utf-8"), ctypes.byref(emu), -1)
				#logging.error(err)
				if not err:
					n = nt.subtrack
					err = self.gme.gme_track_info(emu, byref(track_info), n)
					#logging.error(err)
					if not err:
						nt.length = track_info.contents.play_length / 1000
						nt.title = track_info.contents.song.decode("utf-8")
						nt.artist = track_info.contents.author.decode("utf-8")
						nt.album = track_info.contents.game.decode("utf-8")
						nt.comment = track_info.contents.comment.decode("utf-8")
						self.gme.gme_free_info(track_info)
					self.gme.gme_delete(emu)

					filepath = nt.fullpath  # this is the full file path
					filename = nt.filename  # this is the name of the file

					# Get the directory of the file
					dir_path = os.path.dirname(filepath)

					# Loop through all files in the directory to find any matching M3U
					for file in os.listdir(dir_path):
						if file.endswith(".m3u"):
							with open(os.path.join(dir_path, file), encoding="utf-8", errors="replace") as f:
								content = f.read()
								if "�" in content:  # Check for replacement marker
									with open(os.path.join(dir_path, file), encoding="windows-1252") as b:
										content = b.read()
								if "::" in content:
									a, b = content.split("::")
									if a == filename:
										s = re.split(r"(?<!\\),", b)
										try:
											st = int(s[1])
										except Exception:
											logging.exception("Failed to assign st to int")
											continue
										if st == n:
											nt.title = s[2].split(" - ")[0].replace("\\", "")
											nt.artist = s[2].split(" - ")[1].replace("\\", "")
											nt.album = s[2].split(" - ")[2].replace("\\", "")
											nt.length = hms_to_seconds(s[3])
											break
				if not nt.title:
					nt.title = "Track " + str(nt.subtrack + 1)
			elif nt.file_ext in ("MOD", "IT", "XM", "S3M", "MPTM") and self.mpt:
				with Path(nt.fullpath).open("rb") as file:
					data = file.read()
				MOD1 = MOD.from_address(
					self.mpt.openmpt_module_create_from_memory(
						ctypes.c_char_p(data), ctypes.c_size_t(len(data)), None, None, None))
				nt.length  = self.mpt.openmpt_module_get_duration_seconds(byref(MOD1))
				nt.title   = self.mpt.openmpt_module_get_metadata(byref(MOD1), ctypes.c_char_p(b"title")).decode()
				nt.artist  = self.mpt.openmpt_module_get_metadata(byref(MOD1), ctypes.c_char_p(b"artist")).decode()
				nt.comment = self.mpt.openmpt_module_get_metadata(byref(MOD1), ctypes.c_char_p(b"message_raw")).decode()

				self.mpt.openmpt_module_destroy(byref(MOD1))
				del MOD1
			elif nt.file_ext == "FLAC":
				with Flac(nt.fullpath) as audio:
					audio.read()

					nt.length = audio.length
					nt.title = audio.title
					nt.artist = audio.artist
					nt.album = audio.album
					nt.composer = audio.composer
					nt.date = audio.date
					nt.samplerate = audio.sample_rate
					nt.bit_depth = audio.bit_depth
					nt.size = os.path.getsize(nt.fullpath)
					nt.track_number = audio.track_number
					nt.genre = audio.genre
					nt.album_artist = audio.album_artist
					nt.disc_number = audio.disc_number
					nt.lyrics = audio.lyrics
					nt.synced = audio.synced_lyrics
					if nt.length:
						nt.bitrate = int(nt.size / nt.length * 8 / 1024)
					nt.track_total = audio.track_total
					nt.disc_total = audio.disc_total
					nt.comment = audio.comment
					nt.cue_sheet = audio.cue_sheet
					copy_trackfile_metadata(nt, audio)
			elif nt.file_ext == "WAV":
				with Wav(nt.fullpath) as audio:
					try:
						audio.read()

						nt.samplerate = audio.sample_rate
						nt.length = audio.length
						nt.title = audio.title
						nt.artist = audio.artist
						nt.album = audio.album
						nt.track_number = audio.track_number

					except Exception:
						logging.exception("Failed saving WAV file as a Track, will try again differently")
						audio = mutagen.File(nt.fullpath)
						nt.samplerate = audio.info.sample_rate
						nt.bitrate = audio.info.bitrate // 1000
						nt.length = audio.info.length
						nt.size = os.path.getsize(nt.fullpath)
					audio = mutagen.File(nt.fullpath)
					if audio.tags and type(audio.tags) == mutagen.wave._WaveID3:
						use_id3(audio.tags, nt)
			elif nt.file_ext in ("OPUS", "OGG", "OGA"):
				#logging.info("get opus")
				with Opus(nt.fullpath) as audio:
					audio.read()

					#logging.info(audio.title)

					nt.length = audio.length
					nt.title = audio.title
					nt.artist = audio.artist
					nt.album = audio.album
					nt.composer = audio.composer
					nt.date = audio.date
					nt.samplerate = audio.sample_rate
					nt.size = os.path.getsize(nt.fullpath)
					nt.track_number = audio.track_number
					nt.genre = audio.genre
					nt.album_artist = audio.album_artist
					nt.bitrate = audio.bit_rate
					nt.lyrics = audio.lyrics
					nt.synced = audio.synced_lyrics
					nt.disc_number = audio.disc_number
					nt.track_total = audio.track_total
					nt.disc_total = audio.disc_total
					nt.comment = audio.comment
					copy_trackfile_metadata(nt, audio)
					if nt.bitrate == 0 and nt.length > 0:
						nt.bitrate = int(nt.size / nt.length * 8 / 1024)
			elif nt.file_ext == "APE":
				audio = mutagen.File(nt.fullpath)
				nt.length = audio.info.length
				nt.bit_depth = audio.info.bits_per_sample
				nt.samplerate = audio.info.sample_rate
				nt.size = os.path.getsize(nt.fullpath)
				if nt.length > 0:
					nt.bitrate = int(nt.size / nt.length * 8 / 1024)

				# # def getter(audio, key, type):
				# #	 if
				# t = audio.tags
				# logging.info(t.keys())
				# nt.size = os.path.getsize(nt.fullpath)
				# nt.title = str(t.get("title", ""))
				# nt.album = str(t.get("album", ""))
				# nt.date = str(t.get("year", ""))
				# nt.disc_number = str(t.get("discnumber", ""))
				# nt.comment = str(t.get("comment", ""))
				# nt.artist = str(t.get("artist", ""))
				# nt.composer = str(t.get("composer", ""))
				# nt.composer = str(t.get("composer", ""))

				with Ape(nt.fullpath) as audio:
					audio.read()

					# logging.info(audio.title)

					# nt.length = audio.length
					nt.title = audio.title
					nt.artist = audio.artist
					nt.album = audio.album
					nt.date = audio.date
					nt.composer = audio.composer
					# nt.bit_depth = audio.bit_depth
					nt.track_number = audio.track_number
					nt.genre = audio.genre
					nt.album_artist = audio.album_artist
					nt.disc_number = audio.disc_number
					nt.lyrics = audio.lyrics
					nt.track_total = audio.track_total
					nt.disc_total = audio.disc_total
					nt.comment = audio.comment
					copy_trackfile_metadata(nt, audio)
			elif nt.file_ext in ("WV", "TTA"):
				with Ape(nt.fullpath) as audio:
					audio.read()

					# logging.info(audio.title)

					nt.length = audio.length
					nt.title = audio.title
					nt.artist = audio.artist
					nt.album = audio.album
					nt.date = audio.date
					nt.composer = audio.composer
					nt.samplerate = audio.sample_rate
					nt.bit_depth = audio.bit_depth
					nt.size = os.path.getsize(nt.fullpath)
					nt.track_number = audio.track_number
					nt.genre = audio.genre
					nt.album_artist = audio.album_artist
					nt.disc_number = audio.disc_number
					nt.lyrics = audio.lyrics
					if nt.length > 0:
						nt.bitrate = int(nt.size / nt.length * 8 / 1024)
					nt.track_total = audio.track_total
					nt.disc_total = audio.disc_total
					nt.comment = audio.comment
					copy_trackfile_metadata(nt, audio)
			else:
				# Use MUTAGEN
				try:
					if nt.file_ext.lower() in self.formats.VID:
						self.scan_ffprobe(nt)
						return nt

					try:
						audio = mutagen.File(nt.fullpath)
					except Exception:
						logging.exception("Mutagen scan failed, falling back to FFPROBE")
						self.scan_ffprobe(nt)
						return nt

					nt.samplerate = audio.info.sample_rate
					nt.bitrate = audio.info.bitrate // 1000
					nt.length = audio.info.length
					nt.size = os.path.getsize(nt.fullpath)

					if not nt.length:
						try:
							startupinfo = None
							if self.windows:
								startupinfo = subprocess.STARTUPINFO()
								startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
							result = subprocess.run([self.get_ffprobe(), "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", nt.fullpath], stdout=subprocess.PIPE, startupinfo=startupinfo, check=True)
							nt.length = float(result.stdout.decode())
						except Exception:
							logging.exception("FFPROBE couldn't supply a duration")

					if type(audio.tags) is mutagen.mp4.MP4Tags:
						tags = audio.tags

						def in_get(key, tags):
							if key in tags:
								return tags[key][0]
							return ""

						nt.title = in_get("\xa9nam", tags)
						nt.album = in_get("\xa9alb", tags)
						nt.artist = in_get("\xa9ART", tags)
						nt.album_artist = in_get("aART", tags)
						nt.composer = in_get("\xa9wrt", tags)
						nt.date = in_get("\xa9day", tags)
						nt.comment = in_get("\xa9cmt", tags)
						nt.genre = in_get("\xa9gen", tags)
						if "\xa9lyr" in tags:
							nt.lyrics = in_get("\xa9lyr", tags)
						nt.track_total = ""
						nt.track_number = ""
						t = in_get("trkn", tags)
						if t:
							nt.track_number = str(t[0])
							if t[1]:
								nt.track_total = str(t[1])

						nt.disc_total = ""
						nt.disc_number = ""
						t = in_get("disk", tags)
						if t:
							nt.disc_number = str(t[0])
							if t[1]:
								nt.disc_total = str(t[1])

						if "----:com.apple.iTunes:replaygain_track_gain" in tags:
							nt.replaygain_track_gain = float(in_get(
								"----:com.apple.iTunes:replaygain_track_gain",
								tags).decode().lower().strip(" db"))
						if "----:com.apple.iTunes:replaygain_track_peak" in tags:
							nt.replaygain_track_peak = float(in_get(
								"----:com.apple.iTunes:replaygain_track_peak",
								tags).decode())
						if "----:com.apple.iTunes:replaygain_album_gain" in tags:
							nt.replaygain_album_gain = float(in_get(
								"----:com.apple.iTunes:replaygain_album_gain",
								tags).decode().lower().strip(" db"))
						if "----:com.apple.iTunes:replaygain_album_peak" in tags:
							nt.replaygain_album_peak = float(in_get(
								"----:com.apple.iTunes:replaygain_album_peak",
								tags).decode())

						if "----:com.apple.iTunes:MusicBrainz Track Id" in tags:
							nt.musicbrainz_recordingid = in_get(
								"----:com.apple.iTunes:MusicBrainz Track Id",
								tags).decode()
						if "----:com.apple.iTunes:MusicBrainz Release Track Id" in tags:
							nt.musicbrainz_trackid = in_get(
								"----:com.apple.iTunes:MusicBrainz Release Track Id",
								tags).decode()
						if "----:com.apple.iTunes:MusicBrainz Album Id" in tags:
							nt.musicbrainz_albumid = in_get(
								"----:com.apple.iTunes:MusicBrainz Album Id",
								tags).decode()
						if "----:com.apple.iTunes:MusicBrainz Release Group Id" in tags:
							nt.musicbrainz_releasegroupid = in_get(
								"----:com.apple.iTunes:MusicBrainz Release Group Id",
								tags).decode()
						if "----:com.apple.iTunes:MusicBrainz Artist Id" in tags:
							nt.musicbrainz_artistids = [x.decode() for x in
								tags.get("----:com.apple.iTunes:MusicBrainz Artist Id")]


					elif type(audio.tags) == mutagen.id3.ID3:
						use_id3(audio.tags, nt)


				except Exception:
					logging.exception("Failed loading file through Mutagen")
					raise


			# Parse any multiple artists into list
			artists = nt.artist.split(";")
			if len(artists) > 1:
				for a in artists:
					a = a.strip()
					if a:
						if nt.artists is None:
							nt.artists = []
						if a not in nt.artists:
							nt.artists.append(a)
			find_synced_lyric_data(nt, reload=True) # populates track.synced if it succeeds
		except Exception:
			try:
				if Exception is UnicodeDecodeError:
					logging.exception(f"Unicode decode error on file: {nt.fullpath}")
				else:
					logging.exception(f"Error: Tag read failed on file: {nt.fullpath}")
			except Exception:
				logging.exception(f"Error printing error. Non utf8 not allowed: {nt.fullpath.encode('utf-8', 'surrogateescape').decode('utf-8', 'replace')}")
			return nt
		intern_track_strings(nt)
		if self.pctl.track_queue and nt.index == self.pctl.track_queue[self.pctl.queue_step]:
			self.lyrics_ren_mini.to_reload = True
		return nt

	def notify_song(self, notify_of_end: bool = False, delay: float = 0.0) -> None:
		if not self.de_notify_support:
			return

		if notify_of_end and self.prefs.end_setting != "stop":
			return

		if self.prefs.show_notifications and self.pctl.playing_object() is not None and not window_is_focused(self.t_window):
			if self.prefs.stop_notifications_mini_mode and self.gui.mode == GuiMode.MINI:
				return

			track = self.pctl.playing_object()

			if not track or not (track.title or track.artist or track.album or track.filename):
				return  # only display if we have at least one piece of metadata available

			#i_path = ""
			#try:
			#	if not notify_of_end:
			#		i_path = self.thumb_tracks.path(track)
			#except Exception:
			#	logging.exception(track.fullpath.encode("utf-8", "replace").decode("utf-8"))
			#	logging.error("Thumbnail error")

			top_line = track.title

			if self.prefs.notify_include_album:
				bottom_line = (track.artist + " | " + track.album).strip("| ")
			else:
				bottom_line = track.artist

			if not track.title:
				a, t = filename_to_metadata(clean_string(track.filename))
				if not track.artist:
					bottom_line = a
				top_line = t

			self.gui.notify_main_id = uid_gen()
			id = self.gui.notify_main_id

			if notify_of_end:
				bottom_line = "Tauon Music Box"
				top_line = (_("End of playlist"))
				id = None

			self.song_notification.update(top_line, bottom_line) #, i_path)
			self.notify_image = self.thumb_tracks.pixbuf(track)
			if self.notify_image:
				self.song_notification.set_image_from_pixbuf(self.notify_image)
			else:
				self.song_notification.update(top_line, bottom_line, None)

			shoot_dl = threading.Thread(target=self.notify_song_fire, args=([self.song_notification, delay, id]))
			shoot_dl.daemon = True
			shoot_dl.start()

	def test_auto_lyrics(self, track_object: TrackClass) -> None:
		if not track_object:
			return

		if self.prefs.auto_lyrics and not track_object.lyrics and track_object.index not in self.prefs.auto_lyrics_checked:
			if self.lyrics_check_timer.get() > 5 and self.pctl.playing_time > 1:
				result = self.get_lyric_wiki_silent(track_object)
				if result == "later":
					pass
				else:
					self.lyrics_check_timer.set()
					self.prefs.auto_lyrics_checked.append(track_object.index)

	def hit_discord(self) -> None:
		if not (self.prefs.discord_enable and self.prefs.discord_allow):
			return

		if not hasattr(self, "_discord_loop_guard_lock"):
			self._discord_loop_guard_lock = threading.Lock()
			self._discord_loop_guard_running = False

		with self._discord_loop_guard_lock:
			if self._discord_loop_guard_running:
				return
			self._discord_loop_guard_running = True
			self.prefs.discord_active = True

		try:
			discord_t = threading.Thread(target=self.discord_loop)
			discord_t.daemon = True
			discord_t.start()
		except Exception:
			with self._discord_loop_guard_lock:
				self._discord_loop_guard_running = False
				self.prefs.discord_active = False
			raise

	def _signal_discord(self) -> None:
		"""Ensure Discord RPC thread is running and wake it immediately.

		This starts the RPC thread if needed and sets the wakeup event so
		the background loop processes updates instantly.
		"""
		try:
			if not (self.prefs.discord_enable and self.prefs.discord_allow):
				return

			# Create and set the wakeup event immediately so the RPC thread can
			# observe it without the UI thread polling and sleeping.
			ev = self.__dict__.setdefault("_discord_wakeup_event", threading.Event())
			ev.set()

			try:
				self.hit_discord()
			except Exception:
				# hit_discord already logs on failure; nothing else to do here
				pass
		except Exception:
			logging.exception("Failed to signal Discord wakeup")

	def love(self, set: bool = True, track_id: int | None = None, no_delay: bool = False, notify: bool = False, sync: bool = True) -> bool | None:
		if track_id is None and len(self.pctl.track_queue) < 1:
			return False

		if track_id is not None and track_id < 0:
			return False

		if track_id is None:
			track_id = self.pctl.track_queue[self.pctl.queue_step]

		loved = False
		star = self.star_store.full_get(track_id)

		if star is not None and star.loved:
			loved = True

		if set is False:
			return loved

		# if len(lfm_username) > 0 and not lastfm.connected and not prefs.auto_lfm:
		#	 self.show_message(
		# 	"You have a last.fm account ready but it is not enabled.", 'info',
		# 	'Either connect, enable auto connect, or remove the account.')
		#	 return

		if star is None:
			star = StarRecord()

		loved ^= True

		if notify:
			self.gui.toast_love_object = self.pctl.get_track(track_id)
			self.gui.toast_love_added = loved
			self.toast_love_timer.set()
			self.gui.delay_frame(1.81)

		delay = 0.3
		if no_delay or not sync or not self.lastfm.details_ready():
			delay = 0

		star.loved_timestamp = time.time()

		if loved:
			time.sleep(delay)
			self.gui.update += 1
			self.gui.pl_update += 1
			star.loved = True
			self.star_store.insert(track_id, star)
			if sync:
				if self.prefs.last_fm_token:
					try:
						self.lastfm.love(self.pctl.master_library[track_id].artist, self.pctl.master_library[track_id].title)
					except Exception:
						logging.exception("Failed updating last.fm love status")
						self.show_message(_("Failed updating last.fm love status"), mode="warning")
						star.loved = False
						self.star_store.insert(track_id, star)
						self.show_message(
							_("Error updating love to last.fm!"),
							_("Maybe check your internet connection and try again?"), mode="error")

				if self.pctl.master_library[track_id].file_ext == "JELY":
					self.jellyfin.favorite(self.pctl.master_library[track_id])
				if self.pctl.master_library[track_id].file_ext == "SUB":
					self.subsonic.star_track(self.pctl.master_library[track_id])
		else:
			time.sleep(delay)
			self.gui.update += 1
			self.gui.pl_update += 1
			star.loved = False
			self.star_store.insert(track_id, star)
			if sync:
				if self.prefs.last_fm_token:
					try:
						self.lastfm.unlove(self.pctl.master_library[track_id].artist, self.pctl.master_library[track_id].title)
					except Exception:
						logging.exception("Failed updating last.fm love status")
						self.show_message(_("Failed updating last.fm love status"), mode="warning")
						star.loved = True
						self.star_store.insert(track_id, star)
				if self.pctl.master_library[track_id].file_ext == "JELY":
					self.jellyfin.favorite(self.pctl.master_library[track_id], un=True)
				if self.pctl.master_library[track_id].file_ext == "SUB":
					self.subsonic.unstar_track(self.pctl.master_library[track_id])

		self.gui.pl_update = 2
		self.gui.update += 1
		if sync and self.pctl.mpris is not None:
			self.pctl.mpris.update(force=True)
		return None

	def line_render(self, n_track: TrackClass, p_track: TrackClass, y: int, this_line_playing, album_fade: int, start_x: int, width: int, style: int = 1, ry=None) -> None:
		timec   = self.colours.bar_time
		titlec  = self.colours.title_text
		indexc  = self.colours.index_text
		artistc = self.colours.artist_text
		albumc  = self.colours.album_text

		if this_line_playing is True:
			timec   = self.colours.time_text
			titlec  = self.colours.title_playing
			indexc  = self.colours.index_playing
			artistc = self.colours.artist_playing
			albumc  = self.colours.album_playing

		if n_track.found is False:
			timec   = self.colours.playlist_text_missing
			titlec  = self.colours.playlist_text_missing
			indexc  = self.colours.playlist_text_missing
			artistc = self.colours.playlist_text_missing
			albumc  = self.colours.playlist_text_missing

		artistoffset = 0
		indexLine = ""

		offset_font_extra = 0
		if self.gui.row_font_size > 14:
			offset_font_extra = 8

		# In windows (arial?) draws numbers too high (hack fix)
		num_y_offset = 0
		# if system == 'Windows':
		#    num_y_offset = 1

		if True or style == 1:
			# if not gui.rsp and not gui.combo_mode:
			#     width -= 10 * gui.scale

			dash = False
			if n_track.artist and self.colours.artist_text == self.colours.title_text:
				dash = True

			if n_track.title:
				line = self.track_number_process(n_track.track_number)
				indexLine = line

				if self.prefs.use_absolute_track_index and self.pctl.multi_playlist[self.pctl.active_playlist_viewing].hide_title:
					indexLine = str(p_track)
					if len(indexLine) > 3:
						indexLine += "  "

				line = ""

				if n_track.artist and not dash:
					line0 = n_track.artist

					artistoffset = self.ddt.text(
						(start_x + 27 * self.gui.scale, y),
						line0,
						alpha_mod(artistc, album_fade),
						self.gui.row_font_size,
						int(width / 2))

					line = n_track.title
				else:
					line += n_track.title
			else:
				line = \
					os.path.splitext(n_track.filename)[
						0]

			if p_track >= len(self.pctl.default_playlist):
				self.gui.pl_update += 1
				return

			index = self.pctl.default_playlist[p_track]
			star_x = 0
			total = self.star_store.get(index)

			if self.gui.star_mode == "line" and total > 0 and self.pctl.master_library[index].length > 0:
				ratio = total / self.pctl.master_library[index].length
				if ratio > 0.55:
					star_x = int(ratio * 4 * self.gui.scale)
					star_x = min(star_x, 60 * self.gui.scale)
					sp = y - 0 - self.gui.playlist_text_offset + int(self.gui.playlist_row_height / 2)
					if self.gui.playlist_row_height > 17 * self.gui.scale:
						sp -= 1

					lh = 1
					if self.gui.scale != 1:
						lh = 2

					colour = self.colours.star_line
					if this_line_playing and self.colours.star_line_playing is not None:
						colour = self.colours.star_line_playing

					self.ddt.rect(
						[
							width + start_x - star_x - 45 * self.gui.scale - offset_font_extra,
							sp,
							star_x + 3 * self.gui.scale,
							lh],
						alpha_mod(colour, album_fade))

					star_x += 6 * self.gui.scale

			show_rating_stars = self.gui.show_ratings or self.prefs.rating_playtime_stars
			if show_rating_stars:
				sx = round(width + start_x - round(40 * self.gui.scale) - offset_font_extra)
				sy = round(ry + (self.gui.playlist_row_height // 2) - round(7 * self.gui.scale))
				sx -= round(68 * self.gui.scale)

				self.draw_rating_widget(
					sx,
					sy,
					n_track,
					allow_input=self.gui.show_ratings,
					hint_only=not self.gui.show_ratings,
				)

				star_x += round(70 * self.gui.scale)

			if self.gui.show_hearts:
				xxx = star_x

				count = 0
				spacing = 6 * self.gui.scale

				yy = ry + (self.gui.playlist_row_height // 2) - (5 * self.gui.scale)
				if self.gui.scale == 1.25:
					yy += 1
				if xxx > 0:
					xxx += 3 * self.gui.scale

				if self.love(False, index):
					count = 1
					x = width + start_x - 52 * self.gui.scale - offset_font_extra - xxx
					self.f_store.store(self.display_you_heart, (x, yy))
					star_x += 18 * self.gui.scale

				for name in self.pctl.master_library[index].lfm_friend_likes:
					# Limit to number of hears to display
					if self.gui.star_mode == "none":
						if count > 6:
							break
					elif count > 4:
						break

					x = width + start_x - 52 * self.gui.scale - offset_font_extra - (self.gui.heart_row_icon.w + spacing) * count - xxx
					self.f_store.store(self.display_friend_heart, (x, yy, name))
					count += 1
					star_x += self.gui.heart_row_icon.w + spacing + 2

			# Draw track number/index
			display_queue = False

			if self.pctl.force_queue:
				marks = []
				album_type = False
				for i, item in enumerate(self.pctl.force_queue):
					if item.track_id == n_track.index and item.position == p_track and item.playlist_id == self.pctl.pl_to_id(
							self.pctl.active_playlist_viewing):
						if item.type == QueueType.TRACK:  # Only show mark if track type
							marks.append(i)
						# else:
						# 	album_type = True
						# 	marks.append(i)

				if marks:
					display_queue = True

			if display_queue:
				li = str(marks[0] + 1)
				if li == "1":
					li = "N"
					# if item.track_id == n_track.index and item.position == p_track and item.playlist_id == pctl.active_playlist_viewing
					if self.pctl.playing_ready() and n_track.index == self.pctl.track_queue[self.pctl.queue_step] \
					and p_track == self.pctl.playlist_playing_position:
						li = "R"
					# if album_type:
					# 	li = "A"

				# rect = (start_x + 3 * self.gui.scale, y - 1 * self.gui.scale, 5 * self.gui.scale, 5 * self.gui.scale)
				# self.ddt.rect_r(rect, [100, 200, 100, 255], True)
				if len(marks) > 1:
					li += " " + ("." * (len(marks) - 1))
					li = li[:5]

				# if album_type:
				# 	li += "🠗"

				colour = ColourRGBA(244, 200, 66, 255)
				if self.colours.lm:
					colour = ColourRGBA(220, 40, 40, 255)

				self.ddt.text(
					(start_x + 5 * self.gui.scale, y, 2),
					li, colour, self.gui.row_font_size + 200 - 1)
			elif len(indexLine) > 2:
				self.ddt.text(
					(start_x + 5 * self.gui.scale, y, 2), indexLine,
					alpha_mod(indexc, album_fade), self.gui.row_font_size)
			else:
				self.ddt.text(
					(start_x, y), indexLine,
					alpha_mod(indexc, album_fade), self.gui.row_font_size)

			if dash and n_track.artist and n_track.title:
				line = n_track.artist + " - " + n_track.title

			self.ddt.text(
				(start_x + 33 * self.gui.scale + artistoffset, y),
				line,
				alpha_mod(titlec, album_fade),
				self.gui.row_font_size,
				width - 71 * self.gui.scale - artistoffset - star_x - 20 * self.gui.scale)

			line = get_display_time(n_track.length)

			self.ddt.text(
				(width + start_x - (round(36 * self.gui.scale) + offset_font_extra),
				y + num_y_offset, 0), line,
				alpha_mod(timec, album_fade), self.gui.row_font_size)

			self.f_store.recall_all()

	def clear_img_cache(self, delete_disk: bool = True) -> None:
		self.album_art_gen.clear_cache()
		self.prefs.failed_artists.clear()
		self.prefs.failed_background_artists.clear()
		self.gall_ren.key_list = []

		i = 0
		while len(self.gall_ren.queue) > 0:
			time.sleep(0.01)
			i += 1
			if i > 5 / 0.01:
				break

		for key, value in self.gall_ren.gall.items():
			sdl3.SDL_DestroyTexture(value[2])
		self.gall_ren.gall = {}

		if delete_disk:
			dirs = [self.g_cache_directory, self.n_cache_directory, self.e_cache_directory]
			for direc in dirs:
				if os.path.isdir(direc):
					for item in os.listdir(direc):
						path = os.path.join(direc, item)
						os.remove(path)

		self.prefs.failed_artists.clear()
		for key, value in self.artist_list_box.thumb_cache.items():
			if value:
				sdl3.SDL_DestroyTexture(value[0])
		self.artist_list_box.thumb_cache.clear()
		self.gui.update += 1

	def clear_track_image_cache(self, track: TrackClass) -> None:
		self.gui.halt_image_rendering = True
		if self.gall_ren.queue:
			time.sleep(0.05)
		if self.gall_ren.queue:
			time.sleep(0.2)
		if self.gall_ren.queue:
			time.sleep(0.5)

		direc = os.path.join(self.g_cache_directory)
		if os.path.isdir(direc):
			for item in os.listdir(direc):
				n = item.split("-")
				if len(n) > 2 and n[2] == str(track.index):
					os.remove(os.path.join(direc, item))
					logging.info(f"Cleared cache thumbnail: {os.path.join(direc, item)}")

		keys = set()
		for key, value in self.gall_ren.gall.items():
			if key[0] == track:
				sdl3.SDL_DestroyTexture(value[2])
				if key not in keys:
					keys.add(key)
		for key in keys:
			del self.gall_ren.gall[key]
			if key in self.gall_ren.key_list:
				self.gall_ren.key_list.remove(key)

		self.gui.halt_image_rendering = False
		self.album_art_gen.clear_cache()

	def signal_handler(self, signum, frame) -> None:
		signal.signal(signum, signal.SIG_IGN) # ignore additional signals
		self.exit(reason="SIGINT received")

	def save_state(self) -> None:
		gui   = self.gui
		pctl  = self.pctl
		prefs = self.prefs
		view_prefs = prefs.view_prefs

		if self.bag.should_save_state:
			logging.info("Writing database to disk... ")
		else:
			logging.warning("Dev mode, not saving state... ")
			return

		view_prefs["update-title"] = prefs.update_title
		view_prefs["side-panel"] = prefs.prefer_side
		view_prefs["dim-art"] = prefs.dim_art
		# view_prefs['pl-follow'] = pl_follow
		view_prefs["scroll-enable"] = prefs.scroll_enable
		view_prefs["smooth-scroll-enable"] = prefs.smooth_scroll_enable
		view_prefs["smooth-scroll-speed"] = prefs.smooth_scroll_speed
		view_prefs["break-enable"] = prefs.break_enable
		view_prefs["append-date"] = prefs.append_date

		tauonplaylist_jar = []
		radioplaylist_jar = []
		tauonqueueitem_jar = []
		trackclass_jar = []
		for v in pctl.multi_playlist:
			tauonplaylist_jar.append(v.__dict__)
		for v in pctl.radio_playlists:
			radioplaylist_jar.append(v.__dict__)
		for v in pctl.force_queue:
			tauonqueueitem_jar.append(v.__dict__)
		for v in pctl.master_library.values():
			trackclass_jar.append({k: getattr(v, k) for k in v.__slots__})

		# Columns header-bar config is per custom-slot (stored in
		# custom_layouts.json). Persist the PRESET columns to state.p — while a
		# custom slot is active gui holds the slot's columns, so use the backup.
		_custom = getattr(self, "custom", None)
		if _custom is not None:
			_pcols = _custom.preset_columns_snapshot()
			if gui.custom_mode:
				# Flush any live column edits to the slot's json too.
				_custom.save_slots()
		else:
			_pcols = {"pl_st": gui.pl_st, "set_bar": gui.set_bar,
				"set_mode": gui.set_mode, "pl_st_left": gui.pl_st_left}

		save = [
			None,
			pctl.master_count,
			pctl.playlist_playing_position,
			pctl.active_playlist_viewing,
			pctl.playlist_view_position,
			tauonplaylist_jar, # pctl.multi_playlist, # list[TauonPlaylist]
			pctl.player_volume,
			pctl.track_queue,
			pctl.queue_step,
			pctl.default_playlist,  # not read from here (keep to avoid db version bump)
			None,  # pctl.playlist_playing_position,
			None,  # Was cue list
			"",  # radio_field.text,
			prefs.theme,
			self.folder_image_offsets,
			None,  # lfm_username,
			None,  # lfm_hash,
			self.latest_db_version,  # Used for upgrading
			view_prefs,
			gui.save_size,
			None,  # old side panel size
			0,  # save time (unused)
			gui.vis_want,  # gui.vis
			pctl.selected_in_playlist,
			self.album_mode_art_size,
			self.draw_border,
			prefs.enable_web,
			prefs.allow_remote,
			prefs.expose_web,
			None,  # prefs.enable_transcode, legacy slot kept for state.p compatibility
			prefs.show_rym,
			None,  # was combo mode art size
			gui.maximized,
			prefs.prefer_bottom_title,
			gui.display_time_mode,
			prefs.transcode_mode,
			prefs.transcode_codec,
			prefs.transcode_bitrate,
			1,  # prefs.line_style,
			prefs.cache_gallery,
			prefs.playlist_font_size,
			prefs.use_title,
			_pcols["pl_st"],
			None,  # gui.set_mode,
			None,
			prefs.playlist_row_height,
			prefs.show_wiki,
			prefs.auto_extract,
			prefs.colour_from_image,
			_pcols["set_bar"],
			gui.gallery_show_text,
			gui.bb_show_art,
			False,  # Was show stars
			prefs.auto_lfm,
			prefs.scrobble_mark,
			prefs.replay_gain,
			True,  # Was radio lyrics
			prefs.show_gimage,
			prefs.end_setting,
			prefs.show_gen,
			[],  # was old radio urls
			prefs.auto_del_zip,
			gui.level_meter_colour_mode,
			prefs.ui_scale,
			prefs.show_lyrics_side,
			None, #prefs.last_device,
			self.prefs.album_mode,
			None,  # gui.album_playlist_width
			prefs.transcode_opus_as,
			gui.star_mode if gui.star_mode == "line" else "none",
			prefs.prefer_side,  # gui.rsp,
			gui.lsp,
			gui.rspw,
			gui.pref_gallery_w,
			gui.pref_rspw,
			gui.show_hearts,
			prefs.monitor_downloads,  # 76
			gui.artist_info_panel,  # 77
			prefs.extract_to_music,  # 78
			self.lb.enable,
			None,  # lb.key,
			self.rename_files.text,
			self.rename_folder.text,
			prefs.use_jump_crossfade,
			prefs.use_transition_crossfade,
			prefs.show_notifications,
			prefs.true_shuffle,
			_pcols["set_mode"],
			None,  # prefs.show_queue, # 88
			None,  # prefs.show_transfer,
			tauonqueueitem_jar, # pctl.force_queue, # 90
			prefs.use_pause_fade,  # 91
			prefs.append_total_time,  # 92
			None,  # prefs.backend,
			pctl.album_shuffle_mode,
			pctl.album_repeat_mode,  # 95
			prefs.finish_current,  # Not used
			prefs.reload_state,  # 97
			None,  # prefs.reload_play_state,
			prefs.last_fm_token,
			prefs.last_fm_username,
			prefs.use_card_style,
			prefs.auto_lyrics,
			prefs.auto_lyrics_checked,
			prefs.show_side_art,
			prefs.window_opacity,
			prefs.gallery_single_click,
			prefs.tabs_on_top,
			prefs.showcase_vis,
			prefs.spec2_colour_mode,
			prefs.device_buffer,  # moved to config file
			prefs.use_eq,
			prefs.eq,
			prefs.bio_large,
			prefs.discord_show,
			prefs.min_to_tray,
			prefs.guitar_chords,
			None,  # prefs.playback_follow_cursor,
			prefs.art_bg,
			pctl.random_mode,
			pctl.repeat_mode,
			prefs.art_bg_stronger,
			prefs.art_bg_always_blur,
			prefs.failed_artists,
			prefs.artist_list,
			None,  # prefs.auto_sort,
			prefs.lyrics_enables,
			prefs.fanart_notify,
			None,  # legacy removed background mode slot,
			None,  # prefs.discogs_pat,
			prefs.mini_mode_mode,
			self.after_scan,
			gui.gallery_positions,
			prefs.chart_bg,
			prefs.left_panel_mode,
			gui.last_left_panel_mode,
			None, #prefs.gst_device,
			self.search_string_cache,
			self.search_dia_string_cache,
			pctl.gen_codes,
			gui.show_ratings,
			gui.show_album_ratings,
			prefs.radio_urls,
			gui.showcase_mode,  # gui.combo_mode,
			self.top_panel.prime_tab,
			self.top_panel.prime_side,
			prefs.sync_playlist,
			"",
			"",
			prefs.show_band,
			prefs.download_playlist,
			[],
			prefs.auto_rec,
			"",
			prefs.use_libre_fm,
			self.playlist_box.scroll_on,
			prefs.artist_list_sort_mode,
			prefs.phazor_device_selected,
			prefs.failed_background_artists,
			prefs.bg_flips,
			prefs.tray_show_title,
			prefs.artist_list_style,
			trackclass_jar,
			prefs.premium,
			gui.radio_view,
			radioplaylist_jar, # pctl.radio_playlists,
			pctl.radio_playlist_viewing,
			prefs.radio_thumb_bans,
			prefs.playlist_exports,
			prefs.show_chromecast,
			prefs.cache_list,
			prefs.shuffle_lock,
			prefs.album_shuffle_lock_mode,
			gui.was_radio,
			"",
			"",
			prefs.artist_list_threshold,
			prefs.tray_theme,
			prefs.row_title_format,
			prefs.row_title_genre,
			prefs.row_title_separator_type,  # No longer used
			prefs.replay_preamp,  # 181
			prefs.gallery_combine_disc,
			pctl.active_playlist_playing,  # 183
			prefs.milk,
			prefs.auto_milk,
			str(prefs.loaded_preset) if prefs.loaded_preset else None,  # 186 — as str, a pickled Path is OS-specific
			int(pctl.stop_mode),
			pctl.stop_ref,
			prefs.start_in_tray,  # 189
			gui.custom_mode,  # 190
			prefs.spectrogram_colour,  # 191
			_pcols["pl_st_left"],  # 192
			prefs.milk_cut_out,  # 193
			prefs.milk_favorite_presets,  # 194
		]

		try:
			with atomic_save(self.user_directory / "state.p.backup") as file:
				pickle.dump(save, file, protocol=pickle.HIGHEST_PROTOCOL)
			# if not pctl.running:
			with atomic_save(self.user_directory / "state.p") as file:
				pickle.dump(save, file, protocol=pickle.HIGHEST_PROTOCOL)

			old_position = self.old_window_position
			if not prefs.save_window_position:
				old_position = None

			save = [
				self.draw_border,
				gui.save_size,
				prefs.window_opacity,
				gui.scale,
				gui.maximized,
				old_position,
			]

			if not self.fs_mode:
				with atomic_save(self.user_directory / "window.p") as file:
					pickle.dump(save, file, protocol=pickle.HIGHEST_PROTOCOL)

			with atomic_save(self.user_directory / "lyrics_substitutions.json", "w") as file:
				json.dump(prefs.lyrics_subs, file)

			save_prefs(bag=self.bag)

			# Save playlists to export
			for pl, playlist in enumerate(pctl.multi_playlist):
				id = pctl.pl_to_id(pl)
				if id is None:
					continue
				if playlist.auto_export is False:
					continue

				# if the playlist should auto import, but it hasn't since the file was last changed
				if playlist.auto_import:
					target = pctl.resolve_full_playlist_path(playlist)
					path = Path(target)
					if path.exists():
						filesize = path.stat().st_size
						if filesize and filesize != playlist.file_size:
							logging.warning("Playlist has changed on disk - Skipping overwrite")
							logging.warning(f"-- {path}")
							continue

				self.export_playlist_box.run_export(id, warnings=False)

			logging.info("Done writing database")

		except PermissionError:
			logging.exception("Permission error encountered while writing database")
			self.show_message(_("Permission error encountered while writing database"), "error")
		except Exception:
			logging.exception("Unknown error encountered while writing database")

	def draw_linked_text(self, location: tuple[int, int], text: str, colour: list[int], font: int, force: bool = False, replace: str = "") -> tuple[int, int, str]:
		base = ""
		link_text = ""
		rest = ""
		on_base = True

		if force:
			on_base = False
			base = ""
			link_text = text
			rest = ""
		else:
			for i in range(len(text)):
				if text[i:i + 7] == "http://" or text[i:i + 4] == "www." or text[i:i + 8] == "https://":
					on_base = False
				if on_base:
					base += text[i]
				elif i == len(text) or text[i] in '\\) "\'':
					rest = text[i:]
					break
				else:
					link_text += text[i]

		target_link = link_text
		if replace:
			link_text = replace

		left = self.ddt.get_text_w(base, font)
		right = self.ddt.get_text_w(base + link_text, font)

		x = location[0]
		y = location[1]

		self.ddt.text((x, y), base, colour, font)
		self.ddt.text((x + left, y), link_text, self.colours.link_text, font)
		self.ddt.text((x + right, y), rest, colour, font)

		tweak = font
		while tweak > 100:
			tweak -= 100

		if self.gui.scale == 2:
			tweak *= 2
			tweak += 4
		elif self.gui.scale != 1:
			tweak = round(tweak * self.gui.scale)
			tweak += 2

		# self.ddt.line(x + left, y + tweak + 2, x + right, y + tweak + 2, alpha_mod(self.colours.link_text, 120))
		self.ddt.rect((x + left, y + tweak + 2, right - left, round(1 * self.gui.scale)), alpha_mod(self.colours.link_text, 120))

		return left, right - left, target_link

	def draw_linked_text2(self, x: int, y: int, text: str, colour: list[int], font: int, click: bool = False, replace: str = "") -> None:
		link_pa = self.draw_linked_text(
			(x, y), text, colour, font, replace=replace)
		link_rect = [x + link_pa[0], y, link_pa[1], 18 * self.gui.scale]
		if self.coll(link_rect):
			if not click:
				self.gui.cursor_want = 3
			if click:
				webbrowser.open(link_pa[2], new=2, autoraise=True)
		self.fields.add(link_rect)

	def link_activate(self, x: int, y: int, link_pa: str, click: bool | None = None) -> None:
		link_rect = [x + link_pa[0], y - 2 * self.gui.scale, link_pa[1], 20 * self.gui.scale]

		if click is None:
			click = self.inp.mouse_click

		self.fields.add(link_rect)
		if self.coll(link_rect):
			if not click:
				self.gui.cursor_want = 3
			if click:
				webbrowser.open(link_pa[2], new=2, autoraise=True)

	def trunc_line(self, line: str, font: str, px: int, dots: bool = True) -> str:
		"""This old function is slow and should be avoided"""
		if self.ddt.get_text_w(line, font) < px + 10:
			return line

		if dots:
			while self.ddt.get_text_w(line.rstrip(" ") + self.gui.trunk_end, font) > px:
				if len(line) == 0:
					return self.gui.trunk_end
				line = line[:-1]
			return line.rstrip(" ") + self.gui.trunk_end

		while self.ddt.get_text_w(line, font) > px:
			line = line[:-1]
			if len(line) < 2:
				break

		return line

	def right_trunc(self, line: str, font: str, px: int, dots: bool = True) -> str:
		if self.ddt.get_text_w(line, font) < px + 10:
			return line

		if dots:
			while self.ddt.get_text_w(line.rstrip(" ") + self.gui.trunk_end, font) > px:
				if len(line) == 0:
					return self.gui.trunk_end
				line = line[1:]
			return self.gui.trunk_end + line.rstrip(" ")

		while self.ddt.get_text_w(line, font) > px:
			# trunk = True
			line = line[1:]
			if len(line) < 2:
				break
		# if trunk and dots:
		#	 line = line.rstrip(" ") + self.gui.trunk_end
		return line

	# def trunc_line2(self, line, font, px):
	#	 trunk = False
	#	 p = self.ddt.get_text_w(line, font)
	#	 if p == 0 or p < px + 15:
	#		 return line
	#
	#	 tl = line[0:(int(px / p * len(line)) + 3)]
	#
	#	 if self.ddt.get_text_w(line.rstrip(" ") + self.gui.trunk_end, font) > px:
	#		 line = tl
	#
	#	 while self.ddt.get_text_w(line.rstrip(" ") + self.gui.trunk_end, font) > px + 10:
	#		 trunk = True
	#		 line = line[:-1]
	#		 if len(line) < 1:
	#			 break
	#
	#	 return line.rstrip(" ") + self.gui.trunk_end

	def sort_track_2(self, pl: int, custom_list: list[int] | None = None) -> None:
		current_folder = ""
		current_album = ""
		current_date = ""
		albums: list[int] = []
		playlist = self.pctl.multi_playlist[pl].playlist_ids if custom_list is None else custom_list

		for i in range(len(playlist)):
			tr = self.pctl.master_library[playlist[i]]
			track_date = (tr.rdat if tr.rdat is not None else tr.date)
			if i == 0:
				albums.append(i)
				current_folder = tr.parent_folder_path
				current_album = tr.album
				current_date = track_date
				current_artist = tr.album_artist
			elif tr.parent_folder_path != current_folder:
				if tr.album == current_album and tr.album and track_date == current_date and tr.disc_number \
						and tr.album_artist == current_artist \
						and os.path.dirname(tr.parent_folder_path) == os.path.dirname(current_folder):
					continue
				current_folder = tr.parent_folder_path
				current_album = tr.album
				current_date = track_date
				current_artist = tr.album_artist
				albums.append(i)

		i = 0
		while i < len(albums) - 1:
			playlist[albums[i]:albums[i + 1]] = sorted(playlist[albums[i]:albums[i + 1]], key=self.pctl.index_key)
			i += 1
		if len(albums) > 0:
			playlist[albums[i]:] = sorted(playlist[albums[i]:], key=self.pctl.index_key)

		self.gui.pl_update += 1

	def key_filepath(self, index: int) -> tuple[str, str]:
		track = self.pctl.master_library[index]
		return track.parent_folder_path.lower(), track.filename

	def key_fullpath(self, index: int) -> str:
		return self.pctl.master_library[index].fullpath.lower()

	#def key_filename(index: int):
	#	track = self.pctl.master_library[index]
	#	return track.filename

	def sort_path_pl(self, pl: int, custom_list: list[int] | None = None) -> None:
		target = self.pctl.multi_playlist[pl].playlist_ids if custom_list is None else custom_list

		if self.use_natsort and False:
			target[:] = natsort.os_sorted(target, key=self.key_fullpath)
		else:
			target.sort(key=self.key_filepath)

	def toggle_gimage(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.show_gimage
		self.prefs.show_gimage ^= True
		return None

	def toggle_chromecast(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.show_chromecast
		self.prefs.show_chromecast ^= True
		return None

	def toggle_transfer(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.show_transfer
		self.prefs.show_transfer ^= True

		if self.prefs.show_transfer:
			self.show_message(
				_("Warning! Using this function moves physical folders."),
				_("This menu entry appears after selecting 'copy'. See manual (github wiki) for more info."),
				mode="info")
		return None

	def toggle_rym(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.show_rym
		self.prefs.show_rym ^= True
		return None

	def show_rym_menu_item(self, _reference: object) -> bool:
		return self.prefs.show_rym

	def toggle_band(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.show_band
		self.prefs.show_band ^= True
		return None

	def show_band_menu_item(self, _reference: object) -> bool:
		return self.prefs.show_band

	def toggle_wiki(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.show_wiki
		self.prefs.show_wiki ^= True
		return None

	def show_wiki_menu_item(self, _reference: object) -> bool:
		return self.prefs.show_wiki

	# def toggle_show_discord(self, mode: int = 0) -> bool:
	# 	if mode == 1:
	# 	return self.prefs.discord_show
	# 	if self.prefs.discord_show is False and self.prefs.discord_allow is False:
	# 	self.show_message(_("Warning: pypresence package not installed"))
	# 	self.prefs.discord_show ^= True

	def toggle_gen(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.show_gen
		self.prefs.show_gen ^= True
		return None

	def show_gen_menu_item(self, _reference: object) -> bool:
		return self.prefs.show_gen

	def toggle_dim_albums(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.dim_art

		self.prefs.dim_art ^= True
		self.gui.pl_update = 1
		self.gui.update += 1
		return None

	def toggle_gallery_combine(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.gallery_combine_disc

		self.prefs.gallery_combine_disc ^= True
		self.reload_albums()
		return None

	def toggle_gallery_click(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.gallery_single_click

		self.prefs.gallery_single_click ^= True
		return None

	def toggle_gallery_thin(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.thin_gallery_borders

		self.prefs.thin_gallery_borders ^= True
		self.gui.update += 1
		self.update_layout_do()
		return None

	def toggle_gallery_row_space(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.increase_gallery_row_spacing

		self.prefs.increase_gallery_row_spacing ^= True
		self.gui.update += 1
		self.update_layout_do()
		return None

	def toggle_galler_text(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.gui.gallery_show_text

		self.gui.gallery_show_text ^= True
		self.gui.update += 1
		self.update_layout_do()

		# Jump to playing album
		if self.prefs.album_mode and self.gui.first_in_grid is not None:
			if self.gui.first_in_grid < len(self.pctl.default_playlist):
				self.goto_album(self.gui.first_in_grid, force=True)
		return None

	def toggle_card_style(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.use_card_style

		self.prefs.use_card_style ^= True
		self.gui.update += 1
		return None

	def toggle_side_panel(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.prefer_side

		self.prefs.prefer_side ^= True
		self.gui.update_layout = True

		if self.prefs.album_mode or self.prefs.prefer_side is True:
			self.gui.rsp = True
		else:
			self.gui.rsp = False

		if self.prefs.prefer_side:
			self.gui.rspw = self.gui.pref_rspw
		return None

	def toggle_auto_theme(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.colour_from_image

		self.prefs.colour_from_image ^= True
		self.gui.theme_temp_current = -1
		self.gui.reload_theme = True

		# if self.prefs.colour_from_image and self.prefs.art_bg and not self.inp.key_shift_down:
		# 	toggle_auto_bg()
		return None

	def toggle_transparent_accent(self, mode: int= 0) -> bool | None:
		if mode == 1:
			return self.prefs.transparent_mode == 1

		if self.prefs.transparent_mode == 1:
			self.prefs.transparent_mode = 0
		else:
			self.prefs.transparent_mode = 1

		self.gui.reload_theme = True
		self.gui.update += 1
		self.gui.pl_update += 1
		return None

	def toggle_auto_bg(self, mode: int= 0) -> bool | None:
		if mode == 1:
			return self.prefs.art_bg
		self.prefs.art_bg ^= True

		if self.prefs.art_bg:
			self.gui.update = 60

		self.style_overlay.flush()
		self.thread_manager.ready("style")
		# if self.prefs.colour_from_image and self.prefs.art_bg and not self.inp.key_shift_down:
		# 	toggle_auto_theme()
		return None

	def toggle_auto_bg_strong(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.art_bg_stronger == 2

		if self.prefs.art_bg_stronger == 2:
			self.prefs.art_bg_stronger = 1
		else:
			self.prefs.art_bg_stronger = 2
		self.gui.update_layout = True
		return None

	def toggle_auto_bg_strong1(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.art_bg_stronger == 1
		self.prefs.art_bg_stronger = 1
		self.gui.update_layout = True
		return None

	def toggle_auto_bg_strong2(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.art_bg_stronger == 2
		self.prefs.art_bg_stronger = 2
		self.gui.update_layout = True
		if self.prefs.art_bg:
			self.gui.update = 60
		return None

	def toggle_auto_bg_strong3(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.art_bg_stronger == 3
		self.prefs.art_bg_stronger = 3
		self.gui.update_layout = True
		if self.prefs.art_bg:
			self.gui.update = 60
		return None

	def toggle_auto_bg_blur(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.art_bg_always_blur
		self.prefs.art_bg_always_blur ^= True
		self.style_overlay.flush()
		self.thread_manager.ready("style")
		return None

	def toggle_notifications(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.show_notifications

		self.prefs.show_notifications ^= True

		if self.prefs.show_notifications and not self.de_notify_support:
			self.show_message(_("Notifications for this DE not supported"), "", mode="warning")
		return None

	# def toggle_al_pref_album_artist(self, mode: int = 0) -> bool:
	# 	if mode == 1:
	# 		return self.prefs.artist_list_prefer_album_artist
	# 	self.prefs.artist_list_prefer_album_artist ^= True
	# 	self.artist_list_box.saves.clear()
	# 	return None

	def toggle_mini_lyrics(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.show_lyrics_side
		self.prefs.show_lyrics_side ^= True
		return None

	def toggle_showcase_vis(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.showcase_vis

		self.prefs.showcase_vis ^= True
		self.gui.update_layout = True
		return None

	def toggle_level_meter(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.gui.vis_want != 0

		if self.gui.vis_want == 0:
			self.gui.vis_want = 1
		else:
			self.gui.vis_want = 0

		self.gui.update_layout = True
		return None

	# def toggle_force_subpixel(self, mode: int = 0) -> bool | None:
	# 	if mode == 1:
	# 		return self.prefs.force_subpixel_text != 0
	#
	# 	self.prefs.force_subpixel_text ^= True
	# 	self.ddt.force_subpixel_text = self.prefs.force_subpixel_text
	# 	self.ddt.clear_text_cache()

	# def toggle_queue(self, mode: int = 0) -> bool:
	#	 if mode == 1:
	#		 return self.prefs.show_queue
	#	 self.prefs.show_queue ^= True
	#	 self.prefs.show_queue ^= True

	def star_line_toggle(self, mode: int= 0) -> bool | None:
		if mode == 1:
			return self.gui.star_mode == "line"

		if self.gui.star_mode == "line":
			self.gui.star_mode = "none"
		else:
			self.gui.star_mode = "line"
			self.gui.show_ratings = False
			self.prefs.rating_playtime_stars = False

		self.gui.update += 1
		self.gui.pl_update = 1
		return None

	def star_toggle(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.rating_playtime_stars

		self.prefs.rating_playtime_stars ^= True
		if self.prefs.rating_playtime_stars or self.gui.star_mode == "star":
			self.gui.star_mode = "none"

		# self.gui.show_ratings = False
		self.gui.update += 1
		self.gui.pl_update = 1
		return None

	def heart_toggle(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.gui.show_hearts

		self.gui.show_hearts ^= True
		# self.gui.show_ratings = False

		self.gui.update += 1
		self.gui.pl_update = 1
		return None

	def album_rating_toggle(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.gui.show_album_ratings

		self.gui.show_album_ratings ^= True
		self.gui.update += 1
		self.gui.pl_update = 1
		return None

	def rating_toggle(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.gui.show_ratings

		self.gui.show_ratings ^= True

		if self.gui.show_ratings:
			# gui.show_hearts = False
			self.gui.star_mode = "none"
			self.prefs.rating_playtime_stars = True
			if not self.prefs.write_ratings:
				self.show_message(_("Note that ratings are stored in the local database and not written to tags."))

		self.gui.update += 1
		self.gui.pl_update = 1
		return None

	def toggle_titlebar_line(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.update_title

		line = self.window_title
		sdl3.SDL_SetWindowTitle(self.t_window, line)
		self.prefs.update_title ^= True
		if self.prefs.update_title:
			self.update_title_do()
		return None

	def toggle_meta_persists_stop(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.meta_persists_stop
		self.prefs.meta_persists_stop ^= True
		return None

	def toggle_side_panel_layout(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.side_panel_layout == 1

		if self.prefs.side_panel_layout == 1:
			self.prefs.side_panel_layout = 0
		else:
			self.prefs.side_panel_layout = 1
		self.gui.update_layout = True
		self.gui.update += 1
		return None

	def toggle_side_panel_side(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.side_panel_left
		self.prefs.side_panel_left ^= True
		self.gui.update_layout = True
		self.gui.update += 1
		return None

	def toggle_meta_shows_selected(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.meta_shows_selected_always
		self.prefs.meta_shows_selected_always ^= True
		return None

	def scale1(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.ui_scale == 1

		self.prefs.ui_scale = 1
		self.pref_box.small_preset()

		if self.prefs.ui_scale != self.gui.scale:
			self.show_message(_("Change will be applied on restart."))
		return None

	def scale125(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.ui_scale == 1.25
		return None

		#self.prefs.ui_scale = 1.25
		#self.pref_box.large_preset()

		#if self.prefs.ui_scale != self.gui.scale:
		#	self.show_message(_("Change will be applied on restart."))
		#return None

	def toggle_use_tray(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.use_tray
		self.prefs.use_tray ^= True
		if not self.prefs.use_tray:
			self.prefs.min_to_tray = False
			self.hide_tray()
		else:
			self.show_tray()
		return None

	def toggle_text_tray(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.tray_show_title
		self.prefs.tray_show_title ^= True
		self.pctl.refresh_now_playing()
		return None

	def toggle_min_tray(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.min_to_tray
		self.prefs.min_to_tray ^= True
		return None

	def toggle_start_in_tray(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.start_in_tray
		self.prefs.start_in_tray ^= True
		return None

	def scale2(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.ui_scale == 2

		self.prefs.ui_scale = 2
		self.pref_box.small_preset()

		if self.prefs.ui_scale != self.gui.scale:
			self.show_message(_("Change will be applied on restart."))
		return None

	def toggle_borderless(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.draw_border

		self.gui.update_layout = True
		self.draw_border ^= True

		if self.draw_border:
			sdl3.SDL_SetWindowBordered(self.t_window, False)
		else:
			sdl3.SDL_SetWindowBordered(self.t_window, True)
		return None

	def toggle_rounded_corners(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.rounded_corners

		self.prefs.rounded_corners ^= True
		self.gui.update += 1
		return None

	def toggle_break(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.break_enable ^ True
		self.prefs.break_enable ^= True
		self.gui.pl_update = 1
		return None

	def toggle_scroll(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return not self.prefs.scroll_enable

		self.prefs.scroll_enable ^= True
		self.gui.pl_update = 1
		self.gui.update_layout = True
		return None

	def toggle_smooth_scroll(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.smooth_scroll.enabled()

		if self.prefs.macos:
			return None

		self.prefs.smooth_scroll_enable ^= True
		self.gui.pl_update = 1
		self.gui.update += 1
		return None

	def toggle_hide_bar(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.gui.set_bar ^ True
		self.gui.update_layout = True
		self.gui.set_bar ^= True
		self.show_message(_("Tip: You can also toggle this from a right-click context menu"))
		return None

	def toggle_append_total_time(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.append_total_time
		self.prefs.append_total_time ^= True
		self.gui.pl_update = 1
		self.gui.update += 1
		return None

	def toggle_append_date(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.append_date
		self.prefs.append_date ^= True
		self.gui.pl_update = 1
		self.gui.update += 1
		return None

	def toggle_true_shuffle(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.true_shuffle
		self.prefs.true_shuffle ^= True
		return None

	def toggle_auto_artist_dl(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.auto_dl_artist_data
		self.prefs.auto_dl_artist_data ^= True
		for artist, value in list(self.artist_list_box.thumb_cache.items()):
			if value is None:
				del self.artist_list_box.thumb_cache[artist]
		return None

	def toggle_scrobble_mark(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.scrobble_mark
		self.prefs.scrobble_mark ^= True
		return None

	def toggle_lfm_auto(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.auto_lfm
		self.prefs.auto_lfm ^= True
		if self.prefs.auto_lfm and not self.bag.last_fm_enable:
			self.show_message(_("Optional module python-pylast not installed"), mode="warning")
			self.prefs.auto_lfm = False
		# if prefs.auto_lfm:
		#     lastfm.hold = False
		# else:
		#     lastfm.hold = True
		return None

	def toggle_lb(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.lb.enable
		if not self.lb.enable and not self.prefs.lb_token:
			self.show_message(_("Can't enable this if there's no token."), mode="warning")
			return None
		self.lb.enable ^= True
		return None

	def toggle_maloja(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.maloja_enable
		if not self.prefs.maloja_url or not self.prefs.maloja_key:
			self.show_message(_("One or more fields are missing."), mode="warning")
			return None
		self.prefs.maloja_enable ^= True
		return None

	def toggle_ex_del(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.auto_del_zip
		self.prefs.auto_del_zip ^= True
		# if prefs.auto_del_zip is True:
		#     self.show_message("Caution! This function deletes things!", mode='info', "This could result in data loss if the process were to malfunction.")
		return None

	def toggle_dl_mon(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.monitor_downloads
		self.prefs.monitor_downloads ^= True
		return None

	def toggle_music_ex(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.extract_to_music
		self.prefs.extract_to_music ^= True
		return None

	def toggle_extract(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.auto_extract
		self.prefs.auto_extract ^= True
		if self.prefs.auto_extract is False:
			self.prefs.auto_del_zip = False
		return None

	def toggle_top_tabs(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.tabs_on_top
		self.prefs.tabs_on_top ^= True
		return None

	def toggle_guitar_chords(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.guitar_chords
		self.prefs.guitar_chords ^= True
		return None

	# def toggle_auto_lyrics(self, mode: int = 0) -> bool | None:
	# 	if mode == 1:
	# 		return self.prefs.auto_lyrics
	# 	self.prefs.auto_lyrics ^= True

	def switch_single(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.transcode_mode == "single"
		self.prefs.transcode_mode = "single"
		return None

	def switch_mp3(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.transcode_codec == "mp3"
		self.prefs.transcode_codec = "mp3"
		return None

	def switch_ogg(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.transcode_codec == "ogg"
		self.prefs.transcode_codec = "ogg"
		return None

	def switch_opus(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.transcode_codec == "opus"
		self.prefs.transcode_codec = "opus"
		return None

	def switch_opus_ogg(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.transcode_opus_as
		self.prefs.transcode_opus_as ^= True
		return None

	def toggle_transcode_output(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return not self.prefs.transcode_inplace
		self.prefs.transcode_inplace ^= True
		if self.prefs.transcode_inplace:
			self.gui.transcode_icon.colour = ColourRGBA(250, 20, 20, 255)
			self.show_message(
				_("DANGER! This will delete the original files. Keeping a backup is recommended in case of malfunction."),
				_("For safety, this setting will default to off. Embedded thumbnails are not kept so you may want to extract them first."),
				mode="warning")
		else:
			self.gui.transcode_icon.colour = ColourRGBA(239, 74, 157, 255)
		return None

	def toggle_transcode_inplace(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.transcode_inplace

		if self.gui.sync_progress:
			self.prefs.transcode_inplace = False
			return None

		self.prefs.transcode_inplace ^= True
		if self.prefs.transcode_inplace:
			self.gui.transcode_icon.colour = ColourRGBA(250, 20, 20, 255)
			self.show_message(
				_("DANGER! This will delete the original files. Keeping a backup is recommended in case of malfunction."),
				_("For safety, this setting will reset on restart. Embedded thumbnails are not kept so you may want to extract them first."),
				mode="warning")
		else:
			self.gui.transcode_icon.colour = ColourRGBA(239, 74, 157, 255)
		return None

	def switch_flac(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.transcode_codec == "flac"
		self.prefs.transcode_codec = "flac"
		return None

	def toggle_sbt(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.prefer_bottom_title
		self.prefs.prefer_bottom_title ^= True
		return None

	def toggle_bba(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.gui.bb_show_art
		self.gui.bb_show_art ^= True
		self.gui.update_layout = True
		return None

	def toggle_use_title(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.use_title
		self.prefs.use_title ^= True
		return None

	def switch_rg_off(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.replay_gain == 0
		self.prefs.replay_gain = 0
		return None

	def switch_rg_track(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.replay_gain == 1
		self.prefs.replay_gain = 0 if self.prefs.replay_gain == 1 else 1
		# self.prefs.replay_gain = 1
		return None

	def switch_rg_album(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.replay_gain == 2
		self.prefs.replay_gain = 0 if self.prefs.replay_gain == 2 else 2
		return None

	def switch_rg_auto(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.replay_gain == 3
		self.prefs.replay_gain = 0 if self.prefs.replay_gain == 3 else 3
		return None

	def toggle_jump_crossfade(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return bool(self.prefs.use_jump_crossfade)
		self.prefs.use_jump_crossfade ^= True
		return None

	def toggle_pause_fade(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return bool(self.prefs.use_pause_fade)
		self.prefs.use_pause_fade ^= True
		return None

	def toggle_transition_crossfade(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return bool(self.prefs.use_transition_crossfade)
		self.prefs.use_transition_crossfade ^= True
		return None

	def toggle_transition_gapless(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return not self.prefs.use_transition_crossfade
		self.prefs.use_transition_crossfade ^= True
		return None

	def toggle_eq(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.use_eq
		self.prefs.use_eq ^= True
		self.pctl.playerCommand = "seteq"
		self.pctl.playerCommandReady = True
		return None


	def drop_file(self, target: str) -> None:
		"""Deprecated, move to individual UI components"""
		i_x = self.inp.mouse_position[0]
		i_y = self.inp.mouse_position[1]
		self.gui.drop_playlist_target = 0
		#logging.info(event.drop)

		if i_y < self.gui.panelY and not self.gui.new_playlist_cooldown and self.gui.mode == GuiMode.MAIN:
			x = self.top_panel.tabs_left_x
			for tab in self.top_panel.shown_tabs:
				wid = self.top_panel.tab_text_spaces[tab] + self.top_panel.tab_extra_width

				if x < i_x < x + wid:
					self.gui.drop_playlist_target = tab
					self.tab_pulse.pulse()
					self.gui.update += 1
					self.gui.pl_pulse = True
					logging.info("Direct drop")
					break

				x += wid
			else:
				logging.info("MISS")
				if self.gui.new_playlist_cooldown:
					self.gui.drop_playlist_target = self.pctl.active_playlist_viewing
				else:
					if not target.lower().endswith(".xspf"):
						self.gui.drop_playlist_target = self.new_playlist()
					self.gui.new_playlist_cooldown = True
		elif self.gui.lsp and self.gui.panelY < i_y < self.window_size[1] - self.gui.panelBY and self.gui.lsp_x < i_x < self.gui.lsp_x + self.gui.lspw and self.gui.mode == GuiMode.MAIN:
			y = self.gui.panelY
			y += 5 * self.gui.scale
			y += self.playlist_box.tab_h + self.playlist_box.gap

			for i, pl in enumerate(self.pctl.multi_playlist):
				if i_y < y:
					self.gui.drop_playlist_target = i
					self.tab_pulse.pulse()
					self.gui.update += 1
					self.gui.pl_pulse = True
					logging.info("Direct drop")
					break
				y += self.playlist_box.tab_h + self.playlist_box.gap
			else:
				if self.gui.new_playlist_cooldown:
					self.gui.drop_playlist_target = self.pctl.active_playlist_viewing
				else:
					if not target.lower().endswith(".xspf"):
						self.gui.drop_playlist_target = self.new_playlist()
					self.gui.new_playlist_cooldown = True
		else:
			self.gui.drop_playlist_target = self.pctl.active_playlist_viewing

		load_order = LoadClass()
		load_order.target = target.replace("\\", "/")
		load_order.playlist = self.pctl.multi_playlist[self.gui.drop_playlist_target].uuid_int

		if self.flatpak_mode:
			if not os.path.exists(target):
				self.show_message(
					_("Could not access! Possible insufficient Flatpak permissions."),
					_(" For details, see {link}").format(link="https://github.com/Taiko2k/TauonMusicBox/wiki/Flatpak-Extra-Steps"),
					mode="bubble")
			elif target.startswith("/run/user/"):
				self.gui.message_box_confirm_reference = (copy.deepcopy(load_order),)
				self.gui.message_box_confirm_callback = lambda x: self.load_orders.append(x)
				self.gui.message_box_no_callback = lambda x: self.show_message(
						_("The target may be lost on reboot without necessary Flatpak permissions."),
					_(" For details, see {link}").format(link="https://github.com/Taiko2k/TauonMusicBox/wiki/Flatpak-Extra-Steps"),
					mode="bubble")
				self.show_message(_("Path may be transient! Continue? Press \"No\" for more information."),
					mode="confirm")
				self.gui.update += 1
				self.inp.mouse_down = False
				self.inp.drag_mode = False
				return

		if os.path.isdir(load_order.target):
			self.quick_import_done.append(load_order.target)

			# if not pctl.multi_playlist[self.gui.drop_playlist_target].last_folder:
			self.pctl.multi_playlist[self.gui.drop_playlist_target].last_folder.append(load_order.target)
			reduce_paths(self.pctl.multi_playlist[self.gui.drop_playlist_target].last_folder)

		self.load_orders.append(copy.deepcopy(load_order))

		#logging.info('dropped: ' + str(dropped_file))
		self.gui.update += 1
		self.inp.mouse_down = False
		self.inp.drag_mode = False

	def s_copy(self) -> None:
		# Copy tracks to internal clipboard
		# self.gui.lightning_copy = False
		# if self.inp.key_shift_down:
		self.gui.lightning_copy = True

		clip = copy_from_clipboard()
		if "file://" in clip:
			copy_to_clipboard("")

		self.pctl.cargo = []
		if self.pctl.default_playlist:
			for item in self.gui.shift_selection:
				self.pctl.cargo.append(self.pctl.default_playlist[item])

		if not self.pctl.cargo and -1 < self.pctl.selected_in_playlist < len(self.pctl.default_playlist):
			self.pctl.cargo.append(self.pctl.default_playlist[self.pctl.selected_in_playlist])

		self.copied_track = None

		if len(self.pctl.cargo) == 1:
			self.copied_track = self.pctl.cargo[0]

	def s_cut(self) -> None:
		self.s_copy()
		self.del_selected()

	def s_append(self, index: int) -> None:
		self.paste(playlist_no=index)

	def paste(self, playlist_no: int | None = None, track_id: int | None = None, target_position: int | None = None) -> None:
		clip = copy_from_clipboard()
		logging.info(clip)
		if "tidal.com/album/" in clip:
			logging.info(clip)
			num = clip.split("/")[-1].split("?")[0]
			if num and num.isnumeric():
				logging.info(num)
				self.tidal.append_album(num)
			clip = False

		elif "tidal.com/playlist/" in clip:
			logging.info(clip)
			num = clip.split("/")[-1].split("?")[0]
			self.tidal.playlist(num)
			clip = False

		elif "tidal.com/mix/" in clip:
			logging.info(clip)
			num = clip.split("/")[-1].split("?")[0]
			self.tidal.mix(num)
			clip = False

		elif "tidal.com/browse/track/" in clip:
			logging.info(clip)
			num = clip.split("/")[-1].split("?")[0]
			self.tidal.track(num)
			clip = False

		elif "tidal.com/browse/artist/" in clip:
			logging.info(clip)
			num = clip.split("/")[-1].split("?")[0]
			self.tidal.artist(num)
			clip = False

		found = False
		if clip:
			clip = clip.split("\n")
			for i, line in enumerate(clip):
				if line.startswith(("file://", "/")):
					target = str(urllib.parse.unquote(line)).replace("file://", "").replace("\r", "")
					load_order = LoadClass()
					load_order.target = target
					load_order.playlist = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].uuid_int

					if playlist_no is not None:
						load_order.playlist = self.pctl.pl_to_id(playlist_no)
					if target_position is not None:
						load_order.playlist_position = target_position

					self.load_orders.append(copy.deepcopy(load_order))
					found = True

		if not found:
			if playlist_no is None:
				if track_id is None:
					self.transfer(0, (2, 3))
				else:
					self.transfer(track_id, (2, 2))
			else:
				self.append_playlist(playlist_no)

		self.gui.pl_update += 1
	def do_exit_button(self) -> None:
		if self.inp.mouse_up or self.inp.ab_click:
			if self.gui.tray_active and self.prefs.min_to_tray:
				if self.inp.key_shift_down:
					self.exit("User clicked X button with shift key")
					return
				self.min_to_tray()
			elif self.gui.sync_progress and not self.gui.stop_sync:
				self.show_message(_("Stop the sync before exiting!"))
			else:
				self.exit("User clicked X button")

	def do_maximize_button(self) -> None:
		if self.gui.fullscreen:
			self.gui.fullscreen = False
			sdl3.SDL_SetWindowFullscreen(self.t_window, 0)
		elif self.gui.maximized:
			self.gui.maximized = False
			sdl3.SDL_RestoreWindow(self.t_window)
		else:
			self.gui.maximized = True
			sdl3.SDL_MaximizeWindow(self.t_window)

		self.inp.mouse_down = False
		self.inp.mouse_click = False
		self.inp.drag_mode = False

	def do_minimize_button(self) -> None:
		if self.macos:
			# hack
			sdl3.SDL_SetWindowBordered(self.t_window, True)
			sdl3.SDL_MinimizeWindow(self.t_window)
			sdl3.SDL_SetWindowBordered(self.t_window, False)
		else:
			sdl3.SDL_MinimizeWindow(self.t_window)

		self.inp.mouse_down = False
		self.inp.mouse_click = False
		self.inp.drag_mode = False

	def new_playlist(self, switch: bool = True) -> int | None:
		if self.gui.radio_view:
			self.pctl.radio_playlists.append(RadioPlaylist(uid=uid_gen(), name=_("New Radio List"), stations=[], scroll=0))
			return None

		title = self.gen_unique_pl_title(_("New Playlist"))

		self.top_panel.prime_side = 1
		self.top_panel.prime_tab = len(self.pctl.multi_playlist)

		self.pctl.multi_playlist.append(self.pl_gen(title=title))  # [title, 0, [], 0, 0, 0])
		if switch:
			self.pctl.switch_playlist(len(self.pctl.multi_playlist) - 1)
		return len(self.pctl.multi_playlist) - 1

	def toggle_enable_web(self, mode: int = 0) -> bool | None:
		prefs = self.prefs
		gui   = self.gui
		if mode == 1:
			return prefs.enable_web

		prefs.enable_web ^= True

		if prefs.enable_web and not gui.web_running:
			webThread = threading.Thread(
				target=webserve, args=[self.pctl, prefs, gui, self.album_art_gen, str(self.install_directory), self.strings, self])
			webThread.daemon = True
			webThread.start()
			self.show_message(_("Web server starting"), _("External connections will be accepted."), mode="done")

		elif prefs.enable_web is False:
			if self.radio_server is not None:
				self.radio_server.shutdown()
				gui.web_running = False

			time.sleep(0.25)
		return None

	def get_album_info(self, position: int, pl: int | None = None) -> tuple[bool, list[int], bool]:
		pctl     = self.pctl
		playlist = pctl.default_playlist
		prefs    = self.prefs
		if pl is not None:
			playlist = pctl.multi_playlist[pl].playlist_ids

		cache_key = (pctl.selected_in_playlist, pctl.playing_object(), pctl.playlist_playing_position)
		if self.album_info_cache_key != cache_key:  # Premature optimisation?
			self.album_info_cache.clear()
			self.album_info_cache_key = cache_key

		if position in self.album_info_cache:
			return self.album_info_cache[position]

		# album_dex is kept current while the preset gallery is on (album_mode)
		# or a custom-layout gallery widget is drawing (it rebuilds the dex per
		# playlist — see GalleryWidget._ensure_album_dex). In custom mode
		# album_mode is just whatever the previous preset left behind, so gate
		# on the dex's own playlist instead — otherwise every cache miss here
		# rebuilds the full album index (a whole-playlist scan per visible tile).
		viewing_pl = pctl.active_playlist_viewing if pl is None else pl
		if self.album_dex and viewing_pl == pctl.active_playlist_viewing and (
			prefs.album_mode
			or (self.gui.custom_mode and self.album_dex_pl_uuid == pctl.multi_playlist[viewing_pl].uuid_int)
		):
			dex = self.album_dex
		else:
			dex = self.reload_albums(custom_list=playlist)

		end = len(playlist)
		start = 0

		for i, p in enumerate(reversed(dex)):
			if p <= position:
				start = p
				break
			end = p

		album = list(range(start, end))

		playing = False
		select = False

		if pctl.selected_in_playlist in album:
			select = True

		if len(pctl.track_queue) > 0 and p < len(playlist):
			playing_track = pctl.track_queue[pctl.queue_step]
			if playing_track in playlist[start:end]:
				playing = True
				# If the album appears more than once in the playlist, only
				# highlight the instance the playing position is within
				viewing_pl = pctl.active_playlist_viewing if pl is None else pl
				pp = pctl.playlist_playing_position
				if (
					viewing_pl == pctl.active_playlist_playing
					and 0 <= pp < len(playlist)
					and playlist[pp] == playing_track
					and not start <= pp < end
				):
					playing = False

		self.album_info_cache[position] = playing, album, select
		return playing, album, select

	def set_tray_icons(self, force: bool = False) -> None:

		indicator_icon_play =    str(self.install_directory / "assets/svg/tray-indicator-play.svg")
		indicator_icon_pause =   str(self.install_directory / "assets/svg/tray-indicator-pause.svg")
		indicator_icon_default = str(self.install_directory / "assets/svg/tray-indicator-default.svg")

		if self.prefs.tray_theme == "gray":
			indicator_icon_play =    str(self.install_directory / "assets/svg/tray-indicator-play-g1.svg")
			indicator_icon_pause =   str(self.install_directory / "assets/svg/tray-indicator-pause-g1.svg")
			indicator_icon_default = str(self.install_directory / "assets/svg/tray-indicator-default-g1.svg")

		user_icon_dir = self.cache_directory / "icon-export"
		def install_tray_icon(src: str, name: str) -> None:
			alt = user_icon_dir / f"{name}.svg"
			if not alt.is_file() or force:
				if alt.exists():
					# Remove file first to avoid PermissionError on distributions like NixOS that use 444 for permissions
					# See https://github.com/Taiko2k/Tauon/issues/1615
					alt.unlink()
				shutil.copy(src, str(alt))

		if not user_icon_dir.is_dir():
			os.makedirs(user_icon_dir)

		install_tray_icon(indicator_icon_play, "tray-indicator-play")
		install_tray_icon(indicator_icon_pause, "tray-indicator-pause")
		install_tray_icon(indicator_icon_default, "tray-indicator-default")

	def get_tray_icon(self, name: str) -> str:
		return str(self.cache_directory / "icon-export" / f"{name}.svg")

	def test_ffmpeg(self) -> bool:
		if self.get_ffmpeg():
			return True
		if not self.macos:
			self.show_message(_("This feature requires FFmpeg. Should I download it for you? (~100 MB)"), mode="confirm")
			self.gui.message_box_confirm_callback = self.download_ffmpeg
			self.gui.message_box_no_callback = None
			self.gui.message_box_confirm_reference = (None,)
		else:
			self.show_message(_("FFmpeg could not be found"))
		return False

	def get_ffmpeg(self) -> Path | None:
		path = self.user_directory / "ffmpeg.exe"
		if self.windows and path.is_file():
			return path

		# macOS
		path = self.install_directory / "ffmpeg"
		if path.is_file():
			return path

		# Portable Linux
		path = self.user_directory / "ffmpeg"
		if path.is_file():
			return path

		path = shutil.which("ffmpeg")
		if path:
			return Path(path)
		return None

	def get_ffprobe(self) -> Path | None:
		path = self.user_directory / "ffprobe.exe"
		if self.windows and path.is_file():
			return path

		# macOS
		path = self.install_directory / "ffprobe"
		if path.is_file():
			return path

		# Portable Linux
		path = self.user_directory / "ffprobe"
		if path.is_file():
			return path

		path = shutil.which("ffprobe")
		if path:
			return Path(path)
		return None

	def bg_save(self) -> None:
		self.worker_save_state = True
		self.thread_manager.ready("worker")

	def exit(self, reason: str) -> None:
		logging.info(f"Shutting down. Reason: {reason}")
		self.pctl.running = False
		self.wake()

	def min_to_tray(self) -> None:
		sdl3.SDL_HideWindow(self.t_window)
		self.gui.mouse_unknown = True

	def request_raise(self) -> None:
		self.requested_raise = True
		self.wake()

	def raise_window(self) -> None:
		sdl3.SDL_ShowWindow(self.t_window)
		sdl3.SDL_RaiseWindow(self.t_window)
		sdl3.SDL_RestoreWindow(self.t_window)
		self.gui.lowered = False
		self.gui.update += 1

	def focus_window(self) -> None:
		sdl3.SDL_RaiseWindow(self.t_window)

	def get_playing_playlist_id(self) -> int:
		return self.pctl.pl_to_id(self.pctl.active_playlist_playing)

	def wake(self) -> None:
		sdl3.SDL_PushEvent(ctypes.byref(self.dummy_event))

class PlexService:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon:    Tauon = tauon
		self.gui:     GuiVar = tauon.gui
		self.pctl: PlayerCtl = tauon.pctl
		self.prefs:    Prefs = tauon.prefs
		self.show_message    = tauon.show_message
		self.connected: bool = False
		self.resource        = None
		self.scanning:  bool = False
		self.two_factor_required: bool = False

	def connect(self, code: str | None = None) -> bool:
		if not self.prefs.plex_username or not self.prefs.plex_password or not self.prefs.plex_servername:
			self.show_message(_("Missing username, password and/or server name"), mode="warning")
			self.scanning = False
			return False

		try:
			from plexapi.exceptions import TwoFactorRequired
			from plexapi.myplex import MyPlexAccount
		except ModuleNotFoundError:
			logging.warning("Unable to import python-plexapi, plex support will be disabled.")
			self.scanning = False
			return False
		except Exception:
			logging.exception("Unknown error to import python-plexapi, plex support will be disabled.")
			self.show_message(_("Error importing python-plexapi"), mode="error")
			self.scanning = False
			return False

		try:
			account = MyPlexAccount(self.prefs.plex_username, self.prefs.plex_password, code=code)
			self.resource = account.resource(self.prefs.plex_servername).connect()  # returns a PlexServer instance
			self.connected = True
			self.two_factor_required = False
			return True
		except TwoFactorRequired:
			logging.info("PLEX two-factor authentication required")
			self.connected = False
			self.resource = None
			self.two_factor_required = True
			self.show_message(
				_("Two-factor authentication required"),
				_("Enter the verification code and try again."),
				mode="warning",
			)
			self.gui.update += 1
			self.scanning = False
			return False
		except Exception:
			logging.exception("Error connecting to PLEX server, check login credentials and server accessibility.")
			self.show_message(
				_("Error connecting to PLEX server"),
				_("Try checking login credentials and that the server is accessible."), mode="error")
			self.scanning = False
			return False

	def resolve_stream(self, location: str):
		logging.info("Get plex stream")
		if not self.connected:
			self.connect()

		# return self.resource.url(location, True)
		return self.resource.library.fetchItem(location).getStreamURL()

	def resolve_thumbnail(self, location: str):
		if not self.connected:
			self.connect()
		if self.connected:
			return self.resource.url(location, True)
		return None

	def get_albums(self, return_list: bool = False) -> list[int] | None:
		self.gui.update += 1
		self.scanning = True

		if not self.connected:
			self.connect()

		if not self.connected:
			self.scanning = False
			return []

		playlist: list[int] = []

		existing = {}
		for track_id, track in self.pctl.master_library.items():
			if track.is_network and track.file_ext == "PLEX":
				existing[track.url_key] = track_id

		albums = self.resource.library.section(self.prefs.plex_library).albums()
		self.gui.to_got = 0

		for album in albums:
			year = album.year
			album_artist = album.parentTitle
			album_title = album.title

			parent = (album_artist + " - " + album_title).strip("- ")

			for track in album.tracks():
				if not track.duration:
					logging.warning(f"Skipping track with invalid duration - {track.title} - {track.grandparentTitle}")
					continue

				id = self.pctl.master_count
				replace_existing = False

				e = existing.get(track.key)
				if e is not None:
					id = e
					replace_existing = True

				title = track.title
				track_artist = track.grandparentTitle
				duration = track.duration / 1000

				nt = TrackClass()
				nt.index = id
				nt.track_number = track.index
				nt.file_ext = "PLEX"
				nt.parent_folder_path = parent
				nt.parent_folder_name = parent
				nt.album_artist = album_artist
				nt.artist = track_artist
				nt.title = title
				nt.album = album_title
				nt.length = duration
				if hasattr(track, "locations") and track.locations:
					nt.fullpath = track.locations[0]

				nt.is_network = True

				if track.thumb:
					nt.art_url_key = track.thumb

				nt.url_key = track.key
				nt.date = str(year)

				self.pctl.master_library[id] = nt

				if not replace_existing:
					self.pctl.master_count += 1

				playlist.append(nt.index)

			self.gui.to_got += 1
			self.gui.update += 1
			self.gui.pl_update += 1

		self.scanning = False

		if return_list:
			return playlist

		self.pctl.multi_playlist.append(self.tauon.pl_gen(title=_("PLEX Collection"), playlist_ids=playlist))
		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "plex path"
		self.pctl.switch_playlist(len(self.pctl.multi_playlist) - 1)
		return None

class TauService:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon:            Tauon = tauon
		self.pctl:         PlayerCtl = tauon.pctl
		self.prefs:            Prefs = tauon.prefs
		self.show_message            = tauon.show_message
		self.install_directory: Path = tauon.install_directory
		self.processing: bool        = False

	def resolve_stream(self, key: str) -> str:
		return "http://" + self.prefs.sat_url + ":7814/api1/file/" + key

	def resolve_picture(self, key: str) -> str:
		return "http://" + self.prefs.sat_url + ":7814/api1/pic/medium/" + key

	def get(self, point: str):
		url = "http://" + self.prefs.sat_url + ":7814/api1/"
		data = None
		try:
			r = requests.get(url + point, timeout=10)
			data = r.json()
		except Exception as e:
			logging.exception("Network error")
			self.show_message(_("Network error"), str(e), mode="error")
		return data

	def get_playlist(self, playlist_name: str | None = None, return_list: bool = False) -> list[int] | None:
		p = self.get("playlists")

		if not p or not p["playlists"]:
			self.processing = False
			return []

		if playlist_name is None:
			playlist_name = self.tauon.text_sat_playlist.text.strip()
		if not playlist_name:
			self.show_message(_("No playlist name"))
			return []

		id = None
		name = ""
		for pp in p["playlists"]:
			if pp["name"].lower() == playlist_name.lower():
				id = pp["id"]
				name = pp["name"]

		if id is None:
			self.show_message(_("Playlist not found on target"), mode="error")
			self.processing = False
			return []

		try:
			t = self.get("tracklist/" + id)
		except Exception:
			logging.exception("error getting tracklist")
			return []
		at = t["tracks"]

		exist = {}
		for k, v in self.pctl.master_library.items():
			if v.is_network and v.file_ext == "TAU":
				exist[v.url_key] = k

		playlist = []
		for item in at:
			replace_existing = True

			tid = item["id"]
			id = exist.get(str(tid))
			if id is None:
				id = self.pctl.master_count
				replace_existing = False

			nt = TrackClass()
			nt.index = id
			nt.title = item.get("title", "")
			nt.artist = item.get("artist", "")
			nt.album = item.get("album", "")
			nt.album_artist = item.get("album_artist", "")
			nt.length = int(item.get("duration", 0) / 1000)
			nt.track_number = item.get("track_number", 0)

			nt.fullpath = item.get("path", "")
			nt.filename = os.path.basename(nt.fullpath)
			nt.parent_folder_name = os.path.basename(os.path.dirname(nt.fullpath))
			nt.parent_folder_path = os.path.dirname(nt.fullpath)

			nt.url_key = str(tid)
			nt.art_url_key = str(tid)

			nt.is_network = True
			nt.file_ext = "TAU"
			self.pctl.master_library[id] = nt

			if not replace_existing:
				self.pctl.master_count += 1
			playlist.append(nt.index)

		if return_list:
			self.processing = False
			return playlist

		self.pctl.multi_playlist.append(self.tauon.pl_gen(title=name, playlist_ids=playlist))
		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "tau path tn"
		self.tauon.standard_sort(len(self.pctl.multi_playlist) - 1)
		self.pctl.switch_playlist(len(self.pctl.multi_playlist) - 1)
		self.processing = False
		return None

class GStats:
	def __init__(self, tauon: Tauon) -> None:
		self.pctl       = tauon.pctl
		self.star_store = tauon.star_store
		self.last_db: int = 0
		self.last_pl: int = 0
		self.artist_list: list[tuple[str, int]] = []
		self.album_list:  list[tuple[str, int]] = []
		self.genre_list:  list[tuple[str, int]] = []
		self.genre_dict:   dict[str, list[int]] = {}

	def update(self, playlist: int) -> None:
		pt = 0

		if self.pctl.master_count != self.last_db or self.last_pl != playlist:
			self.last_db = self.pctl.master_count
			self.last_pl = playlist

			artists: dict[str, int] = {}

			for index in self.pctl.multi_playlist[playlist].playlist_ids:
				artist = self.pctl.master_library[index].artist

				if artist == "":
					artist = "<Artist Unspecified>"

				pt = int(self.star_store.get(index))
				if pt < 30:
					continue

				if artist in artists:
					artists[artist] += pt
				else:
					artists[artist] = pt

			art_list = artists.items()

			sorted_list = sorted(art_list, key=lambda x: x[1], reverse=True)

			self.artist_list = copy.deepcopy(sorted_list)

			genres: dict[str, int] = {}
			genre_dict: dict[str, list[int]] = {}

			for index in self.pctl.multi_playlist[playlist].playlist_ids:
				genre_r = self.pctl.master_library[index].genre

				pt = int(self.star_store.get(index))

				gn = []
				if "," in genre_r:
					for g in genre_r.split(","):
						g = g.rstrip(" ").lstrip(" ")
						if len(g) > 0:
							gn.append(g)
				elif ";" in genre_r:
					for g in genre_r.split(";"):
						g = g.rstrip(" ").lstrip(" ")
						if len(g) > 0:
							gn.append(g)
				elif "/" in genre_r:
					for g in genre_r.split("/"):
						g = g.rstrip(" ").lstrip(" ")
						if len(g) > 0:
							gn.append(g)
				elif " & " in genre_r:
					for g in genre_r.split(" & "):
						g = g.rstrip(" ").lstrip(" ")
						if len(g) > 0:
							gn.append(g)
				else:
					gn = [genre_r]

				pt = int(pt / len(gn))

				for genre in gn:
					if genre.lower() in {"", "other", "unknown", "misc"}:
						genre = "<Genre Unspecified>"
					if genre.lower() in {"jpop", "japanese pop"}:
						genre = "J-Pop"
					if genre.lower() in {"jrock", "japanese rock"}:
						genre = "J-Rock"
					if genre.lower() in {"alternative music", "alt-rock", "alternative", "alternrock", "alt"}:
						genre = "Alternative Rock"
					if genre.lower() in {"jpunk", "japanese punk"}:
						genre = "J-Punk"
					if genre.lower() in {"post rock", "post-rock"}:
						genre = "Post-Rock"
					if genre.lower() in {"video game", "game", "game music", "video game music", "game ost"}:
						genre = "Video Game Soundtrack"
					if genre.lower() in {"general soundtrack", "ost", "Soundtracks"}:
						genre = "Soundtrack"
					if genre.lower() in ("anime", "アニメ", "anime ost"):
						genre = "Anime Soundtrack"
					if genre.lower() in {"同人"}:
						genre = "Doujin"
					if genre.lower() in {"chill, chill out", "chill-out"}:
						genre = "Chillout"

					genre = genre.title()

					if len(genre) == 3 and genre[2] == "m":
						genre = genre.upper()

					if genre in genres:

						genres[genre] += pt
					else:
						genres[genre] = pt

					if genre in genre_dict:
						genre_dict[genre].append(index)
					else:
						genre_dict[genre] = [index]

			art_list = genres.items()
			sorted_list = sorted(art_list, key=lambda x: x[1], reverse=True)

			self.genre_list = copy.deepcopy(sorted_list)
			self.genre_dict = genre_dict

			# logging.info('\n-----------------------\n')

			g_albums: dict[str, int] = {}

			for index in self.pctl.multi_playlist[playlist].playlist_ids:
				album = self.pctl.master_library[index].album

				if album == "":
					album = "<Album Unspecified>"

				pt = int(self.star_store.get(index))

				if pt < 30:
					continue

				if album in g_albums:
					g_albums[album] += pt
				else:
					g_albums[album] = pt

			art_list = g_albums.items()

			sorted_list = sorted(art_list, key=lambda x: x[1], reverse=True)

			self.album_list = copy.deepcopy(sorted_list)

class Drawing:
	def __init__(self, tauon: Tauon, pctl: PlayerCtl) -> None:
		self.tauon: Tauon      = tauon
		self.gui: GuiVar        = tauon.gui
		self.inp: Input        = tauon.inp
		self.ddt: TDraw        = tauon.ddt
		self.coll       = tauon.coll
		self.fields: Fields     = tauon.fields
		self.colours: ColoursClass    = tauon.colours
		self.star_store: StarStore = pctl.star_store

	def button(
		self, text: str, x: int, y: int, w: int | None = None, h: int | None = None, font: int = 212, text_highlight_colour: ColourRGBA | None = None, text_colour: ColourRGBA | None = None,
		background_colour: ColourRGBA | None = None, background_highlight_colour: ColourRGBA | None = None, press: bool | None = None, tooltip: str = "") -> bool:
		"""PSA for anyone making a new button function: use fields.add(rect) to make the gui
		refresh when you pan the mouse over it
		"""
		if w is None:
			w = self.ddt.get_text_w(text, font) + 18 * self.gui.scale
		if h is None:
			h = self.ddt.get_text_w(text, font, True) + 6*self.gui.scale

		rect = (x, y, w, h)
		self.fields.add(rect)

		if text_highlight_colour is None:
			text_highlight_colour = self.colours.box_button_text_highlight
		if text_colour is None:
			text_colour = self.colours.box_button_text
		if background_colour is None:
			background_colour = self.colours.box_button_background
		if background_highlight_colour is None:
			background_highlight_colour = self.colours.box_button_background_highlight

		click = False
		text_y = rect[1] + rect[3] / 2 - 9 * self.gui.scale

		if press is None:
			press = self.inp.mouse_click

		if self.coll(rect):
			if tooltip:
				self.tauon.tool_tip.test(x + 15 * self.gui.scale, y - 28 * self.gui.scale, tooltip)
			self.ddt.rect(rect, background_highlight_colour)

			# if background_highlight_colour[3] != 255:
			#	 background_highlight_colour = None

			self.ddt.text(
				(rect[0] + int(rect[2] / 2), text_y, 2), text, text_highlight_colour, font, bg=background_highlight_colour)
			if press:
				click = True
		else:
			self.ddt.rect(rect, background_colour)
			if background_highlight_colour.a != 255:
				background_colour = None
			self.ddt.text(
				(rect[0] + int(rect[2] / 2), text_y, 2), text, text_colour, font, bg=background_colour)
		return click

class DropShadow:

	def __init__(self, tauon: Tauon) -> None:
		self.gui      = tauon.gui
		self.ddt      = tauon.ddt
		self.renderer = tauon.renderer
		self.readys = {}
		self.underscan = int(15 * tauon.gui.scale)
		self.radius = 4
		self.grow = 2 * tauon.gui.scale
		self.opacity = 90

	def prepare(self, w: int, h: int) -> None:
		fh = h + self.underscan
		fw = w + self.underscan

		im = Image.new("RGBA", (round(fw), round(fh)), 0x00000000)
		d = ImageDraw.Draw(im)
		d.rectangle(((self.underscan, self.underscan), (w + 2, h + 2)), fill="black")

		im = im.filter(ImageFilter.GaussianBlur(self.radius))

		g = io.BytesIO()
		g.seek(0)
		im.save(g, "PNG")
		g.seek(0)


		s_image = self.ddt.load_image(g)

		c = sdl3.SDL_CreateTextureFromSurface(self.renderer, s_image)
		sdl3.SDL_SetTextureAlphaMod(c, self.opacity)

		tex_w = pointer(c_float(0))
		tex_h = pointer(c_float(0))
		sdl3.SDL_GetTextureSize(c, tex_w, tex_h)

		dst = sdl3.SDL_FRect(0, 0)
		dst.w = int(tex_w.contents.value)
		dst.h = int(tex_h.contents.value)

		sdl3.SDL_DestroySurface(s_image)
		g.close()
		im.close()

		unit = (dst, c)
		self.readys[(w, h)] = unit

	def render(self, x: int, y: int, w: int, h: int) -> None:
		if (w, h) not in self.readys:
			self.prepare(w, h)

		unit = self.readys[(w, h)]
		unit[0].x = round(x) - round(self.underscan)
		unit[0].y = round(y) - round(self.underscan)
		sdl3.SDL_RenderTexture(self.renderer, unit[1], None, unit[0])

def strip_lrc_formatting(lyrics: str) -> str:
	text = ""
	for line in lyrics.split("\n"):
		if len(line) < 10 or (line[0] != "[" or (line[9] != "]" and ":" not in line)) or "." not in line:
			text += line + "\n"
		else:
			text += line.split("]")[-1] + "\n"
	return text

class LyricsRenMini:

	def __init__(self, tauon: Tauon) -> None:
		self.pctl  = tauon.pctl
		self.ddt   = tauon.ddt
		self.colours = tauon.colours
		self.prefs = tauon.prefs
		self.index: int = -1
		self.text: str  = ""
		self.to_reload: bool = False

		self.lyrics_position = 0

	def generate(self, index: int, w: float) -> None:
		self.text = strip_lrc_formatting(self.pctl.master_library[index].lyrics)
		self.lyrics_position = 0

	def render(self, index: int, x: float, y: float, w: float, h: None, p: int) -> None:
		if index != self.index or self.to_reload: # or self.text != self.pctl.master_library[index].lyrics:
			self.index = index
			self.generate(index, w)
			self.to_reload = False

		colour = self.colours.lyrics
		bg = self.colours.lyrics_panel_background

		# if inp.key_ctrl_down:
		#	 if inp.mouse_wheel < 0:
		#		 prefs.lyrics_font_size += 1
		#	 if inp.mouse_wheel > 0:
		#		 prefs.lyrics_font_size -= 1

		self.ddt.text((x, y, 4, w), self.text, colour, self.prefs.lyrics_font_size, w - (w % 2), bg)

class LyricsRen:

	def __init__(self, tauon: Tauon) -> None:
		self.ddt     = tauon.ddt
		self.colours = tauon.colours
		self.index = -1
		self.text = ""
		self.lrm     = tauon.lyrics_ren_mini

		self.lyrics_position = 0

	def test_update(self, track_object: TrackClass) -> None:
		if track_object.index != self.index or self.lrm.to_reload: # or self.text != track_object.lyrics:
			self.index = track_object.index
			self.text = strip_lrc_formatting(track_object.lyrics)
			self.lyrics_position = 0
			self.lrm.to_reload = False

	def render(self, x: int, y: int, w: int, h: int, p: int) -> None:
		colour = self.colours.lyrics
		bg = self.colours.lyrics_panel_background

		#colour = self.colours.grey(40)
		# if test_lumi(self.colours.lyrics_panel_background) < 0.5:
		#	colour = self.colours.grey(40)
		# TODO (Flynn): this used to check the gallery background & i don't even know why it did that much
		self.ddt.text((x, y, 4, w), self.text, colour, 17, w, bg)

class TimedLyricsToStatic:

	def __init__(self) -> None:
		self.cache_key = None
		self.cache_lyrics = ""

	def get(self, track: TrackClass) -> str:
		if track.is_network:
			return ""
		if track == self.cache_key:
			return self.cache_lyrics
		data = track.lyrics.splitlines() if track.lyrics else find_synced_lyric_data(track)

		if data is None:
			self.cache_lyrics = ""
			self.cache_key = track
			return ""
		text = ""

		for line in data:
			if line and line[0] != "[" and ":" not in line:
				text += line + "\n"
				continue

			if len(line) < 10:
				continue

			text += line.split("]")[-1].rstrip("\n") + "\n"

		self.cache_lyrics = text
		self.cache_key = track
		return text

class TimedLyricsRen:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon         = tauon
		self.ddt           = tauon.ddt
		self.gui           = tauon.gui
		self.inp           = tauon.inp
		self.coll          = tauon.coll
		self.pctl          = tauon.pctl
		self.prefs         = tauon.prefs
		self.colours       = tauon.colours
		self.top_panel     = tauon.top_panel
		self.window_size   = tauon.window_size
		self.showcase_menu = tauon.showcase_menu
		self.smooth_scroll = tauon.smooth_scroll
		self.lrm           = tauon.lyrics_ren_mini
		self.index         = -1

		self.scanned = {}
		self.ready = False
		self.data = []
		self.line_heights: list[int] = []

		self.scroll_position: int = 0

		self.recenter_timeout = Timer()
		self.temp_line: int = -1
		self.teleport_line: int | None = None
		self.temp_scale: float = self.gui.scale
		self.temp_w: int = -1
		self.temp_side_panel: bool = False

	def generate(self, track: TrackClass) -> bool | None:
		if self.index == track.index and not self.lrm.to_reload:
			return self.ready

		self.ready = False
		self.index = track.index
		self.scroll_position = 0
		self.data.clear()
		self.temp_scale = self.gui.scale

		data = find_synced_lyric_data(track)
		if data is None:
			return None

		for line in data:
			if len(line) < 10:
				continue

			if line[0] != "[" or "]" not in line or ":" not in line or "." not in line:
				continue

			try:
				text = line.split("]")[-1].rstrip("\n")
				t = line

				while t[0] == "[" and (t[9] == "]" or t[10] == "]") and ":" in t and "." in t:
					a = t.lstrip("[")
					t = t.split("]")[1] + "]"

					a = a.split("]")[0]
					mm, b = a.split(":")
					ss, ms = b.split(".")

					s = int(mm) * 60 + int(ss)
					if len(ms) == 2:
						s += int(ms) / 100
					elif len(ms) == 3:
						s += int(ms) / 1000

					self.data.append((s, text))

					if len(t) < 10:
						break
			except Exception:
				logging.exception("Failed generating timed lyrics")
				continue

		self.data = sorted(self.data, key=lambda x: x[0])
		if not self.data:
			return None
		self.line_heights = []
		self.recenter_timeout.set()
		self.temp_line = -1

		self.ready = True
		return True

	def render(self, index: int, x: int, y: int, side_panel: bool = False, w: int = 0, h: int = 0) -> bool | None:
		if index != self.index or self.lrm.to_reload:
			self.ready = False
			self.generate(self.pctl.master_library[index])
			self.lrm.to_reload = False
		line_positions: list[tuple[list[int], list[float | str], int]] = []
		# saves collider positions alongside their respective lines

		if self.inp.right_click and x and y and self.coll((x, y, w, h)):
			self.showcase_menu.activate(self.pctl.master_library[index])

		if not self.ready:
			return False

		line_active = -1
		last = -1

		highlight = True

		if side_panel:
			scroll_to = round(-h/3)
			bg = self.colours.lyrics_panel_background
			font_size = 15
			spacing = round(6 * self.gui.scale)
			self.ddt.rect((self.gui.rsp_x, y, self.gui.rspw, h), bg)
			y += 25 * self.gui.scale
			y_center = y + (h/2) - (spacing)
			allowed_width = round(w - 20 * self.gui.scale)
		else:
			scroll_to = 0
			bg = self.colours.lyrics_panel_background
			font_size = 20
			spacing = round(10 * self.gui.scale)
			y_center = self.window_size[1]/2
			allowed_width = round(w - 20 * self.gui.scale) - 108

		# reset scroll position after 5 seconds
		if self.recenter_timeout.get() > 5 and self.pctl.playing_state == PlayingState.PLAYING:
			self.scroll_position = scroll_to


		if self.teleport_line:
			line_active = self.teleport_line
			self.teleport_line = None
		else:
			# determine active lyric
			test_time = self.tauon.get_real_time()
			if self.pctl.track_queue[self.pctl.queue_step] == index:
				for i, line in enumerate(self.data):
					if line[0] <= test_time:
						last = i

					if line[0] > test_time:
						self.pctl.wake_past_time = line[0]
						line_active = last
						break
				else:
					line_active = len(self.data) - 1

		# record line heights so we can perfectly center the active lyric
		if not self.line_heights or self.temp_scale != self.gui.scale or self.temp_w != w or self.temp_side_panel != side_panel:
			self.scroll_position = scroll_to
			self.line_heights = []
			for i, line in enumerate(self.data):
				drop_w, line_h = self.ddt.get_text_wh(line[1], font_size, allowed_width, True)
				self.line_heights.append( line_h + spacing )
			self.temp_scale = self.gui.scale
			self.temp_w = w
			self.temp_side_panel = side_panel

		# don't autoscroll if the new active line is not visible
		if ( self.scroll_position > h/2 or self.scroll_position < -h/2 ) and self.temp_line != line_active:
			self.scroll_position -= ( self.temp_line - line_active ) * self.line_heights[line_active]
			self.temp_line = line_active


		if self.inp.mouse_wheel:
			scroll_distance = self.smooth_scroll.scroll("timed lyrics", 30*self.gui.scale)
			if side_panel:
				if self.coll((x, y, w, h)):
					self.scroll_position += scroll_distance
				self.recenter_timeout.set()
			elif self.gui.panelY < self.inp.mouse_position[1] < self.window_size[1] - self.gui.panelBY:
				self.scroll_position += scroll_distance
				self.recenter_timeout.set()


		self.scroll_position = round(self.scroll_position)

		if side_panel:
			top_position =     sum( self.line_heights[ :max(0,line_active) ]) - h/2
			bottom_position = -sum( self.line_heights[ max(0,line_active): ]) + h/2 - self.gui.panelBY
		else:
			top_position =     sum( self.line_heights[ :max(0,line_active) ]) - self.window_size[1]/2 + y/2
			bottom_position = -sum( self.line_heights[ max(0,line_active): ]) + self.window_size[1]/2 - self.gui.panelBY

		if self.scroll_position < bottom_position:
			self.scroll_position = int(bottom_position)
		if self.scroll_position > top_position:
			self.scroll_position = int(top_position)


		center = y_center + self.scroll_position
		# scroll position refers to y offset (in pixels) from the active lyric

		for i, line in enumerate(self.data):
			# determine y val
			possible_y = center - \
				sum( self.line_heights[i: max(0,line_active) ] ) + \
				sum( self.line_heights[ max(line_active,0) :i] )

			if possible_y > 0 and possible_y < self.window_size[1]:
				colour = self.colours.lyrics

				#colour = self.colours.grey(70)
				#if test_lumi(self.colours.gallery_background) < 0.5:
				#	colour = self.colours.grey(40)

				if i == line_active and highlight:
					colour = self.colours.active_lyric

				location = [ round(x), round(possible_y), 4, allowed_width - 12 ]
				# see t_draw.py -> __draw_text_cairo -> line that says #Hack
				text = line[1]
				if text.rstrip() == "":
					text = "♪♪♪"
				line_h = self.ddt.text(location, text, colour, font_size, allowed_width, bg)

				collider = [ round(x), round(possible_y - spacing/2), allowed_width, self.line_heights[i] ]
				association = collider, line, i
				line_positions.append( association )


		# click a lyric to seek to it
		if self.inp.mouse_click \
			and self.gui.panelY < self.inp.mouse_position[1] < self.window_size[1] - self.gui.panelBY \
			and (not h or y-25*self.gui.scale < self.inp.mouse_position[1] < y+h-25*self.gui.scale):
			for rendered_line in line_positions:
				if self.coll(rendered_line[0]):
					self.pctl.seek_time(rendered_line[1][0] + self.prefs.sync_lyrics_time_offset/1000)
					self.scroll_position = scroll_to
					self.teleport_line = rendered_line[2]
					self.temp_line = rendered_line[2]
					break

		return None

class TextBox2:
	# TODO(Martin): Global class var!
	cursor = True

	def __init__(self, tauon: Tauon) -> None:
		self.tauon:    Tauon = tauon
		self.coll     = tauon.coll
		self.ddt:      TDraw = tauon.ddt
		self.gui:     GuiVar = tauon.gui
		self.inp:      Input = tauon.inp
		self.fields:  Fields = tauon.fields
		self.t_window = tauon.t_window
		self.renderer = tauon.renderer
		self.text: str = ""
		self.cursor_position = 0
		self.selection = 0
		self.offset = 0
		self.down_lock: bool = False
		self.paste_text: str = ""

	def paste(self) -> None:
		if sdl3.SDL_HasClipboardText():
			clip = sdl3.SDL_GetClipboardText().decode("utf-8")
			self.paste_text = clip

	def copy(self) -> None:
		text = self.get_selection()
		if not text:
			text = self.text
		if text:
			sdl3.SDL_SetClipboardText(text.encode("utf-8"))

	def set_text(self, text: str) -> None:
		self.text = text
		if self.cursor_position > len(text):
			self.cursor_position = 0
			self.selection = 0
		else:
			self.selection = self.cursor_position

	def clear(self) -> None:
		self.text = ""
		#self.cursor_position = 0
		self.selection = self.cursor_position

	def highlight_all(self) -> None:
		self.selection = len(self.text)
		self.cursor_position = 0

	def eliminate_selection(self) -> None:
		if self.selection != self.cursor_position:
			if self.selection > self.cursor_position:
				self.text = self.text[0: len(self.text) - self.selection] + self.text[len(self.text) - self.cursor_position:]
				self.selection = self.cursor_position
			else:
				self.text = self.text[0: len(self.text) - self.cursor_position] + self.text[len(self.text) - self.selection:]
				self.cursor_position = self.selection

	def get_selection(self, p: int = 1) -> str | None:
		if self.selection != self.cursor_position:
			if p == 1:
				if self.selection > self.cursor_position:
					return self.text[len(self.text) - self.selection: len(self.text) - self.cursor_position]

				return self.text[len(self.text) - self.cursor_position: len(self.text) - self.selection]
			if p == 0:
				return self.text[0: len(self.text) - max(self.cursor_position, self.selection)]
			if p == 2:
				return self.text[len(self.text) - min(self.cursor_position, self.selection):]
			return None
		return ""

	def draw(
			self, x: int, y: int, colour: ColourRGBA, active: bool = True, secret: bool = False, font: int = 13, width: int = 0, click: bool = False, selection_height: int = 18, big: bool = False) -> None:

		# A little bit messy
		# For now, this is set up so where 'width' is set > 0, the cursor position becomes editable,
		# otherwise it is fixed to end
		previous_target = sdl3.SDL_GetRenderTarget(self.renderer)
		sdl3.SDL_SetRenderTarget(self.renderer, self.tauon.text_box_canvas)
		sdl3.SDL_SetRenderDrawBlendMode(self.renderer, sdl3.SDL_BLENDMODE_NONE)
		sdl3.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 0)

		self.tauon.text_box_canvas_rect.x = 0
		self.tauon.text_box_canvas_rect.y = 0
		sdl3.SDL_RenderFillRect(self.renderer, self.tauon.text_box_canvas_rect)

		sdl3.SDL_SetRenderDrawBlendMode(self.renderer, sdl3.SDL_BLENDMODE_BLEND)

		selection_height *= self.gui.scale

		if click is False:
			click = self.inp.mouse_click
		if self.inp.mouse_down:
			self.gui.update = 2  # TODO(Taiko): more elegant fix

		rect = (x - 3, y - 2, width - 3, 21 * self.gui.scale)
		select_rect = (x - 20 * self.gui.scale, y - 2, width + 20 * self.gui.scale, 21 * self.gui.scale)

		self.fields.add(rect)

		# Activate Menu
		if self.coll(rect) and (self.inp.right_click or self.inp.level_2_right_click):
			self.tauon.field_menu.activate(self)

		if width > 0 and active:
			if click and self.tauon.field_menu.active:
				# field_menu.click()
				click = False

			# Add text from input
			if self.inp.input_text:
				self.eliminate_selection()
				self.text = self.text[0: len(self.text) - self.cursor_position] + self.inp.input_text + self.text[len(
					self.text) - self.cursor_position:]

			def g() -> str | None:
				if len(self.text) == 0 or self.cursor_position == len(self.text):
					return None
				return self.text[len(self.text) - self.cursor_position - 1]

			def g2() -> str | None:
				if len(self.text) == 0 or self.cursor_position == 0:
					return None
				return self.text[len(self.text) - self.cursor_position]

			def d() -> None:
				self.text = self.text[0: len(self.text) - self.cursor_position - 1] + self.text[len(
					self.text) - self.cursor_position:]
				self.selection = self.cursor_position

			# Ctrl + Backspace to delete word
			if self.inp.backspace_press and (self.inp.key_ctrl_down or self.inp.key_rctrl_down) and \
					self.cursor_position == self.selection and len(self.text) > 0 and self.cursor_position < len(
				self.text):
				while g() == " ":
					d()
				while g() != " " and g() is not None:
					d()

			# Ctrl + left to move cursor back a word
			elif (self.inp.key_ctrl_down or self.inp.key_rctrl_down) and self.inp.key_left_press:
				while g() == " ":
					self.cursor_position += 1
					if not self.inp.key_shift_down:
						self.selection = self.cursor_position
				while g() is not None and g() not in " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~":
					self.cursor_position += 1
					if not self.inp.key_shift_down:
						self.selection = self.cursor_position
					if g() == " ":
						self.cursor_position -= 1
						if not self.inp.key_shift_down:
							self.selection = self.cursor_position
						break

			# Ctrl + right to move cursor forward a word
			elif (self.inp.key_ctrl_down or self.inp.key_rctrl_down) and self.inp.key_right_press:
				while g2() == " ":
					self.cursor_position -= 1
					if not self.inp.key_shift_down:
						self.selection = self.cursor_position
				while g2() is not None and g2() not in " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~":
					self.cursor_position -= 1
					if not self.inp.key_shift_down:
						self.selection = self.cursor_position
					if g2() == " ":
						self.cursor_position += 1
						if not self.inp.key_shift_down:
							self.selection = self.cursor_position
						break

			# Handle normal backspace
			elif self.inp.backspace_press and len(self.text) > 0 and self.cursor_position < len(self.text):
				while self.inp.backspace_press and len(self.text) > 0 and self.cursor_position < len(self.text):
					if self.selection != self.cursor_position:
						self.eliminate_selection()
					else:
						self.text = self.text[0:len(self.text) - self.cursor_position - 1] + self.text[len(
							self.text) - self.cursor_position:]
					self.inp.backspace_press -= 1
			elif self.inp.backspace_press and len(self.get_selection()) > 0:
				self.eliminate_selection()

			# Left and right arrow keys to move cursor
			if self.inp.key_right_press:
				if self.cursor_position > 0:
					self.cursor_position -= 1
				if not self.inp.key_shift_down and not self.inp.key_shiftr_down:
					self.selection = self.cursor_position

			if self.inp.key_left_press:
				if self.cursor_position < len(self.text):
					self.cursor_position += 1
				if not self.inp.key_shift_down and not self.inp.key_shiftr_down:
					self.selection = self.cursor_position

			if self.paste_text:
				if "http://" in self.text and "http://" in self.paste_text:
					self.text = ""

				self.paste_text = self.paste_text.rstrip(" ").lstrip(" ")
				self.paste_text = self.paste_text.replace("\n", " ").replace("\r", "")

				self.eliminate_selection()
				self.text = self.text[0: len(self.text) - self.cursor_position] + self.paste_text + self.text[len(
					self.text) - self.cursor_position:]
				self.paste_text = ""

			# Paste via ctrl-v
			if self.inp.key_ctrl_down and self.inp.key_v_press:
				clip = sdl3.SDL_GetClipboardText().decode("utf-8")
				self.eliminate_selection()
				self.text = self.text[0: len(self.text) - self.cursor_position] + clip + self.text[len(
					self.text) - self.cursor_position:]

			if self.inp.key_ctrl_down and self.inp.key_c_press:
				self.copy()

			if self.inp.key_ctrl_down and self.inp.key_x_press and len(self.get_selection()) > 0:
				text = self.get_selection()
				if text:
					sdl3.SDL_SetClipboardText(text.encode("utf-8"))
				self.eliminate_selection()

			if self.inp.key_ctrl_down and self.inp.key_a_press:
				self.cursor_position = 0
				self.selection = len(self.text)

			# self.ddt.rect(rect, [255, 50, 50, 80], True)
			if self.coll(rect) and not self.tauon.field_menu.active:
				self.gui.cursor_want = 2

			# Delete key to remove text in front of cursor
			if self.inp.key_del:
				if self.selection != self.cursor_position:
					self.eliminate_selection()
				else:
					self.text = self.text[0:len(self.text) - self.cursor_position] + self.text[len(
						self.text) - self.cursor_position + 1:]
					if self.cursor_position > 0:
						self.cursor_position -= 1
					self.selection = self.cursor_position

			if self.inp.key_home_press:
				self.cursor_position = len(self.text)
				if not self.inp.key_shift_down and not self.inp.key_shiftr_down:
					self.selection = self.cursor_position
			if self.inp.key_end_press:
				self.cursor_position = 0
				if not self.inp.key_shift_down and not self.inp.key_shiftr_down:
					self.selection = self.cursor_position

			width -= round(15 * self.gui.scale)
			t_len = self.ddt.get_text_w(self.text, font)
			if active and self.gui.editline and self.gui.editline != self.inp.input_text:
				t_len += self.ddt.get_text_w(self.gui.editline, font)
			if not click and not self.down_lock:
				cursor_x = self.ddt.get_text_w(self.text[:len(self.text) - self.cursor_position], font)
				margin = round(15 * self.gui.scale)
				max_offset = max(t_len - width, 0)
				if self.cursor_position == len(self.text):
					self.offset = 0
				elif self.cursor_position == 0:
					self.offset = max_offset
				elif cursor_x < self.offset + margin:
					self.offset = max(cursor_x - margin, 0)
				elif cursor_x > self.offset + width:
					self.offset = min(cursor_x - width, max_offset)
				else:
					self.offset = min(max(self.offset, 0), max_offset)

			x -= self.offset

			if self.coll(select_rect):  # self.coll((x - 15, y, width + 16, selection_height + 1)):
				# ddt.rect_r((x - 15, y, width + 16, 19), [50, 255, 50, 50], True)
				if click:
					pre = 0
					post = 0
					if self.inp.mouse_position[0] < x + 1:
						self.cursor_position = len(self.text)
					else:
						for i in range(len(self.text)):
							post = self.ddt.get_text_w(self.text[0:i + 1], font)
							# pre_half = int((post - pre) / 2)

							if x + pre - 0 <= self.inp.mouse_position[0] <= x + post + 0:
								diff = post - pre
								if self.inp.mouse_position[0] >= x + pre + int(diff / 2):
									self.cursor_position = len(self.text) - i - 1
								else:
									self.cursor_position = len(self.text) - i
								break
							pre = post
						else:
							self.cursor_position = 0
					self.selection = 0
					self.down_lock = True

			if self.inp.mouse_up:
				self.down_lock = False
			if self.down_lock:
				pre = 0
				post = 0
				text = self.text
				if secret:
					text = "●" * len(self.text)
				if self.inp.mouse_position[0] < x + 1:
					self.selection = len(text)
				else:

					for i in range(len(text)):
						post = self.ddt.get_text_w(text[0:i + 1], font)
						# pre_half = int((post - pre) / 2)

						if x + pre - 0 <= self.inp.mouse_position[0] <= x + post + 0:
							diff = post - pre

							if self.inp.mouse_position[0] >= x + pre + int(diff / 2):
								self.selection = len(text) - i - 1

							else:
								self.selection = len(text) - i

							break
						pre = post

					else:
						self.selection = 0

			text = self.text[0: len(self.text) - self.cursor_position]
			if secret:
				text = "●" * len(text)
			a = self.ddt.get_text_w(text, font)

			text = self.text[0: len(self.text) - self.selection]
			if secret:
				text = "●" * len(text)
			b = self.ddt.get_text_w(text, font)

			top = y
			if big:
				top -= 12 * self.gui.scale

			self.ddt.rect([a, 0, b - a, selection_height], ColourRGBA(40, 120, 180, 255))

			if self.selection != self.cursor_position:
				inf_comp = 0
				text = self.get_selection(0)
				if secret:
					text = "●" * len(text)
				space = self.ddt.text((0, 0), text, colour, font)
				text = self.get_selection(1)
				if secret:
					text = "●" * len(text)
				space += self.ddt.text((0 + space - inf_comp, 0), text, ColourRGBA(240, 240, 240, 255), font, bg=ColourRGBA(40, 120, 180, 255))
				text = self.get_selection(2)
				if secret:
					text = "●" * len(text)
				self.ddt.text((0 + space - (inf_comp * 2), 0), text, colour, font)
			else:
				text = self.text
				if secret:
					text = "●" * len(text)
				self.ddt.text((0, 0), text, colour, font)

			text = self.text[0: len(self.text) - self.cursor_position]
			if secret:
				text = "●" * len(text)
			space = self.ddt.get_text_w(text, font)

			if TextBox.cursor and self.selection == self.cursor_position:
				# ddt.line(x + space, y + 2, x + space, y + 15, colour)
				self.ddt.rect((0 + space, 0 + 2, 1 * self.gui.scale, 14 * self.gui.scale), colour)

			if click:
				self.selection = self.cursor_position
		else:
			width -= round(15 * self.gui.scale)
			text = self.text
			if secret:
				text = "●" * len(text)
			t_len = self.ddt.get_text_w(text, font)
			self.ddt.text((0, 0), text, colour, font)
			self.offset = 0
			if self.coll(rect) and not self.tauon.field_menu.active:
				self.gui.cursor_want = 2

		if active:
			tw, th = self.ddt.get_text_wh(self.gui.editline, font, max_x=2000)
			if self.gui.editline not in ("", self.inp.input_text):
				ex = self.ddt.text((space + round(4 * self.gui.scale), 0), self.gui.editline, ColourRGBA(240, 230, 230, 255), font)
				self.ddt.rect((space + round(4 * self.gui.scale), th + round(2 * self.gui.scale), ex, round(1 * self.gui.scale)), ColourRGBA(245, 245, 245, 255))

			pixel_to_logical = self.tauon.pixel_to_logical
			rect = sdl3.SDL_Rect(pixel_to_logical(x), pixel_to_logical(y), pixel_to_logical(tw), pixel_to_logical(th))
			sdl3.SDL_SetTextInputArea(self.t_window, rect, pixel_to_logical(space))

		self.tauon.animate_monitor_timer.set()

		self.tauon.text_box_canvas_hide_rect.x = 0
		self.tauon.text_box_canvas_hide_rect.y = 0

		# if self.offset:
		sdl3.SDL_SetRenderDrawBlendMode(self.renderer, sdl3.SDL_BLENDMODE_NONE)

		self.tauon.text_box_canvas_hide_rect.w = round(self.offset)
		sdl3.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 0)
		sdl3.SDL_RenderFillRect(self.renderer, self.tauon.text_box_canvas_hide_rect)

		self.tauon.text_box_canvas_hide_rect.w = round(t_len)
		self.tauon.text_box_canvas_hide_rect.x = round(self.offset + width + round(5 * self.gui.scale))
		sdl3.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 0)
		sdl3.SDL_RenderFillRect(self.renderer, self.tauon.text_box_canvas_hide_rect)

		sdl3.SDL_SetRenderDrawBlendMode(self.renderer, sdl3.SDL_BLENDMODE_BLEND)
		sdl3.SDL_SetRenderTarget(self.renderer, previous_target)

		self.tauon.text_box_canvas_rect.x = round(x)
		self.tauon.text_box_canvas_rect.y = round(y)
		sdl3.SDL_RenderTexture(self.renderer, self.tauon.text_box_canvas, None, self.tauon.text_box_canvas_rect)

class TextBox:
	# TODO(Martin): Global class var!
	cursor = True

	def __init__(self, tauon: Tauon) -> None:
		self.tauon:   Tauon = tauon
		self.ddt:     TDraw = tauon.ddt
		self.gui:    GuiVar = tauon.gui
		self.inp:     Input = tauon.inp
		self.coll           = tauon.coll
		self.fields: Fields = tauon.fields
		self.t_window       = tauon.t_window
		self.renderer       = tauon.renderer
		self.text: str = ""
		self.cursor_position = 0
		self.selection = 0
		self.down_lock: bool = False

	def paste(self) -> None:
		if sdl3.SDL_HasClipboardText():
			clip = sdl3.SDL_GetClipboardText().decode("utf-8")

			if "http://" in self.text and "http://" in clip:
				self.text = ""

			clip = clip.rstrip(" ").lstrip(" ")
			clip = clip.replace("\n", " ").replace("\r", "")

			self.eliminate_selection()
			self.text = self.text[0: len(self.text) - self.cursor_position] + clip + self.text[len(
				self.text) - self.cursor_position:]

	def copy(self) -> None:
		text = self.get_selection()
		if not text:
			text = self.text
		if text:
			sdl3.SDL_SetClipboardText(text.encode("utf-8"))

	def set_text(self, text: str) -> None:
		self.text = text
		self.cursor_position = 0
		self.selection = 0

	def clear(self) -> None:
		self.text = ""

	def highlight_all(self) -> None:
		self.selection = len(self.text)
		self.cursor_position = 0

	def highlight_none(self) -> None:
		self.selection = 0
		self.cursor_position = 0

	def eliminate_selection(self) -> None:
		if self.selection != self.cursor_position:
			if self.selection > self.cursor_position:
				self.text = self.text[0: len(self.text) - self.selection] + self.text[
					len(self.text) - self.cursor_position:]
				self.selection = self.cursor_position
			else:
				self.text = self.text[0: len(self.text) - self.cursor_position] + self.text[
					len(self.text) - self.selection:]
				self.cursor_position = self.selection

	def get_selection(self, p: int = 1):
		if self.selection != self.cursor_position:
			if p == 1:
				if self.selection > self.cursor_position:
					return self.text[len(self.text) - self.selection: len(self.text) - self.cursor_position]

				return self.text[len(self.text) - self.cursor_position: len(self.text) - self.selection]
			if p == 0:
				return self.text[0: len(self.text) - max(self.cursor_position, self.selection)]
			if p == 2:
				return self.text[len(self.text) - min(self.cursor_position, self.selection):]
		else:
			return ""
		return None

	def draw(
		self, x: int, y: int, colour: list[int], active: bool = True, secret: bool = False,
		font: int = 13, width: int = 0, click: bool = False, selection_height: float = 18, big: bool = False) -> None:
		inp = self.inp
		ddt = self.ddt
		gui = self.gui

		# A little bit messy
		# For now, this is set up so where 'width' is set > 0, the cursor position becomes editable,
		# otherwise it is fixed to end

		selection_height *= self.gui.scale

		if click is False:
			click = self.inp.mouse_click

		if width > 0 and active:

			rect = (x - 3, y - 2, width - 3, 21 * gui.scale)
			select_rect = (x - 20 * gui.scale, y - 2, width + 20 * gui.scale, 21 * gui.scale)
			if big:
				rect = (x - 3, y - 15 * gui.scale, width - 3, 35 * gui.scale)
				select_rect = (x - 50 * gui.scale, y - 15 * gui.scale, width + 50 * gui.scale, 35 * gui.scale)

			# Activate Menu
			if self.coll(rect) and (inp.right_click or inp.level_2_right_click):
				self.tauon.field_menu.activate(self)

			if click and self.tauon.field_menu.active:
				# field_menu.click()
				click = False

			# Add text from input
			if self.inp.input_text:
				self.eliminate_selection()
				self.text = self.text[0: len(self.text) - self.cursor_position] + self.inp.input_text + self.text[
					len(self.text) - self.cursor_position:]

			def g() -> str | None:
				if len(self.text) == 0 or self.cursor_position == len(self.text):
					return None
				return self.text[len(self.text) - self.cursor_position - 1]

			def g2() -> str | None:
				if len(self.text) == 0 or self.cursor_position == 0:
					return None
				return self.text[len(self.text) - self.cursor_position]

			def d() -> None:
				self.text = self.text[0: len(self.text) - self.cursor_position - 1] + self.text[
					len(self.text) - self.cursor_position:]
				self.selection = self.cursor_position

			# Ctrl + Backspace to delete word
			if inp.backspace_press and (inp.key_ctrl_down or inp.key_rctrl_down) and \
					self.cursor_position == self.selection and len(self.text) > 0 and self.cursor_position < len(
				self.text):
				while g() == " ":
					d()
				while g() != " " and g() is not None:
					d()

			# Ctrl + left to move cursor back a word
			elif (inp.key_ctrl_down or inp.key_rctrl_down) and inp.key_left_press:
				while g() == " ":
					self.cursor_position += 1
					if not inp.key_shift_down:
						self.selection = self.cursor_position
				while g() is not None and g() not in " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~":
					self.cursor_position += 1
					if not inp.key_shift_down:
						self.selection = self.cursor_position
					if g() == " ":
						self.cursor_position -= 1
						if not inp.key_shift_down:
							self.selection = self.cursor_position
						break

			# Ctrl + right to move cursor forward a word
			elif (inp.key_ctrl_down or inp.key_rctrl_down) and inp.key_right_press:
				while g2() == " ":
					self.cursor_position -= 1
					if not inp.key_shift_down:
						self.selection = self.cursor_position
				while g2() is not None and g2() not in " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~":
					self.cursor_position -= 1
					if not inp.key_shift_down:
						self.selection = self.cursor_position
					if g2() == " ":
						self.cursor_position += 1
						if not inp.key_shift_down:
							self.selection = self.cursor_position
						break

			# Handle normal backspace
			elif inp.backspace_press and len(self.text) > 0 and self.cursor_position < len(self.text):
				while inp.backspace_press and len(self.text) > 0 and self.cursor_position < len(self.text):
					if self.selection != self.cursor_position:
						self.eliminate_selection()
					else:
						self.text = self.text[0:len(self.text) - self.cursor_position - 1] + self.text[
							len(self.text) - self.cursor_position:]
					inp.backspace_press -= 1
			elif inp.backspace_press and len(self.get_selection()) > 0:
				self.eliminate_selection()

			# Left and right arrow keys to move cursor
			if inp.key_right_press:
				if self.cursor_position > 0:
					self.cursor_position -= 1
				if not inp.key_shift_down and not inp.key_shiftr_down:
					self.selection = self.cursor_position

			if inp.key_left_press:
				if self.cursor_position < len(self.text):
					self.cursor_position += 1
				if not inp.key_shift_down and not inp.key_shiftr_down:
					self.selection = self.cursor_position

			# Paste via ctrl-v
			if inp.key_ctrl_down and inp.key_v_press:
				clip = sdl3.SDL_GetClipboardText().decode("utf-8")
				self.eliminate_selection()
				self.text = self.text[0: len(self.text) - self.cursor_position] + clip + self.text[len(
					self.text) - self.cursor_position:]

			if inp.key_ctrl_down and inp.key_c_press:
				self.copy()

			if inp.key_ctrl_down and inp.key_x_press and len(self.get_selection()) > 0:
				text = self.get_selection()
				if text:
					sdl3.SDL_SetClipboardText(text.encode("utf-8"))
				self.eliminate_selection()

			if inp.key_ctrl_down and inp.key_a_press:
				self.cursor_position = 0
				self.selection = len(self.text)

			# ddt.rect_r(rect, [255, 50, 50, 80], True)
			if self.coll(rect) and not self.tauon.field_menu.active:
				gui.cursor_want = 2

			self.fields.add(rect)

			# Delete key to remove text in front of cursor
			if inp.key_del:
				if self.selection != self.cursor_position:
					self.eliminate_selection()
				else:
					self.text = self.text[0:len(self.text) - self.cursor_position] + self.text[len(
						self.text) - self.cursor_position + 1:]
					if self.cursor_position > 0:
						self.cursor_position -= 1
					self.selection = self.cursor_position

			if inp.key_home_press:
				self.cursor_position = len(self.text)
				if not inp.key_shift_down and not inp.key_shiftr_down:
					self.selection = self.cursor_position
			if inp.key_end_press:
				self.cursor_position = 0
				if not inp.key_shift_down and not inp.key_shiftr_down:
					self.selection = self.cursor_position

			if self.coll(select_rect):
				# ddt.rect_r((x - 15, y, width + 16, 19), [50, 255, 50, 50], True)
				if click:
					pre = 0
					post = 0
					if inp.mouse_position[0] < x + 1:
						self.cursor_position = len(self.text)
					else:
						for i in range(len(self.text)):
							post = ddt.get_text_w(self.text[0:i + 1], font)
							# pre_half = int((post - pre) / 2)

							if x + pre - 0 <= inp.mouse_position[0] <= x + post + 0:
								diff = post - pre
								if inp.mouse_position[0] >= x + pre + int(diff / 2):
									self.cursor_position = len(self.text) - i - 1
								else:
									self.cursor_position = len(self.text) - i
								break
							pre = post
						else:
							self.cursor_position = 0
					self.selection = 0
					self.down_lock = True

			if inp.mouse_up:
				self.down_lock = False
			if self.down_lock:
				pre = 0
				post = 0
				if inp.mouse_position[0] < x + 1:

					self.selection = len(self.text)
				else:

					for i in range(len(self.text)):
						post = ddt.get_text_w(self.text[0:i + 1], font)
						# pre_half = int((post - pre) / 2)

						if x + pre - 0 <= inp.mouse_position[0] <= x + post + 0:
							diff = post - pre

							if inp.mouse_position[0] >= x + pre + int(diff / 2):
								self.selection = len(self.text) - i - 1
							else:
								self.selection = len(self.text) - i

							break
						pre = post

					else:
						self.selection = 0

			a = ddt.get_text_w(self.text[0: len(self.text) - self.cursor_position], font)

			# logging.info("")
			# logging.info(self.selection)
			# logging.info(self.cursor_position)

			b = ddt.get_text_w(self.text[0: len(self.text) - self.selection], font)

			# rint((a, b))

			top = y
			if big:
				top -= 12 * gui.scale

			ddt.rect([x + a, top, b - a, selection_height], ColourRGBA(40, 120, 180, 255))

			if self.selection != self.cursor_position:
				inf_comp = 0
				space = ddt.text((x, y), self.get_selection(0), colour, font)
				space += ddt.text(
					(x + space - inf_comp, y), self.get_selection(1), ColourRGBA(240, 240, 240, 255), font,
					bg=ColourRGBA(40, 120, 180, 255))
				ddt.text((x + space - (inf_comp * 2), y), self.get_selection(2), colour, font)
			else:
				ddt.text((x, y), self.text, colour, font)

			space = ddt.get_text_w(self.text[0: len(self.text) - self.cursor_position], font)

			if TextBox.cursor and self.selection == self.cursor_position:
				# ddt.line(x + space, y + 2, x + space, y + 15, colour)

				if big:
					# ddt.rect_r((xx + 1 , yy - 12 * gui.scale, 2 * gui.scale, 27 * gui.scale), colour, True)
					ddt.rect((x + space, y - 15 * gui.scale + 2, 1 * gui.scale, 30 * gui.scale), colour)
				else:
					ddt.rect((x + space, y + 2, 1 * gui.scale, 14 * gui.scale), colour)

			if click:
				self.selection = self.cursor_position

		else:
			if active:
				self.text += self.inp.input_text
				if self.inp.input_text:
					self.cursor = True

				while inp.backspace_press and len(self.text) > 0:
					self.text = self.text[:-1]
					inp.backspace_press -= 1

				if inp.key_ctrl_down and inp.key_v_press:
					self.paste()

			if secret:
				space = ddt.text((x, y), "●" * len(self.text), colour, font)
			else:
				space = ddt.text((x, y), self.text, colour, font)

			if active and TextBox.cursor:
				xx = x + space + 1
				yy = y + 3
				if big:
					ddt.rect((xx + 1, yy - 12 * gui.scale, 2 * gui.scale, 27 * gui.scale), colour)
				else:
					ddt.rect((xx, yy, 1 * gui.scale, 14 * gui.scale), colour)

		if active:
			tw, th = ddt.get_text_wh(self.gui.editline, font, max_x=2000)
			if self.gui.editline not in ("", self.inp.input_text):
				ex = ddt.text((x + space + round(4 * gui.scale), y), self.gui.editline, ColourRGBA(240, 230, 230, 255), font)

				ddt.rect((x + space + round(4 * gui.scale), (y + th) - round(4 * gui.scale), ex, round(1 * gui.scale)),
					ColourRGBA(245, 245, 245, 255))

			pixel_to_logical = self.tauon.pixel_to_logical
			rect = sdl3.SDL_Rect(pixel_to_logical(x), pixel_to_logical(y), pixel_to_logical(tw), pixel_to_logical(th))
			sdl3.SDL_SetTextInputArea(self.t_window, rect, pixel_to_logical(space))

		self.tauon.animate_monitor_timer.set()

class ImageObject:
	def __init__(self) -> None:
		self.index = 0
		self.texture = None
		self.rect = None
		self.request_size = (0, 0)
		self.original_size = (0, 0)
		self.actual_size = (0, 0)
		self.source = ""
		self.offset = 0
		self.stats = True
		self.format = ""

class AlbumArt:
	def __init__(self, tauon: Tauon, style_overlay: StyleOverlay) -> None:
		self.tauon: Tauon                = tauon
		self.inp: Input                  = tauon.inp
		self.gui: GuiVar                  = tauon.gui
		self.ddt: TDraw                  = tauon.ddt
		self.pctl: PlayerCtl                 = tauon.pctl
		self.windows: bool                 = tauon.windows
		self.macos: bool                = tauon.macos
		self.prefs: Prefs                = tauon.prefs
		self.temp_dest: sdl3.SDL_FRect            = tauon.temp_dest
		self.a_cache_directory: Path    = tauon.dirs.a_cache_directory
		self.b_cache_directory: Path    = tauon.dirs.b_cache_directory
		self.style_overlay: StyleOverlay        = style_overlay
		self.colours: ColoursClass              = tauon.colours
		self.renderer             = tauon.renderer
		self.tls_context          = tauon.tls_context
		self.folder_image_offsets: dict[str, int] = tauon.folder_image_offsets
		self.install_directory: Path    = tauon.install_directory
		self.window_size: list[int]          = tauon.window_size
		self.cache_directory: Path      = tauon.cache_directory
		self.show_message = tauon.show_message
		self.image_types: set[str] = {"jpg", "JPG", "jpeg", "JPEG", "PNG", "png", "BMP", "bmp", "GIF", "gif", "jxl", "JXL"}
		self.art_folder_names: set[str] = {
			"art", "scans", "scan", "booklet", "images", "image", "cover",
			"covers", "coverart", "albumart", "gallery", "jacket", "artwork",
			"bonus", "bk", "cover artwork", "cover art"}
		self.source_cache: dict[int, list[tuple[int, str]]] = {}
		self.image_cache: list[ImageObject] = []

		self.blur_texture = None
		self.blur_rect = None
		self.loaded_bg_type: int = 0

		self.download_in_progress: bool = False
		self.downloaded_image = None
		self.downloaded_track = None

		# State for display(async_hold=True), see display_async()
		self.async_lock: threading.LockType = threading.Lock()
		self.async_loads: dict[tuple, set] = {}      # (source, offset) -> requested boxes. One disk load per art
		self.async_results: dict[tuple, tuple] = {}  # (source, offset, box) -> (BytesIO, original size, format, time)
		self.async_failed: dict[tuple, float] = {}   # (source, offset, box) -> failure time (displays as blank)
		self.caller_history: dict[str, ImageObject] = {}  # caller_id -> unit it last displayed (held during loads)
		self.net_art_failed: dict[str, float] = {}  # art_url_key -> monotonic time of last failed network fetch

		self.base64cache = (0, 0, "")
		self.processing64on = None

		self.bin_cached = (None, None, None)  # track, subsource, bin

		self.embed_cached = (None, None)

	def async_download_image(self, track: TrackClass, subsource: list[tuple[int, str]]) -> None:
		self.downloaded_image = self.get_source_raw(0, 0, track, subsource=subsource)
		self.downloaded_track = track
		self.download_in_progress = False
		self.gui.update += 1

	def get_info(self, track_object: TrackClass) -> list[tuple[int, int, int, int, str]] | None:
		sources = self.get_sources(track_object)
		if len(sources) == 0:
			return None

		offset = self.get_offset(track_object.fullpath, sources)

		o_size = (0, 0)
		format = "ERROR"

		for item in self.image_cache:
			if item.index == track_object.index and item.offset == offset:
				o_size = item.original_size
				format = item.format
				break

		else:
			# Hacky fix
			# A quirk is the index stays of the cached image
			# This workaround can be done since (currently) cache has max size of 1
			if self.image_cache:
				o_size = self.image_cache[0].original_size
				format = self.image_cache[0].format

		return [sources[offset][0], len(sources), offset, o_size, format]

	def get_sources(self, tr: TrackClass) -> list[tuple[int, str]]:
		filepath = tr.fullpath
		ext = tr.file_ext

		# Check if source list already exists, if not, make it
		if tr.index in self.source_cache:
			return self.source_cache[tr.index]

		source_list: list[tuple[int, str]] = []  # istag,

		# Source type the is first element in list
		# 0 = File
		# 1 = Embedded in tag
		# 2 = Network location

		if tr.is_network:
			# Add url if network target
			if tr.art_url_key:
				source_list.append([2, tr.art_url_key])
		else:
			# Check for local image files
			direc = os.path.dirname(filepath)
			try:
				items_in_dir = os.listdir(direc)
			except FileNotFoundError:
				logging.warning(f"Failed to find directory: {direc}")
				return []
			except Exception:
				logging.exception(f"Unknown error loading directory: {direc}")
				return []

		# Check for embedded image
		try:
			pic = self.get_embed(tr)
			if pic:
				source_list.append([1, filepath])
		except Exception:
			logging.exception("Failed to get embedded image")

		if not tr.is_network:

			dirs_in_dir = [
				subdirec for subdirec in items_in_dir if
				os.path.isdir(os.path.join(direc, subdirec)) and subdirec.lower() in self.art_folder_names]

			ins = len(source_list)
			for i in range(len(items_in_dir)):
				if os.path.splitext(items_in_dir[i])[1][1:] in self.image_types:
					dir_path = os.path.join(direc, items_in_dir[i]).replace("\\", "/")
					# The image name "Folder" is likely desired to be prioritised over other names
					if os.path.splitext(os.path.basename(dir_path))[0] in ("Folder", "folder", "Cover", "cover"):
						source_list.insert(ins, [0, dir_path])
					else:
						source_list.append([0, dir_path])

			for i in range(len(dirs_in_dir)):
				subdirec = os.path.join(direc, dirs_in_dir[i])
				items_in_dir2 = os.listdir(subdirec)

				for y in range(len(items_in_dir2)):
					if os.path.splitext(items_in_dir2[y])[1][1:] in self.image_types:
						dir_path = os.path.join(subdirec, items_in_dir2[y]).replace("\\", "/")
						source_list.append([0, dir_path])

		self.source_cache[tr.index] = source_list

		return source_list

	def get_error_img(self, size: float) -> ImageFile:
		im = Image.open(str(self.install_directory / "assets" / "load-error.png"))
		im.thumbnail((size, size), Image.Resampling.LANCZOS)
		return im

	def fast_display(self, index: int, location: list[int], box: tuple[int, int], source: list[tuple[int, str]], offset: int) -> int:
		"""Renders cached image only by given size for faster performance"""
		found_unit = None
		max_h = 0

		for unit in self.image_cache:
			if unit.source == source[offset][1] and unit.actual_size[1] > max_h:
				max_h = unit.actual_size[1]
				found_unit = unit

		if found_unit is None:
			return 1

		self.render_fit(found_unit, location, box)
		return 0

	def render_fit(self, unit: ImageObject, location: list[int], box: tuple[int, int]) -> None:
		"""Render a cached unit scaled to fit the given box.

		Unlike render(), this handles a unit that was made for a different box
		size, e.g. an async hold after a resize."""
		self.temp_dest.x = round(location[0])
		self.temp_dest.y = round(location[1])

		self.temp_dest.w = unit.original_size[0]  # round(box[0])
		self.temp_dest.h = unit.original_size[1]  # round(box[1])

		bh = round(box[1])
		bw = round(box[0])

		if self.prefs.zoom_art:
			self.temp_dest.w, self.temp_dest.h = fit_box((unit.original_size[0], unit.original_size[1]), box)
		else:
			# Constrain image to given box
			if self.temp_dest.w > bw:
				self.temp_dest.w = bw
				self.temp_dest.h = int(bw * (unit.original_size[1] / unit.original_size[0]))

			if self.temp_dest.h > bh:
				self.temp_dest.h = bh
				self.temp_dest.w = int(self.temp_dest.h * (unit.original_size[0] / unit.original_size[1]))

			# prevent scaling larger than original image size
			if self.temp_dest.w > unit.original_size[0] or self.temp_dest.h > unit.original_size[1]:
				self.temp_dest.w = unit.original_size[0]
				self.temp_dest.h = unit.original_size[1]

		# center the image
		self.temp_dest.x = int((box[0] - self.temp_dest.w) / 2) + self.temp_dest.x
		self.temp_dest.y = int((box[1] - self.temp_dest.h) / 2) + self.temp_dest.y

		# render the image
		sdl3.SDL_RenderTexture(self.renderer, unit.texture, None, self.temp_dest)
		self.style_overlay.hole_punches.append(self.temp_dest)

		self.gui.art_drawn_rect = (self.temp_dest.x, self.temp_dest.y, self.temp_dest.w, self.temp_dest.h)

	def open_external(self, track_object: TrackClass) -> int:
		index = track_object.index

		source = self.get_sources(track_object)
		if len(source) == 0:
			return 0

		offset = self.get_offset(track_object.fullpath, source)

		if track_object.is_network:
			self.show_message(_("Saving network images not implemented"))
			return 0
		if source[offset][0] > 0:
			pic = self.get_embed(track_object)
			if not pic:
				self.show_message(_("Image save error."), _("No embedded album art."), mode="warning")
				return 0

			source_image = io.BytesIO(pic)
			im = Image.open(source_image)
			source_image.close()

			ext = "." + im.format.lower()
			if im.format == "JPEG":
				ext = ".jpg"
			target = str(self.cache_directory / "open-image")
			if not os.path.exists(target):
				os.makedirs(target)
			target = os.path.join(target, "embed-" + str(im.height) + "px-" + str(track_object.index) + ext)

			if len(pic) > 30:
				with open(target, "wb") as w:
					w.write(pic)

		else:
			target = source[offset][1]

		if self.windows:
			os.startfile(target)
		elif self.macos:
			subprocess.call(["open", target])
		else:
			subprocess.call(["xdg-open", target])

		return 0

	def cycle_offset(self, track_object: TrackClass, reverse: bool = False) -> int:
		filepath = track_object.fullpath
		sources = self.get_sources(track_object)
		if len(sources) == 0:
			return 0
		parent_folder = os.path.dirname(filepath)
		# Find cached offset
		if parent_folder in self.folder_image_offsets:

			if reverse:
				self.folder_image_offsets[parent_folder] -= 1
			else:
				self.folder_image_offsets[parent_folder] += 1

			self.folder_image_offsets[parent_folder] %= len(sources)
		return 0

	def cycle_offset_reverse(self, track_object: TrackClass) -> None:
		self.cycle_offset(track_object, True)

	def get_offset(self, filepath: str, source: list[tuple[int, str]]) -> int:
		# Check if folder offset already exists, if not, make it
		parent_folder = os.path.dirname(filepath)

		if parent_folder in self.folder_image_offsets:
			# Reset the offset if greater than number of images available
			if self.folder_image_offsets[parent_folder] > len(source) - 1:
				self.folder_image_offsets[parent_folder] = 0
		else:
			self.folder_image_offsets[parent_folder] = 0

		return self.folder_image_offsets[parent_folder]

	def get_embed(self, track: TrackClass):
		# cached = self.embed_cached
		# if cached[0] == track:
		#	#logging.info("used cached")
		#	return cached[1]

		filepath = track.fullpath

		# Use cached file if present
		if self.prefs.precache and self.tauon.cachement:
			path = self.tauon.cachement.get_file_cached_only(track)
			if path:
				filepath = path

		pic = None

		if track.file_ext == "MP3":
			try:
				tag = mutagen.id3.ID3(filepath)
				frame = tag.getall("APIC")
				if frame:
					pic = frame[0].data
			except Exception:
				logging.exception(f"Failed to get tags on file: {filepath}")

			if pic is not None and len(pic) < 30:
				pic = None
		elif track.file_ext == "FLAC":
			with Flac(filepath) as tag:
				tag.read(True)
				if tag.has_picture and len(tag.picture) > 30:
					pic = tag.picture
		elif track.file_ext == "APE":
			with Ape(filepath) as tag:
				tag.read()
				if tag.has_picture and len(tag.picture) > 30:
					pic = tag.picture
		elif track.file_ext == "M4A":
			with M4a(filepath) as tag:
				tag.read(True)
				if tag.has_picture and len(tag.picture) > 30:
					pic = tag.picture
		elif track.file_ext in ("OPUS", "OGG", "OGA"):
			with Opus(filepath) as tag:
				tag.read()
				if tag.has_picture and len(tag.picture) > 30:
					with io.BytesIO(base64.b64decode(tag.picture)) as a:
						a.seek(0)
						image = parse_picture_block(a)
					pic = image

		# self.embed_cached = (track, pic)
		return pic

	def get_source_raw(self, offset: int, sources: list[tuple[int, str]] | int, track: TrackClass, subsource: list[tuple[int, str]] | None = None) -> BytesIO | BufferedReader[_BufferedReaderStream] | None:
		"""Caller has to call .close() on the returned object afterwards"""
		source_image = None

		if subsource is None:
			subsource = sources[offset]

		if subsource[0] == 1:
			# Target is a embedded image\\\
			pic = self.get_embed(track)
			assert pic
			source_image = io.BytesIO(pic)
		elif subsource[0] == 2:
			fetching = False
			try:
				if track.file_ext == "RADIO" and self.pctl.radio_image_bin:
					return self.pctl.radio_image_bin

				cached_path = os.path.join(self.tauon.n_cache_directory, hashlib.md5(track.art_url_key.encode()).hexdigest()[:12])
				if os.path.isfile(cached_path):
					source_image = open(cached_path, "rb")
				else:
					# Negative cache: many thumbnail sizes can queue up for the same
					# art, so a dead server would otherwise be hit once per size
					last_fail = self.net_art_failed.get(track.art_url_key)
					if last_fail is not None and time.monotonic() - last_fail < 120:
						return None
					fetching = True
					if track.file_ext == "SUB":
						source_image = self.tauon.subsonic.get_cover(track)
					elif track.file_ext == "JELY":
						source_image = self.tauon.jellyfin.get_cover(track)
					else:
						response = urllib.request.urlopen(self.tauon.get_network_thumbnail_url(track), context=self.tls_context)
						source_image = io.BytesIO(response.read())
					if source_image:
						with Path(cached_path).open("wb") as file:
							file.write(source_image.read())
						source_image.seek(0)
						self.net_art_failed.pop(track.art_url_key, None)
					else:
						self.net_art_failed[track.art_url_key] = time.monotonic()
			except Exception:
				if fetching:
					self.net_art_failed[track.art_url_key] = time.monotonic()
				logging.exception("Failed to get source")
		else:
			source_image = open(subsource[1], "rb")

		return source_image

	def get_base64(self, track: TrackClass, size):
		# Wait if an identical track is already being processed
		if self.processing64on == track:
			t = 0
			while True:
				if self.processing64on is None:
					break
				time.sleep(0.05)
				t += 1
				if t > 20:
					break

		cached = self.base64cache
		if track == cached[0] and size == cached[1]:
			return cached[2]

		self.processing64on = track

		filepath = track.fullpath
		sources = self.get_sources(track)

		if len(sources) == 0:
			self.processing64on = None
			return False

		offset = self.get_offset(filepath, sources)

		# Get source IO
		source_image = self.get_source_raw(offset, sources, track)

		if source_image is None:
			self.processing64on = None
			return ""

		im = Image.open(source_image)
		if im.mode != "RGB":
			im = im.convert("RGB")
		im.thumbnail(size, Image.Resampling.LANCZOS)
		buff = io.BytesIO()
		im.save(buff, format="JPEG")
		sss = base64.b64encode(buff.getvalue())

		self.base64cache = (track, size, sss)
		self.processing64on = None
		return sss

	def get_background(self, track: TrackClass) -> BytesIO | BufferedReader | None:
		#logging.info("Find background...")
		# Determine artist name to use
		artist = get_artist_safe(track)
		if not artist:
			return None

		# Check cache for existing image
		path = os.path.join(self.b_cache_directory, artist)
		if os.path.isfile(path):
			logging.info("Load cached background")
			return open(path, "rb")

		# Try last.fm background
		path = self.tauon.artist_info_box.get_data(artist, get_img_path=True)
		if os.path.isfile(path):
			logging.info("Load cached background lfm")
			return open(path, "rb")

		# Check we've not already attempted a search for this artist
		if artist in self.prefs.failed_background_artists:
			return None

		# Get artist MBID
		try:
			s = musicbrainzngs.search_artists(artist, limit=1)
			artist_id = s["artist-list"][0]["id"]
		except Exception:
			logging.exception(f"Failed to find artist MBID for: {artist}")
			self.prefs.failed_background_artists.append(artist)
			return None

		# Search fanart.tv for background
		try:
			r = requests.get(
				"https://webservice.fanart.tv/v3/music/" \
				+ artist_id + "?api_key=" + self.prefs.fatvap, timeout=(4, 10))

			artlink = r.json()["artistbackground"][0]["url"]

			response = urllib.request.urlopen(artlink, context=self.tls_context)
			info = response.info()

			assert info.get_content_maintype() == "image"

			t = io.BytesIO()
			t.seek(0)
			t.write(response.read())
			t.seek(0, 2)
			l = t.tell()
			t.seek(0)

			assert l > 1000

			# Cache image for future use
			path = os.path.join(self.a_cache_directory, artist + "-ftv-full.jpg")
			with open(path, "wb") as f:
				f.write(t.read())
			t.seek(0)
			return t

		except Exception:
			logging.exception(f"Failed to find fanart background for: {artist}")
			if not self.gui.artist_info_panel:
				self.tauon.artist_info_box.get_data(artist)
				path = self.tauon.artist_info_box.get_data(artist, get_img_path=True)
				if os.path.isfile(path):
					logging.debug("Downloaded background lfm")
					return open(path, "rb")


			self.prefs.failed_background_artists.append(artist)
			return None

	def get_blur_im(self, track: TrackClass) -> BytesIO | bool | None:
		source_image = None
		self.loaded_bg_type = 0
		if self.prefs.enable_fanart_bg:
			source_image = self.get_background(track)
			if source_image:
				self.loaded_bg_type = 1

		if source_image is None:
			filepath = track.fullpath
			sources = self.get_sources(track)

			if len(sources) == 0:
				return False

			offset = self.get_offset(filepath, sources)

			source_image = self.get_source_raw(offset, sources, track)

		if source_image is None:
			return None

		im = Image.open(source_image)

		ox_size = im.size[0]
		oy_size = im.size[1]

		format = im.format
		if im.format == "JPEG":
			format = "JPG"

		#logging.info(im.size)
		if im.mode != "RGB":
			im = im.convert("RGB")

		ratio = self.window_size[0] / ox_size
		ratio += 0.2

		if (oy_size * ratio) - ((oy_size * ratio) // 4) < self.window_size[1]:
			logging.info("Adjust bg vertical")
			ratio = self.window_size[1] / (oy_size - (oy_size // 4))
			ratio += 0.2

		new_x = round(ox_size * ratio)
		new_y = round(oy_size * ratio)

		im = im.resize((new_x, new_y))

		if self.loaded_bg_type == 1:
			artist = get_artist_safe(track)
			if artist and artist in self.prefs.bg_flips:
				im = im.transpose(Image.FLIP_LEFT_RIGHT)

		if (ox_size < 500 or self.prefs.art_bg_always_blur) or self.gui.mode == GuiMode.MINI:
			blur = self.prefs.art_bg_blur
			if self.prefs.mini_mode_mode == MiniModeMode.SLATE and self.gui.mode == GuiMode.MINI:
				blur = 160
				pix = im.getpixel((new_x // 2, new_y // 4 * 3))
				pixel_sum = sum(pix) / (255 * 3)
				if pixel_sum > 0.6:
					enhancer = ImageEnhance.Brightness(im)
					deduct = 1 - ((pixel_sum - 0.6) * 1.5)
					im = enhancer.enhance(deduct)
					logging.info(deduct)

				self.gui.center_blur_pixel = im.getpixel((new_x // 2, new_y // 4 * 3))

			im = im.filter(ImageFilter.GaussianBlur(blur))


		self.gui.center_blur_pixel = im.getpixel((new_x // 2, new_y // 2))

		g = io.BytesIO()
		g.seek(0)

		a_channel = Image.new("L", im.size, 255)  # 'L' 8-bit pixels, black and white
		im.putalpha(a_channel)

		im.save(g, "PNG")
		g.seek(0)

		# source_image.close()

		return g

	def save_thumb(self, track_object: TrackClass, size: tuple[int, int], save_path: str | None, png: bool = False, zoom: bool = False) -> BytesIO | bool | None:
		filepath = track_object.fullpath
		sources = self.get_sources(track_object)

		if len(sources) == 0:
			logging.error("Error thumbnailing; no source images found")
			return False

		offset = self.get_offset(filepath, sources)
		source_image = self.get_source_raw(offset, sources, track_object)

		im = Image.open(source_image)
		if im.mode != "RGB":
			im = im.convert("RGB")

		if not zoom:
			im.thumbnail(size, Image.Resampling.LANCZOS)
		else:
			w, h = im.size
			if w != h:
				m = min(w, h)
				im = im.crop((
					(w - m) / 2,
					(h - m) / 2,
					(w + m) / 2,
					(h + m) / 2,
				))

			im = im.resize(size, Image.Resampling.LANCZOS)

		if not save_path:
			g = io.BytesIO()
			g.seek(0)
			if png:
				im.save(g, "PNG")
			else:
				im.save(g, "JPEG")
			g.seek(0)
			return g

		if png:
			im.save(save_path + ".png", "PNG")
		else:
			im.save(save_path + ".jpg", "JPEG")
		return None

	def display(self, track: TrackClass, location: list[int], box: tuple[int, int], fast: bool = False, theme_only: bool = False, async_hold: bool = False, caller_id: str | None = None) -> int | None:
		"""Draw the art for the given track at location, sized to fit box.

		Without async_hold this always blocks to return the requested art.
		With async_hold, uncached images are loaded and resized on a worker
		thread while the art this caller last displayed is held on screen (no
		blank flash). Only use it for a live UI display box; thumbnailers etc.
		want the synchronous result. Callers passing async_hold should pass a
		unique caller_id so their previous art can be tracked for the hold.
		"""
		# A non-positive box (can happen for a very short/narrow Custom Layout
		# segment) would crash the PIL thumbnail/resize, so skip drawing.
		if box[0] <= 0 or box[1] <= 0:
			return None
		index = track.index
		filepath = track.fullpath

		if self.prefs.colour_from_image and track.album != self.gui.theme_temp_current and box[0] != 115:
			if track.album in self.gui.temp_themes:
				self.tauon.colours.__dict__.update(self.gui.temp_themes[track.album].__dict__)
				self.gui.theme_temp_current = track.album

		source = self.get_sources(track)

		if len(source) == 0:
			if caller_id:
				self.caller_history.pop(caller_id, None)  # No art means blank; don't hold this later
			return 1

		offset = self.get_offset(filepath, source)

		if not theme_only:
			# Check cache; any unit of the same source image at the same size will do
			for unit in self.image_cache:
				if unit.source == source[offset][1] and unit.request_size == box:
					self.touch_cache_unit(unit)
					self.render(unit, location)
					if caller_id:
						self.caller_history[caller_id] = unit
					return 0

			if fast:
				return self.fast_display(track.index, location, box, source, offset)

		if async_hold and not theme_only:
			return self.display_async(track, location, box, source, offset, index, caller_id)

		# Load and render, blocking
		r = self.load_art_image(track, source, offset, theme_only)
		if not isinstance(r, tuple):
			return r
		im, o_size, image_format = r

		try:
			if theme_only:
				self.extract_art_theme(im, track, box)
				return None

			g, o_size = self.resize_art_image(im, o_size, box)
			self.extract_art_theme(im, track, box)
			unit = self.create_unit_and_render(g, o_size, image_format, index, offset, box, source, location)
			if caller_id:
				self.caller_history[caller_id] = unit

			# temp fix
			self.inp.quick_drag = False
			self.gui.move_on_title = False
			self.gui.playlist_hold = False

		except Exception:
			logging.exception("Image display error")
			logging.error(f"-- Associated track: {track.fullpath}")  # noqa: TRY400
			return 1
		return 0

	def display_async(self, track: TrackClass, location: list[int], box: tuple[int, int], source: list[tuple[int, str]], offset: int, index: int, caller_id: str | None) -> int | None:
		"""Non-blocking version of the display() slow path.

		The source image is loaded and resized on a worker thread; one disk
		load is shared by every size requested of the same art. While that is
		in flight, the art this caller last displayed is held on screen so
		there is no blank flash. If the load fails we go straight to blank.
		"""
		load_key = (source[offset][1], offset)
		key = (source[offset][1], offset, box)
		now = time.monotonic()
		with self.async_lock:
			# Drop entries nothing came back for (e.g. sizes requested mid window-resize)
			for k, r in list(self.async_results.items()):
				if now - r[3] > 5:
					r[0].close()
					del self.async_results[k]
			for k, t in list(self.async_failed.items()):
				if now - t > 10:  # Allow an eventual retry
					del self.async_failed[k]

			if key in self.async_failed:
				if caller_id:
					self.caller_history.pop(caller_id, None)
				return 1  # The load failed; show blank

			pickup = self.async_results.pop(key, None)
			if pickup is None:
				boxes = self.async_loads.get(load_key)
				if boxes is not None:
					boxes.add(box)  # This art is already loading; have that load also make our size
				elif len(self.async_loads) < 3:
					self.async_loads[load_key] = {box}
					shoot = threading.Thread(
						target=self.async_prepare,
						args=(track, source, offset, load_key))
					shoot.daemon = True
					shoot.start()

				# Hold the art this caller last displayed while the new image loads
				if caller_id:
					held = self.caller_history.get(caller_id)
					if held is not None and held in self.image_cache:
						self.touch_cache_unit(held)
						if held.request_size == box:
							self.render(held, location)
						else:
							self.render_fit(held, location, box)
						return 0
				return None

		g, o_size, image_format, _ = pickup
		try:
			unit = self.create_unit_and_render(g, o_size, image_format, index, offset, box, source, location)
			if caller_id:
				self.caller_history[caller_id] = unit
		except Exception:
			logging.exception("Image display error")
			logging.error(f"-- Associated track: {track.fullpath}")  # noqa: TRY400
			return 1
		return 0

	def async_prepare(self, track: TrackClass, source: list[tuple[int, str]], offset: int, load_key: tuple) -> None:
		"""Load an art image once off the UI thread, then produce every size
		requested of it from that single load (see display_async)"""
		r = self.load_art_image(track, source, offset, in_worker=True)
		if not isinstance(r, tuple):
			# Load failed; mark every size that was waiting on it as failed
			now = time.monotonic()
			with self.async_lock:
				for b in self.async_loads.pop(load_key, ()):
					self.async_failed[(load_key[0], load_key[1], b)] = now
			self.gui.update += 1
			return

		im, o_size, image_format = r
		themed = False
		try:
			while True:
				with self.async_lock:
					boxes = self.async_loads.get(load_key)
					if not boxes:
						self.async_loads.pop(load_key, None)
						break
					# Peek rather than pop, so a repeat request while we resize
					# sees the job still pending instead of starting another
					b = next(iter(boxes))
				g, sized_o_size = self.resize_art_image(im, o_size, b)
				if not themed:
					self.extract_art_theme(im, track, b)
					themed = True
				with self.async_lock:
					if load_key not in self.async_loads:  # clear_cache() happened; result is stale
						g.close()
						break
					self.async_results[(load_key[0], load_key[1], b)] = (g, sized_o_size, image_format, time.monotonic())
					self.async_loads[load_key].discard(b)
				self.gui.update += 1
		except Exception:
			logging.exception("Error preparing image sizes")
			now = time.monotonic()
			with self.async_lock:
				for b in self.async_loads.pop(load_key, ()):
					self.async_failed[(load_key[0], load_key[1], b)] = now
		self.gui.update += 1

	def load_art_image(self, track: TrackClass, source: list[tuple[int, str]], offset: int, theme_only: bool = False, in_worker: bool = False) -> tuple[ImageFile, tuple[int, int], str] | int | None:
		"""Fetch and decode the source image; this is the one disk (or network)
		load per art, which resize_art_image() can then be run against several
		times.

		Returns (image, original size, format) on success, or an int/None
		result code for display() to pass through. This is the slow part of
		display(); with in_worker it runs on a thread (see display_async).
		"""
		index = track.index
		try:
			# Get source IO
			if source[offset][0] == 1:
				# Target is a embedded image
				# source_image = io.BytesIO(self.get_embed(track))
				source_image = self.get_source_raw(0, 0, track, source[offset])

			elif source[offset][0] == 2:
				idea = self.prefs.encoder_output / encode_folder_name(track) / "cover.jpg"
				if idea.is_file():
					source_image = idea.open("rb")
				else:
					try:
						# We want to download the image asynchronously as to not block the UI
						if self.downloaded_image and self.downloaded_track == track:
							source_image = self.downloaded_image

						elif in_worker:
							# Already off the UI thread, just download here
							self.download_in_progress = True
							try:
								self.async_download_image(track, source[offset])
							finally:
								self.download_in_progress = False
							if self.downloaded_track != track:
								return None
							assert self.downloaded_image
							source_image = self.downloaded_image

						elif self.download_in_progress:
							return 0

						else:
							self.download_in_progress = True
							shoot_dl = threading.Thread(
								target=self.async_download_image,
								args=([track, source[offset]]))
							shoot_dl.daemon = True
							shoot_dl.start()

							# We'll block with a small timeout to avoid unwanted flashing between frames
							s = 0
							while self.download_in_progress:
								s += 1
								time.sleep(0.01)
								if s > 20:  # 200 ms
									break

							if self.downloaded_track != track:
								return None

							assert self.downloaded_image
							source_image = self.downloaded_image

					except Exception:
						logging.exception("IMAGE NETWORK LOAD ERROR")
						raise
			else:
				# source_image = open(source[offset][1], 'rb')
				source_image = self.get_source_raw(0, 0, track, source[offset])

			im = Image.open(source_image)
			o_size = im.size

			format = im.format

			try:
				if im.format == "JPEG":
					format = "JPG"

				if im.mode != "RGB":
					im = im.convert("RGB")
			except Exception:
				logging.exception("Failed to convert image")
				if theme_only:
					if not track.is_network:
						source_image.close()
					return None
				im = Image.open(str(self.install_directory / "assets" / "load-error.png"))
				o_size = im.size

			im.load()  # Force the full decode now so the source stream can be closed
			if not track.is_network:
				source_image.close()

		except Exception:
			logging.exception("Image load error")
			logging.error(f"-- Associated track: {track.fullpath}")  # noqa: TRY400

			try:
				del self.source_cache[index][offset]
			except Exception:
				logging.exception(" -- Error, no source cache?")
			return 1
		return im, o_size, format

	def resize_art_image(self, im: ImageFile, o_size: tuple[int, int], box: tuple[int, int]) -> tuple[BytesIO, tuple[int, int]]:
		"""Resize a loaded image to fit box and encode it ready for texture upload.

		Non-destructive, so several sizes can be made from a single load.
		Returns (BMP data, original size)."""
		try:
			if self.prefs.zoom_art:
				new_size = fit_box(o_size, box)
			else:
				# Fit within box, preserving aspect, never upscaling
				scale = min(box[0] / o_size[0], box[1] / o_size[1], 1)
				new_size = (max(1, round(o_size[0] * scale)), max(1, round(o_size[1] * scale)))
			im = im.resize(new_size, Image.Resampling.LANCZOS)
		except Exception:
			logging.exception("Failed to resize image")
			im = Image.open(str(self.install_directory / "assets" / "load-error.png"))
			o_size = im.size
			if self.prefs.zoom_art:
				im = im.resize(fit_box(o_size, box), Image.Resampling.LANCZOS)
			else:
				im.thumbnail((box[0], box[1]), Image.Resampling.LANCZOS)

		g = io.BytesIO()
		im.save(g, "BMP")
		g.seek(0)
		return g, o_size

	def extract_art_theme(self, im: ImageFile, track: TrackClass, box: tuple[int, int]) -> None:
		"""Set theme colours from the image (the "Carbon" theme and the
		"colour from image" setting).

		Pass the original full-size image (colours are sampled from an internal
		copy of it, so results don't vary with the display size). Best effort:
		on failure the theme is simply left unchanged.
		"""
		try:
			# Processing for "Carbon" theme
			if track == self.pctl.playing_object() and self.gui.theme_name == "Carbon" and track.parent_folder_path != self.colours.last_album:
				# Find main image colours
				_im_theme = im.copy()
				_im_theme.thumbnail((50, 50), Image.Resampling.LANCZOS)
				try:
					pixels = _im_theme.getcolors(maxcolors=2500)
				except Exception:
					logging.exception("theme gen error")
					return
				pixels = sorted(pixels, key=lambda x: x[0], reverse=True)[:]
				colour = pixels[0][1]

				# Try and find a colour that is not grayscale
				for c in pixels:
					cc = c[1]
					av = sum(cc) / 3
					if abs(cc[0] - av) > 10 or abs(cc[1] - av) > 10 or abs(cc[2] - av) > 10:
						colour = cc
						break

				h_colour = rgb_to_hls(colour[0], colour[1], colour[2])

				l = .51
				s = .44

				hh = h_colour[0]
				if 0.14 < hh < 0.3:  # Yellow and green are hard to read text on, so lower the luminance for those
					l = .45
				if check_equal(colour):  # Default to theme purple if source colour was grayscale
					hh = 0.72

				self.colours.bottom_panel_colour = hls_to_rgb(hh, l, s)
				self.colours.last_album = track.parent_folder_path

			# Processing for "Auto-theme" setting
			if self.prefs.colour_from_image and box[0] != 115 and track.album != self.gui.theme_temp_current \
					and track.album not in self.gui.temp_themes:  # and pctl.master_library[index].parent_folder_path != colours.last_album: #mark2233
				self.colours.last_album = track.parent_folder_path

				_im_theme = im.copy()
				_im_theme.thumbnail((50, 50), Image.Resampling.LANCZOS)

				colours = copy.deepcopy(self.colours)

				pixels = _im_theme.getcolors(maxcolors=2500)
				#logging.info(pixels)
				pixels = sorted(pixels, key=lambda x: x[0], reverse=True)[:]
				#logging.info(pixels)

				min_colour_varience = 75

				x_colours: list[ColourRGBA] = []
				for item in pixels:
					colour = item[1]
					for cc in x_colours:
						if abs(
							colour[0] - cc.r) < min_colour_varience and abs(
							colour[1] - cc.g) < min_colour_varience and abs(
							colour[2] - cc.b) < min_colour_varience:
							break
					else:
						x_colours.append(ColourRGBA(colour[0], colour[1], colour[2], 255))

				#logging.info(x_colours)
				colours.playlist_box_background = colours.side_panel_background

				colours.playlist_panel_background = x_colours[0]
				if len(x_colours) > 1:
					colours.side_panel_background = x_colours[1]
					colours.playlist_box_background = colours.side_panel_background
					if len(x_colours) > 2:
						colours.title_text = x_colours[2]
						colours.title_playing = x_colours[2]
						if len(x_colours) > 3:
							colours.artist_text = x_colours[3]
							colours.artist_playing = x_colours[3]
							if len(x_colours) > 4:
								colours.playlist_box_background = x_colours[4]

				colours.queue_background = colours.side_panel_background
				colours.lyrics_panel_background = colours.side_panel_background
				# Check artist text colour
				if contrast_ratio(colours.artist_text, colours.playlist_panel_background) < 1.9:
					black = ColourRGBA(25, 25, 25, 255)
					white = ColourRGBA(220, 220, 220, 255)

					con_b = contrast_ratio(black, colours.playlist_panel_background)
					con_w = contrast_ratio(white, colours.playlist_panel_background)

					choice = black
					if con_w > con_b:
						choice = white

					colours.artist_text = choice
					colours.artist_playing = choice

				# Check title text colour
				if contrast_ratio(colours.title_text, colours.playlist_panel_background) < 1.9:
					black = ColourRGBA(60, 60, 60, 255)
					white = ColourRGBA(180, 180, 180, 255)

					con_b = contrast_ratio(black, colours.playlist_panel_background)
					con_w = contrast_ratio(white, colours.playlist_panel_background)

					choice = black
					if con_w > con_b:
						choice = white

					colours.title_text = choice
					colours.title_playing = choice

				# Check lyrics text colour
				if contrast_ratio(colours.lyrics, colours.lyrics_panel_background) < 1.9:
					black = ColourRGBA(60, 60, 60, 255)
					white = ColourRGBA(180, 180, 180, 255)

					con_b = contrast_ratio(black, colours.lyrics_panel_background)
					con_w = contrast_ratio(white, colours.lyrics_panel_background)

					choice = black
					if con_w > con_b:
						choice = white

					colours.lyrics = choice

				# try to pick high-contrast active lyric color
				contrast = 0
				for i in x_colours:
					temp = contrast_ratio(i, colours.lyrics_panel_background)
					if temp > contrast:
						colours.active_lyric = i
						contrast = temp
				# if there isn't one, just do full black/white
				if contrast_ratio(colours.active_lyric, colours.lyrics_panel_background) < 2.9 or contrast_ratio(colours.active_lyric, colours.lyrics) < 1.9:
					lpb = colours.lyrics_panel_background
					lr  = colours.lyrics
					tc = rgb_to_hls(lpb.r, lpb.g, lpb.b)
					lc = rgb_to_hls(lr.r,  lr.g,  lr.b)

					colours.active_lyric = hls_to_rgb( tc[0]+0.3, lc[1], max(tc[2]*1.5, 0.5) )

				if test_lumi(colours.side_panel_background) < 0.50 and not self.prefs.transparent_mode:
					colours.side_bar_line1 = ColourRGBA(25, 25, 25, 255)
					colours.side_bar_line2 = ColourRGBA(35, 35, 35, 255)
				else:
					colours.side_bar_line1 = ColourRGBA(250, 250, 250, 255)
					colours.side_bar_line2 = ColourRGBA(235, 235, 235, 255)

				colours.album_text = colours.title_text
				colours.album_playing = colours.title_playing

				self.gui.pl_update = 1

				prcl = 100 - int(test_lumi(colours.playlist_panel_background) * 100)

				if prcl > 45:
					ce = alpha_blend(ColourRGBA(0, 0, 0, 180), colours.playlist_panel_background)  # [40, 40, 40, 255]
					colours.index_text = ce
					colours.index_playing = ce
					colours.time_text = ce
					colours.bar_time = ce
					colours.folder_title = ce
					colours.star_line = ColourRGBA(60, 60, 60, 255)
					colours.row_select_highlight = ColourRGBA(0, 0, 0, 30)
					colours.row_playing_highlight = ColourRGBA(0, 0, 0, 20)
					colours.gallery_background = rgb_add_hls(colours.playlist_panel_background, 0, -0.03, -0.03)
				else:
					ce = alpha_blend(ColourRGBA(255, 255, 255, 160), colours.playlist_panel_background)  # [165, 165, 165, 255]
					colours.index_text = ce
					colours.index_playing = ce
					colours.time_text = ce
					colours.bar_time = ce
					colours.folder_title = ce
					colours.star_line = ce  # ColourRGBA(150, 150, 150, 255)
					colours.row_select_highlight = ColourRGBA(255, 255, 255, 12)
					colours.row_playing_highlight = ColourRGBA(255, 255, 255, 8)
					colours.gallery_background = rgb_add_hls(colours.playlist_panel_background, 0, 0.03, 0.03)

				self.gui.temp_themes[track.album] = copy.deepcopy(colours)
				self.tauon.colours.__dict__.update(self.gui.temp_themes[track.album].__dict__)
				self.gui.theme_temp_current = track.album

				if self.prefs.transparent_mode:
					colours.apply_transparency()

		except Exception:
			logging.exception("Error extracting theme colours from image")

	def touch_cache_unit(self, unit: ImageObject) -> None:
		"""Move a cached unit to the back so eviction stays least-recently-used."""
		try:
			self.image_cache.remove(unit)
		except ValueError:
			return
		self.image_cache.append(unit)

	def create_unit_and_render(self, g: BytesIO, o_size: tuple[int, int], image_format: str, index: int, offset: int, box: tuple[int, int], source: list[tuple[int, str]], location: list[int]) -> ImageObject:
		"""Upload decoded image data as a texture, cache it and render it (main thread only)"""
		s_image = self.ddt.load_image(g)

		c = sdl3.SDL_CreateTextureFromSurface(self.renderer, s_image)

		tex_w = pointer(c_float(0))
		tex_h = pointer(c_float(0))
		sdl3.SDL_GetTextureSize(c, tex_w, tex_h)

		dst = sdl3.SDL_FRect(round(location[0]), round(location[1]))
		dst.w = int(tex_w.contents.value)
		dst.h = int(tex_h.contents.value)

		# Clean up
		sdl3.SDL_DestroySurface(s_image)
		g.close()

		unit = ImageObject()
		unit.index = index
		unit.texture = c
		unit.rect = dst
		unit.request_size = box
		unit.original_size = o_size
		unit.actual_size = (dst.w, dst.h)
		unit.source = source[offset][1]
		unit.offset = offset
		unit.format = image_format

		self.image_cache.append(unit)

		self.render(unit, location)

		if len(self.image_cache) > 3:
			sdl3.SDL_DestroyTexture(self.image_cache[0].texture)
			del self.image_cache[0]

		return unit

	def render(self, unit, location) -> None:
		rect = unit.rect

		self.gui.art_aspect_ratio = unit.actual_size[0] / unit.actual_size[1]

		rect.x = round(int((unit.request_size[0] - unit.actual_size[0]) / 2) + location[0])
		rect.y = round(int((unit.request_size[1] - unit.actual_size[1]) / 2) + location[1])

		self.tauon.style_overlay.hole_punches.append(rect)

		sdl3.SDL_RenderTexture(self.renderer, unit.texture, None, rect)

		self.gui.art_drawn_rect = (rect.x, rect.y, rect.w, rect.h)

	def clear_cache(self) -> None:
		for unit in self.image_cache:
			sdl3.SDL_DestroyTexture(unit.texture)

		self.image_cache.clear()
		self.source_cache.clear()
		self.downloaded_track = None

		with self.async_lock:
			self.async_loads.clear()  # In-flight workers see their job vanish and stop
			for r in self.async_results.values():
				r[0].close()
			self.async_results.clear()
			self.async_failed.clear()
			self.caller_history.clear()

		self.base64cache = (0, 0, "")
		self.processing64on = None
		self.bin_cached = (None, None, None)
		self.loading_bin = (None, None)
		self.embed_cached = (None, None)

		self.gui.temp_themes.clear()
		self.gui.theme_temp_current = -1
		self.colours.last_album = ""

class StyleOverlay:
	"""Stage:
	0 - blank
	1 - preparing first
	2 - render first
	"""

	def __init__(self, tauon: Tauon) -> None:
		self.tauon:   Tauon = tauon
		self.gui:     GuiVar = tauon.gui
		self.ddt:     TDraw = tauon.ddt
		self.pctl:    PlayerCtl = tauon.pctl
		self.prefs:   Prefs = tauon.prefs
		self.renderer       = tauon.renderer
		self.window_size: list[int]    = tauon.window_size
		self.album_art_gen: AlbumArt  = AlbumArt(tauon=tauon, style_overlay=self)
		self.thread_manager: ThreadManager = tauon.thread_manager
		self.min_on_timer:   Timer = Timer()
		self.fade_on_timer:  Timer = Timer(0)
		self.fade_off_timer: Timer = Timer()

		# TODO(Martin): Document and probably turn into an enum
		self.stage: int = 0

		self.im = None

		self.a_texture = None
		self.a_rect = None

		self.b_texture = None
		self.b_rect = None

		self.a_type = 0
		self.b_type = 0

		self.window_size_int = None
		self.parent_path = None

		self.hole_punches: list[sdl3.SDL_FRect] = []
		#self.hole_refills = []

		self.go_to_sleep: bool = False

		self.current_track_album: str = "none"
		self.current_track_id: int = -1

	def worker(self) -> None:
		if self.stage == 0:
			if (self.gui.mode == GuiMode.MINI and self.prefs.mini_mode_mode == MiniModeMode.SLATE):
				pass

			if self.pctl.playing_ready() and self.min_on_timer.get() > 0:

				track = self.pctl.playing_object()

				self.window_size_int = copy.copy(self.window_size)
				self.parent_path = track.parent_folder_path
				self.current_track_id = track.index
				self.current_track_album = track.album

				try:
					self.im = self.album_art_gen.get_blur_im(track)
				except Exception:
					logging.exception("Blur blackground error")
					raise
					#logging.debug(track.fullpath)

				if self.im is None or self.im is False:
					if self.a_texture:
						self.stage = 2
						self.fade_off_timer.set()
						self.go_to_sleep = True
						return
					self.flush()
					self.min_on_timer.force_set(-4)
					return

				self.stage = 1
				self.gui.update += 1
				return

	def flush(self) -> None:
		if self.a_texture is not None:
			sdl3.SDL_DestroyTexture(self.a_texture)
			self.a_texture = None
		if self.b_texture is not None:
			sdl3.SDL_DestroyTexture(self.b_texture)
			self.b_texture = None
		self.min_on_timer.force_set(-0.2)
		self.parent_path = "None"
		self.stage = 0
		self.thread_manager.ready("worker")
		self.gui.style_worker_timer.set()
		self.gui.delay_frame(0.25)
		self.gui.update += 1

	def display(self) -> None:
		if self.min_on_timer.get() < 0:
			return

		if self.stage == 1:

			s_image = self.ddt.load_image(self.im)

			c = sdl3.SDL_CreateTextureFromSurface(self.renderer, s_image)

			tex_w = pointer(c_float(0))
			tex_h = pointer(c_float(0))
			sdl3.SDL_GetTextureSize(c, tex_w, tex_h)

			dst = sdl3.SDL_FRect(-40)
			dst.w = int(tex_w.contents.value)
			dst.h = int(tex_h.contents.value)

			# Clean uo
			sdl3.SDL_DestroySurface(s_image)
			self.im.close()

			# sdl3.SDL_SetTextureAlphaMod(c, 10)
			self.fade_on_timer.set()

			if self.a_texture is not None:
				self.b_texture = self.a_texture
				self.b_rect = self.a_rect
				self.b_type = self.a_type

			self.a_texture = c
			self.a_rect = dst
			self.a_type = self.album_art_gen.loaded_bg_type

			self.stage = 2
			self.radio_meta = None

			self.gui.update += 1

		if self.stage == 2:
			track = self.pctl.playing_object()

			if self.pctl.playing_state == PlayingState.URL_STREAM:
				if self.radio_meta != self.pctl.tag_meta:
					self.radio_meta = self.pctl.tag_meta
					self.current_track_id = -1
					self.stage = 0

			elif not self.go_to_sleep and self.b_texture is None and self.current_track_id != track.index:
				self.radio_meta = None
				if not track.album:
					self.stage = 0
				else:
					self.current_track_id = track.index
					if (
							self.parent_path != self.pctl.playing_object().parent_folder_path or self.current_track_album != self.pctl.playing_object().album):
						self.stage = 0

		if self.gui.mode == GuiMode.MINI and self.prefs.mini_mode_mode == MiniModeMode.SLATE:
			pass

		t = self.fade_on_timer.get()
		sdl3.SDL_SetRenderTarget(self.renderer, self.gui.main_texture_overlay_temp)
		sdl3.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 255)
		sdl3.SDL_RenderClear(self.renderer)

		if self.a_texture is not None and self.window_size_int != self.window_size:
			self.flush()

		if self.b_texture is not None:

			self.b_rect.y = 0 - self.b_rect.h // 4
			if self.b_type == 1:
				self.b_rect.y = 0

			if t < 0.4:

				sdl3.SDL_RenderTexture(self.renderer, self.b_texture, None, self.b_rect)

			else:
				sdl3.SDL_DestroyTexture(self.b_texture)
				self.b_texture = None
				self.b_rect = None

		if self.a_texture is not None:

			self.a_rect.y = 0 - self.a_rect.h // 4
			if self.a_type == 1:
				self.a_rect.y = 0

			if t < 0.4:
				fade = round(t / 0.4 * 255)
				self.gui.update += 1

			else:
				fade = 255

			if self.go_to_sleep:
				t = self.fade_off_timer.get()
				self.gui.update += 1

				if t < 1:
					fade = 255
				elif t < 1.4:
					fade = 255 - round((t - 1) / 0.4 * 255)
				else:
					self.go_to_sleep = False
					self.flush()
					return

			# Center image
			if self.window_size[0] < 900 * self.gui.scale:
				self.a_rect.x = (self.window_size[0] // 2) - self.a_rect.w // 2
			else:
				self.a_rect.x = -40

			sdl3.SDL_SetRenderTarget(self.renderer, self.gui.main_texture_overlay_temp)

			sdl3.SDL_SetTextureAlphaMod(self.a_texture, fade)
			sdl3.SDL_RenderTexture(self.renderer, self.a_texture, None, self.a_rect)

			sdl3.SDL_SetRenderDrawBlendMode(self.renderer, sdl3.SDL_BLENDMODE_NONE)

			sdl3.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 0)
			for rect in self.hole_punches:
				sdl3.SDL_RenderFillRect(self.renderer, rect)

			sdl3.SDL_SetRenderDrawBlendMode(self.renderer, sdl3.SDL_BLENDMODE_BLEND)

			sdl3.SDL_SetRenderTarget(self.renderer, self.gui.main_texture)
			opacity = self.prefs.art_bg_opacity
			if self.prefs.mini_mode_mode == MiniModeMode.SLATE and self.gui.mode == GuiMode.MINI:
				opacity = 255

			sdl3.SDL_SetTextureAlphaMod(self.gui.main_texture_overlay_temp, opacity)
			sdl3.SDL_RenderTexture(self.renderer, self.gui.main_texture_overlay_temp, None, None)

			sdl3.SDL_SetRenderTarget(self.renderer, self.gui.main_texture)

		else:
			sdl3.SDL_SetRenderTarget(self.renderer, self.gui.main_texture)

class ToolTip:

	def __init__(self, tauon: Tauon) -> None:
		self.gui     = tauon.gui
		self.ddt     = tauon.ddt
		self.colours = tauon.colours
		self.text = ""
		self.h = 24 * self.gui.scale
		self.w = 62 * self.gui.scale
		self.x = 0
		self.y = 0
		self.timer = Timer()
		self.trigger = 1.1
		self.font = 13
		self.called = False
		self.a = False

	def test(self, x: float, y: float, text: str) -> None:
		if self.text != text or x != self.x or y != self.y:
			self.text = text
			# self.timer.set()
			self.a = False

			self.x = x
			self.y = y
			self.w = self.ddt.get_text_w(text, self.font) + 20 * self.gui.scale
			self.h = 24 * self.gui.scale

		self.called = True

		if self.a is False:
			self.timer.set()
			self.gui.frame_callback_list.append(TestTimer(self.trigger))
		self.a = True

	def render(self) -> None:
		if self.called is True:
			if self.timer.get() > self.trigger:
				self.ddt.rect((self.x, self.y, self.w, self.h), self.colours.box_button_background)
				# ddt.rect((self.x, self.y, self.w, self.h), self.colours.grey(45))
				self.ddt.text(
					(self.x + int(self.w / 2), self.y + 4 * self.gui.scale, 2), self.text,
					self.colours.menu_text, self.font, bg=self.colours.box_button_background)
			else:
				# self.gui.update += 1
				pass
		else:
			self.timer.set()
			self.a = False
		self.called = False

class ToolTip3:

	def __init__(self, tauon: Tauon) -> None:
		self.inp     = tauon.inp
		self.ddt     = tauon.ddt
		self.gui     = tauon.gui
		self.pctl    = tauon.pctl
		self.coll    = tauon.coll
		self.colours = tauon.colours
		self.x = 0
		self.y = 0
		self.text = ""
		self.rect: list[int] = []
		self.font = None
		self.show = False
		self.width = 0
		self.height = 24 * self.gui.scale
		self.timer = Timer()
		self.pl_position = 0
		self.click_exclude_point = (0, 0)

	def set(self, x: int, y: int, text: str, font, rect: list[int]) -> None:
		y -= round(11 * self.gui.scale)
		if self.show is False or self.y != y or x != self.x or self.pl_position != self.pctl.playlist_view_position:
			self.timer.set()

		if point_proximity_test(self.click_exclude_point, self.inp.mouse_position, 20 * self.gui.scale):
			self.timer.set()
			return

		if self.inp.mouse_click:
			self.click_exclude_point = copy.copy(self.inp.mouse_position)
			self.timer.set()
			return

		self.x = x
		self.y = y
		self.text = text
		self.font = font
		self.show = True
		self.rect = rect
		self.pl_position = self.pctl.playlist_view_position

	def render(self) -> None:
		if not self.show:
			return

		if not point_proximity_test(self.click_exclude_point, self.inp.mouse_position, 20 * self.gui.scale):
			self.click_exclude_point = (0, 0)

		if not self.coll(
				self.rect) or self.inp.mouse_click or self.gui.level_2_click or self.pl_position != self.pctl.playlist_view_position:
			self.show = False

		self.gui.frame_callback_list.append(TestTimer(0.02))

		if self.timer.get() < 0.6:
			return

		w = self.ddt.get_text_w(self.text, 312) + self.height
		x = self.x  # - int(self.width / 2)
		y = self.y
		h = self.height

		border = 1 * self.gui.scale

		self.ddt.rect((x - border, y - border, w + border * 2, h + border * 2), self.colours.grey(60))
		self.ddt.rect((x, y, w, h), self.colours.menu_background)
		p = self.ddt.text(
			(x + int(w / 2), y + 3 * self.gui.scale, 2), self.text, self.colours.menu_text, 312, bg=self.colours.menu_background)

		if not self.coll(self.rect):
			self.show = False

class RenameTrackBox:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon        = tauon
		self.inp          = tauon.inp
		self.ddt          = tauon.ddt
		self.gui          = tauon.gui
		self.draw         = tauon.draw
		self.pctl         = tauon.pctl
		self.coll         = tauon.coll
		self.prefs        = tauon.prefs
		self.colours      = tauon.colours
		self.star_store   = tauon.star_store
		self.window_size  = tauon.window_size
		self.rename_files = tauon.rename_files
		self.show_message = tauon.show_message
		self.active = False
		self.target_track_id = None
		self.single_only = False

	def activate(self, track_ref: MenuTrackRef) -> None:
		self.active = True
		self.target_track_id = track_ref.track_id
		if self.inp.key_shift_down or self.inp.key_shiftr_down:
			self.single_only = True
		else:
			self.single_only = False

	def disable_test(self, track_ref: MenuTrackRef) -> bool:
		track_id = track_ref.track_id
		single_only = bool(self.inp.key_shift_down or self.inp.key_shiftr_down)

		if not single_only:
			for item in self.pctl.default_playlist:
				if self.pctl.master_library[item].parent_folder_path == self.pctl.master_library[track_id].parent_folder_path:
					if self.pctl.master_library[item].is_network is True:
						return True
		return False

	def render(self) -> None:
		if not self.active:
			return

		if self.gui.level_2_click:
			self.inp.mouse_click = True
		self.gui.level_2_click = False

		w = 420 * self.gui.scale
		h = 155 * self.gui.scale
		x = int(self.window_size[0] / 2) - int(w / 2)
		y = int(self.window_size[1] / 2) - int(h / 2)

		self.ddt.rect_a((x - 2 * self.gui.scale, y - 2 * self.gui.scale), (w + 4 * self.gui.scale, h + 4 * self.gui.scale), self.colours.box_border)
		self.ddt.rect_a((x, y), (w, h), self.colours.box_background)
		self.ddt.text_background_colour = self.colours.box_background

		if self.inp.key_esc_press or ((self.inp.mouse_click or self.inp.right_click or self.inp.level_2_right_click) and not self.coll((x, y, w, h))):
			self.tauon.rename_track_box.active = False

		r_todo = []

		# Find matching folder tracks in playlist
		if not self.single_only:
			for item in self.pctl.default_playlist:
				if self.pctl.master_library[item].parent_folder_path == self.pctl.master_library[
					self.target_track_id].parent_folder_path:

					# Close and display error if any tracks are not single local files
					if self.pctl.master_library[item].is_network is True:
						self.tauon.rename_track_box.active = False
						self.show_message(_("Cannot rename"), _("One or more tracks is from a network location!"), mode="info")
					if self.pctl.master_library[item].is_cue is True:
						self.tauon.rename_track_box.active = False
						self.show_message(_("This function does not support renaming CUE Sheet tracks."))
					else:
						r_todo.append(item)
		else:
			r_todo = [self.target_track_id]

		self.ddt.text((x + 10 * self.gui.scale, y + 8 * self.gui.scale), _("Track Renaming"), self.colours.grey(230), 213)
		input_h = 23 * self.gui.scale

		# if draw.button("Default", x + 230 * gui.scale, y + 8 * gui.scale,
		if self.rename_files.text != self.prefs.rename_tracks_template and self.draw.button(
			_("Default"), x + w - 85 * self.gui.scale, y + h - 35 * self.gui.scale, 70 * self.gui.scale, input_h):
			self.rename_files.set_text(self.prefs.rename_tracks_template)
			self.rename_files.offset = 0

		# ddt.draw_text((x + 14, y + 40,), NRN + cursor, self.colours.grey(150), 12)
		self.rename_files.draw(
			x + 14 * self.gui.scale,
			y + 39 * self.gui.scale,
			self.colours.box_input_text,
			width=300 * self.gui.scale,
		)
		NRN = self.rename_files.text

		self.ddt.rect_s(
			(x + 8 * self.gui.scale, y + 36 * self.gui.scale, 300 * self.gui.scale, 22 * self.gui.scale), self.colours.box_text_border, 1 * self.gui.scale)

		afterline = ""
		warn = False
		underscore = False

		for item in r_todo:
			if self.pctl.master_library[item].track_number == "" or self.pctl.master_library[item].artist == "" or \
					self.pctl.master_library[item].title == "" or self.pctl.master_library[item].album == "":
				warn = True

			if item == self.target_track_id:
				afterline = parse_template2(NRN, self.pctl.master_library[item])

		self.ddt.text((x + 10 * self.gui.scale, y + 68 * self.gui.scale), _("BEFORE"), self.colours.box_text_label, 212)
		line = self.tauon.trunc_line(self.pctl.master_library[self.target_track_id].filename, 12, 335)
		self.ddt.text((x + 70 * self.gui.scale, y + 68 * self.gui.scale), line, self.colours.grey(210), 211, max_w=340)

		self.ddt.text((x + 10 * self.gui.scale, y + 83 * self.gui.scale), _("AFTER"), self.colours.box_text_label, 212)
		self.ddt.text((x + 70 * self.gui.scale, y + 83 * self.gui.scale), afterline, self.colours.grey(210), 211, max_w=340)

		if (len(NRN) > 3 and len(self.pctl.master_library[self.target_track_id].filename) > 3 and afterline[-3:].lower() !=
			self.pctl.master_library[self.target_track_id].filename[-3:].lower()) or len(NRN) < 4 or "." not in afterline[-5:]:
			self.ddt.text(
				(x + 10 * self.gui.scale, y + 108 * self.gui.scale), _("Warning: This may change the file extension"),
				ColourRGBA(245, 90, 90, 255),
				13)

		colour_warn = ColourRGBA(143, 186, 65, 255)
		if not unique_template(NRN):
			self.ddt.text(
				(x + 10 * self.gui.scale, y + 123 * self.gui.scale), _("Warning: The filename might not be unique"),
				ColourRGBA(245, 90, 90, 255),
				13)
		if warn:
			self.ddt.text(
				(x + 10 * self.gui.scale, y + 135 * self.gui.scale), _("Warning: A track has incomplete metadata"),
				ColourRGBA(245, 90, 90, 255),
				13)
			colour_warn = ColourRGBA(180, 60, 60, 255)

		label = _("Write") + " (" + str(len(r_todo)) + ")"

		if self.draw.button(
			label, x + (8 + 300 + 10) * self.gui.scale, y + 36 * self.gui.scale, 80 * self.gui.scale, input_h,
			text_highlight_colour=self.colours.grey(255), background_highlight_colour=colour_warn,
			tooltip=_("Physically renames all the tracks in the folder")) or self.inp.level_2_enter:

			self.inp.mouse_click = False
			total_todo = len(r_todo)
			pre_state = 0

			for item in r_todo:
				if self.pctl.playing_state != PlayingState.STOPPED and item == self.pctl.track_queue[self.pctl.queue_step]:
					pre_state = self.pctl.stop(True)

				try:
					afterline = parse_template2(NRN, self.pctl.master_library[item], strict=True)

					oldname = self.pctl.master_library[item].filename
					oldpath = self.pctl.master_library[item].fullpath

					logging.info("Renaming...")

					star = self.star_store.full_get(item)
					star_key = self.star_store.key(item)

					oldpath = self.pctl.master_library[item].fullpath

					oldsplit = os.path.split(oldpath)

					if os.path.exists(os.path.join(oldsplit[0], afterline)):
						logging.error("A file with that name already exists")
						total_todo -= 1
						continue

					if not afterline:
						logging.error("Rename Error")
						total_todo -= 1
						continue

					if "." in afterline and not afterline.split(".")[0]:
						logging.error("A file does not have a target filename")
						total_todo -= 1
						continue

					os.rename(self.pctl.master_library[item].fullpath, os.path.join(oldsplit[0], afterline))

					self.pctl.master_library[item].fullpath = os.path.join(oldsplit[0], afterline)
					self.pctl.master_library[item].filename = afterline

					self.tauon.search_string_cache.pop(item, None)
					self.tauon.search_dia_string_cache.pop(item, None)
					self.tauon.search_field_cache.pop(item, None)
					self.tauon.search_dia_field_cache.pop(item, None)

					if star is not None:
						self.star_store.db.pop(star_key, None)
						self.star_store.insert(item, star)

				except Exception:
					logging.exception("Rendering error")
					total_todo -= 1

			self.tauon.rename_track_box.active = False
			logging.info("Done")
			if pre_state == 1:
				self.pctl.revert()

			if total_todo != len(r_todo):
				self.show_message(
					_("Rename complete."),
					_("{N} / {T} filenames were written.")
					.format(N=str(total_todo), T=str(len(r_todo))), mode="warning")
			else:
				self.show_message(
					_("Rename complete."),
					_("{N} / {T} filenames were written.")
					.format(N=str(total_todo), T=str(len(r_todo))), mode="done")
			self.pctl.notify_database_changed()

class TransEditBox:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon             = tauon
		self.gui               = tauon.gui
		self.ddt               = tauon.ddt
		self.inp               = tauon.inp
		self.coll              = tauon.coll
		self.draw              = tauon.draw
		self.pctl              = tauon.pctl
		self.fields            = tauon.fields
		self.colours           = tauon.colours
		self.star_store        = tauon.star_store
		self.window_size       = tauon.window_size
		self.show_message      = tauon.show_message
		self.edit_title        = tauon.edit_title
		self.edit_album        = tauon.edit_album
		self.edit_artist       = tauon.edit_artist
		self.edit_album_artist = tauon.edit_album_artist
		self.active = False
		self.active_field = 1
		self.selected = []
		self.playlist = -1

	def render(self) -> None:
		if not self.active:
			return

		if self.gui.level_2_click:
			self.inp.mouse_click = True
		self.gui.level_2_click = False

		w = 500 * self.gui.scale
		h = 255 * self.gui.scale
		x = int(self.window_size[0] / 2) - int(w / 2)
		y = int(self.window_size[1] / 2) - int(h / 2)

		self.ddt.rect_a((x - 2 * self.gui.scale, y - 2 * self.gui.scale), (w + 4 * self.gui.scale, h + 4 * self.gui.scale), self.colours.box_border)
		self.ddt.rect_a((x, y), (w, h), self.colours.box_background)
		self.ddt.text_background_colour = self.colours.box_background

		if self.inp.key_esc_press or ((self.inp.mouse_click or self.inp.right_click or self.inp.level_2_right_click) and not self.coll((x, y, w, h))):
			self.active = False

		select = list(set(self.gui.shift_selection))
		if not select and self.pctl.selected_ready():
			select = [self.pctl.selected_in_playlist]

		titles        = [self.pctl.get_track(self.pctl.default_playlist[s]).title for s in select]
		artists       = [self.pctl.get_track(self.pctl.default_playlist[s]).artist for s in select]
		albums        = [self.pctl.get_track(self.pctl.default_playlist[s]).album for s in select]
		album_artists = [self.pctl.get_track(self.pctl.default_playlist[s]).album_artist for s in select]

		#logging.info(select)
		if select != self.selected or self.pctl.active_playlist_viewing != self.playlist:
			#logging.info("reset")
			self.selected = select
			self.playlist = self.pctl.active_playlist_viewing
			self.edit_album.clear()
			self.edit_artist.clear()
			self.edit_title.clear()
			self.edit_album_artist.clear()

			if len(select) == 0:
				return

			tr = self.pctl.get_track(self.pctl.default_playlist[select[0]])
			self.edit_title.set_text(tr.title)

			if check_equal(artists):
				self.edit_artist.set_text(artists[0])

			if check_equal(albums):
				self.edit_album.set_text(albums[0])

			if check_equal(album_artists):
				self.edit_album_artist.set_text(album_artists[0])

		x += round(20 * self.gui.scale)
		y += round(18 * self.gui.scale)

		self.ddt.text((x, y), _("Simple tag editor"), self.colours.box_title_text, 215)

		if self.draw.button(_("?"), x + 440 * self.gui.scale, y):
			self.show_message(
				_("Press Enter in each field to apply its changes to local database."),
				_("When done, press WRITE TAGS to save to tags in actual files. (Optional but recommended)"),
				mode="info")

		y += round(24 * self.gui.scale)
		self.ddt.text((x, y), _("Number of tracks selected: {N}").format(N=len(select)), self.colours.box_title_text, 313)

		y += round(24 * self.gui.scale)

		if self.inp.key_tab_press:
			if self.inp.key_shift_down or self.inp.key_shiftr_down:
				self.active_field -= 1
			else:
				self.active_field += 1

		if self.active_field < 0:
			self.active_field = 3
		if self.active_field == 4:
			self.active_field = 0
			if len(select) > 1:
				self.active_field = 1

		def field_edit(x: int, y: int, label: str, field_number: int, names: list[str], text_box: TextBox2) -> bool:
			changed = False
			self.ddt.text((x, y), label, self.colours.box_text_label, 11)
			y += round(16 * self.gui.scale)
			rect1 = (x, y, round(370 * self.gui.scale), round(17 * self.gui.scale))
			self.fields.add(rect1)
			if (self.coll(rect1) and self.inp.mouse_click) or (self.inp.key_tab_press and self.active_field == field_number):
				self.active_field = field_number
			self.ddt.bordered_rect(rect1, self.colours.box_background, self.colours.box_text_border, round(1 * self.gui.scale))
			tc = self.colours.box_input_text
			if names and check_equal(names) and text_box.text == names[0]:
				h, l, s = rgb_to_hls(tc.r, tc.g, tc.b)
				l *= 0.7
				tc = hls_to_rgb(h, l, s)
			else:
				changed = True
			if not (names and check_equal(names)) and not text_box.text:
				changed = False
				self.ddt.text((x + round(2 * self.gui.scale), y), _("<Multiple selected>"), self.colours.box_text_label, 12)
			text_box.draw(x + round(3 * self.gui.scale), y, tc, self.active_field == field_number, width=370 * self.gui.scale)
			if changed:
				self.ddt.text((x + 377 * self.gui.scale, y - 1 * self.gui.scale), "⮨", self.colours.box_title_text, 214)
			return changed

		changed = False
		if len(select) == 1:
			changed |= field_edit(x, y, _("Track title"), 0, titles, self.edit_title)
		y += round(40 * self.gui.scale)
		changed |= field_edit(x, y, _("Album name"), 1, albums, self.edit_album)
		y += round(40 * self.gui.scale)
		changed |= field_edit(x, y, _("Artist name"), 2, artists, self.edit_artist)
		y += round(40 * self.gui.scale)
		changed |= field_edit(x, y, _("Album-artist name"), 3, album_artists, self.edit_album_artist)

		y += round(40 * self.gui.scale)
		for s in select:
			tr = self.pctl.get_track(self.pctl.default_playlist[s])
			if tr.is_network:
				self.ddt.text((x, y), _("Editing network tracks is not recommended!"), ColourRGBA(245, 90, 90, 255), 312)

		if self.inp.key_return_press:
			self.gui.pl_update += 1
			if self.active_field == 0 and len(select) == 1:
				for s in select:
					tr = self.pctl.get_track(self.pctl.default_playlist[s])
					star = self.star_store.full_get(tr.index)
					self.star_store.remove(tr.index)
					tr.title = self.edit_title.text
					self.star_store.merge(tr.index, star)

			if self.active_field == 1:
				for s in select:
					tr = self.pctl.get_track(self.pctl.default_playlist[s])
					tr.album = self.edit_album.text
			if self.active_field == 2:
				for s in select:
					tr = self.pctl.get_track(self.pctl.default_playlist[s])
					star = self.star_store.full_get(tr.index)
					self.star_store.remove(tr.index)
					tr.artist = self.edit_artist.text
					self.star_store.merge(tr.index, star)
			if self.active_field == 3:
				for s in select:
					tr = self.pctl.get_track(self.pctl.default_playlist[s])
					tr.album_artist = self.edit_album_artist.text
			self.tauon.bg_save()

		ww = self.ddt.get_text_w(_("WRITE TAGS"), 212) + round(48 * self.gui.scale)
		if self.gui.write_tag_in_progress:
			text = f"{self.gui.tag_write_count}/{len(select)}"
		text = _("WRITE TAGS")
		if self.draw.button(text, (x + w) - ww, y - (0) * self.gui.scale):
			if changed:
				self.show_message(_("Press enter on fields to apply your changes first!"))
				return

			if self.gui.write_tag_in_progress:
				return

			def write_tag_go() -> None:
				for s in select:
					tr = self.pctl.get_track(self.pctl.default_playlist[s])

					if tr.is_network:
						self.show_message(_("Writing to a network track is not applicable!"), mode="error")
						self.gui.write_tag_in_progress = True
						return
					if tr.is_cue:
						self.show_message(_("Cannot write CUE sheet types!"), mode="error")
						self.gui.write_tag_in_progress = True
						return

					muta = mutagen.File(tr.fullpath, easy=True)

					def write_tag(track: TrackClass, muta, field_name_tauon, field_name_muta) -> int:
						item = muta.get(field_name_muta)
						if item and len(item) > 1:
							self.show_message(_("Cannot handle multi-field! Please use external tag editor"), mode="error")
							return 0
						if not getattr(tr, field_name_tauon):  # Want delete tag field
							if item:
								del muta[field_name_muta]
						else:
							muta[field_name_muta] = getattr(tr, field_name_tauon)
						return 1

					write_tag(tr, muta, "artist", "artist")
					write_tag(tr, muta, "album", "album")
					write_tag(tr, muta, "title", "title")
					write_tag(tr, muta, "album_artist", "albumartist")

					muta.save()
					self.gui.tag_write_count += 1
					self.gui.update += 1
				self.tauon.bg_save()
				if not self.gui.message_box:
					self.show_message(_("{N} files rewritten").format(N=self.gui.tag_write_count), mode="done")
				self.gui.write_tag_in_progress = False
			if not self.gui.write_tag_in_progress:
				self.gui.tag_write_count = 0
				self.gui.write_tag_in_progress = True
				shooter(write_tag_go)

class SubLyricsBox:

	def __init__(self, tauon: Tauon) -> None:
		self.ddt:             TDraw = tauon.ddt
		self.gui:            GuiVar = tauon.gui
		self.inp:             Input = tauon.inp
		self.coll                   = tauon.coll
		self.fields:         Fields = tauon.fields
		self.prefs:           Prefs = tauon.prefs
		self.colours:  ColoursClass = tauon.colours
		self.window_size: list[int] = tauon.window_size
		self.sub_lyrics_a: TextBox2 = tauon.sub_lyrics_a
		self.sub_lyrics_b: TextBox2 = tauon.sub_lyrics_b
		self.active:           bool = False
		self.target_track: TrackClass | None = None
		self.active_field:      int = 1

	def activate(self, track: TrackClass) -> None:
		self.active = True
		self.gui.box_over = True
		self.target_track = track

		self.sub_lyrics_a.text = self.prefs.lyrics_subs.get(self.target_track.artist, "")
		self.sub_lyrics_b.text = self.prefs.lyrics_subs.get(self.target_track.title, "")

		if not self.sub_lyrics_a.text:
			self.sub_lyrics_a.text = self.target_track.artist
		if not self.sub_lyrics_b.text:
			self.sub_lyrics_b.text = self.target_track.title

	def render(self) -> None:
		if not self.active:
			return

		if self.gui.level_2_click:
			self.inp.mouse_click = True
		self.gui.level_2_click = False

		w = 400 * self.gui.scale
		h = 155 * self.gui.scale
		x = int(self.window_size[0] / 2) - int(w / 2)
		y = int(self.window_size[1] / 2) - int(h / 2)

		self.ddt.rect_a((x - 2 * self.gui.scale, y - 2 * self.gui.scale), (w + 4 * self.gui.scale, h + 4 * self.gui.scale), self.colours.box_border)
		self.ddt.rect_a((x, y), (w, h), self.colours.box_background)
		self.ddt.text_background_colour = self.colours.box_background

		if self.inp.key_esc_press or ((self.inp.mouse_click or self.inp.right_click or self.inp.level_2_right_click) and not self.coll((x, y, w, h))):
			self.active = False
			self.gui.box_over = False

			if self.sub_lyrics_a.text and self.sub_lyrics_a.text != self.target_track.artist:
				self.prefs.lyrics_subs[self.target_track.artist] = self.sub_lyrics_a.text
			elif self.target_track.artist in self.prefs.lyrics_subs:
				del self.prefs.lyrics_subs[self.target_track.artist]

			if self.sub_lyrics_b.text and self.sub_lyrics_b.text != self.target_track.title:
				self.prefs.lyrics_subs[self.target_track.title] = self.sub_lyrics_b.text
			elif self.target_track.title in self.prefs.lyrics_subs:
				del self.prefs.lyrics_subs[self.target_track.title]

		self.ddt.text((x + 10 * self.gui.scale, y + 8 * self.gui.scale), _("Substitute Lyric Search"), self.colours.grey(230), 213)

		y += round(35 * self.gui.scale)
		x += round(23 * self.gui.scale)

		xx = x
		xx += self.ddt.text(
			(x + round(0 * self.gui.scale), y + round(0 * self.gui.scale)), _("Substitute"), self.colours.box_text_label, 212)
		xx += round(6 * self.gui.scale)
		self.ddt.text((xx, y + round(0 * self.gui.scale)), self.target_track.artist, self.colours.box_sub_text, 312)

		y += round(19 * self.gui.scale)
		xx = x
		xx += self.ddt.text((xx + round(0 * self.gui.scale), y + round(0 * self.gui.scale)), _("with"), self.colours.box_text_label, 212)
		xx += round(6 * self.gui.scale)
		rect1 = (xx, y, round(250 * self.gui.scale), round(17 * self.gui.scale))
		self.fields.add(rect1)
		self.ddt.bordered_rect(rect1, self.colours.box_background, self.colours.box_text_border, round(1 * self.gui.scale))
		if (self.coll(rect1) and self.inp.mouse_click) or (self.inp.key_tab_press and self.active_field == 2):
			self.active_field = 1
			self.inp.key_tab_press = False

		self.sub_lyrics_a.draw(
			xx + round(4 * self.gui.scale), y, self.colours.box_input_text, self.active_field == 1,
			width=rect1[2] - 8 * self.gui.scale)

		y += round(28 * self.gui.scale)

		xx = x
		xx += self.ddt.text(
			(x + round(0 * self.gui.scale), y + round(0 * self.gui.scale)), _("Substitute"), self.colours.box_text_label, 212)
		xx += round(6 * self.gui.scale)
		self.ddt.text((xx, y + round(0 * self.gui.scale)), self.target_track.title, self.colours.box_sub_text, 312)

		y += round(19 * self.gui.scale)
		xx = x
		xx += self.ddt.text((xx + round(0 * self.gui.scale), y + round(0 * self.gui.scale)), _("with"), self.colours.box_text_label, 212)
		xx += round(6 * self.gui.scale)
		rect1 = (xx, y, round(250 * self.gui.scale), round(16 * self.gui.scale))
		self.fields.add(rect1)
		if (self.coll(rect1) and self.inp.mouse_click) or (self.inp.key_tab_press and self.active_field == 1):
			self.active_field = 2
		# ddt.rect(rect1, [40, 40, 40, 255], True)
		self.ddt.bordered_rect(rect1, self.colours.box_background, self.colours.box_text_border, round(1 * self.gui.scale))
		self.sub_lyrics_b.draw(
			xx + round(4 * self.gui.scale), y, self.colours.box_input_text, self.active_field == 2, width=rect1[2] - 8 * self.gui.scale)

class ExportPlaylistBox:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon        = tauon
		self.ddt          = tauon.ddt
		self.gui          = tauon.gui
		self.inp          = tauon.inp
		self.coll         = tauon.coll
		self.draw         = tauon.draw
		self.pctl         = tauon.pctl
		self.prefs        = tauon.prefs
		self.fields       = tauon.fields
		self.colours      = tauon.colours
		self.pref_box     = tauon.pref_box
		self.window_size  = tauon.window_size
		self.show_message = tauon.show_message
		self.active = False
		self.playlist_id = 0
		self.directory_text_box = TextBox2(tauon)

		# self.default = {
		# 	"path": self.prefs.playlist_folder_path if self.prefs.playlist_folder_path else ( str(tauon.dirs.music_directory) if tauon.dirs.music_directory else str(tauon.dirs.user_directory / "playlists") ),
		# 	"type": "xspf",
		# 	"relative": False,
		# 	"auto": False,
		# 	"auto_imp": False,
		# }

	def activate(self, playlist_index: int) -> None:
		"""Runs when the playlist export menu is opened"""
		self.active = True
		self.gui.box_over = True

		playlist = self.pctl.multi_playlist[playlist_index]
		id = playlist.uuid_int
		self.playlist_id = id

		if not playlist.playlist_file:
			playlist.playlist_file = self.suggest_default_playlist_target(playlist)

	def suggest_default_playlist_target(self, playlst: TauonPlaylist) -> str:
		if self.prefs.playlist_folder_path:
			path = str(self.prefs.playlist_folder_path)
			if not path.endswith("/") and not path.endswith("\\"):
				path += "/"
			return path
		if self.tauon.dirs.music_directory:
			return str(self.tauon.dirs.music_directory) + "/"
		return str(self.tauon.dirs.user_directory / "playlists/")

	def render(self) -> None:
		"""Runs every frame that the playlist export menu is open.
		also deals with the export entry logic.
		"""
		if not self.active:
			return

		gui = self.tauon.gui
		ddt = self.tauon.ddt
		colours = self.tauon.colours

		w = 500 * gui.scale
		h = 180 * gui.scale
		x = int(self.window_size[0] / 2) - int(w / 2)
		y = int(self.window_size[1] / 2) - int(h / 2)

		ddt.rect_a((x - 2 * gui.scale, y - 2 * gui.scale), (w + 4 * gui.scale, h + 4 * gui.scale), colours.box_border)
		ddt.rect_a((x, y), (w, h), colours.box_background)
		ddt.text_background_colour = colours.box_background

		playlist_id = self.playlist_id
		pl = self.pctl.id_to_pl(playlist_id)

		if pl is None or self.inp.key_esc_press or ((self.inp.mouse_click or gui.level_2_click or self.inp.right_click or self.inp.level_2_right_click) and not self.coll(
				(x, y, w, h))):
			self.active = False
			gui.box_over = False

		playlist = self.pctl.multi_playlist[pl]

		# Title
		ddt.text((x + 10 * gui.scale, y + 8 * gui.scale), _("Import/Export Playlist"), colours.grey(230), 213)

		# Path entry
		x += round(15 * gui.scale)
		y += round(25 * gui.scale)
		ddt.text((x, y + 8 * gui.scale), _("Target folder or file"), colours.grey(230), 11)
		y += round(30 * gui.scale)
		rect1 = (x, y, round(450 * gui.scale), round(16 * gui.scale))
		self.fields.add(rect1)
		ddt.bordered_rect(rect1, colours.box_background, colours.box_text_border, round(1 * gui.scale))

		self.directory_text_box.text = playlist.playlist_file
		self.directory_text_box.draw(
			x + round(4 * gui.scale), y, colours.box_input_text, True,
			width=rect1[2] - 8 * gui.scale, click=gui.level_2_click)

		text = self.directory_text_box.text
		playlist.playlist_file = text
		root, ext = os.path.splitext(text)

		xx = x + rect1[2]
		yy = y + rect1[3] + round(3 * gui.scale)
		if text.endswith("/") or text.endswith("\\"):
			if Path(self.pctl.resolve_full_playlist_path(playlist)).exists():
				ddt.text((xx, yy, 1), _("Will overwrite existing file: ") + f" {self.pctl.resolve_full_playlist_path(playlist, get_name=True)}", ColourRGBA(80, 230, 80, 255), 10)
				yy += round(13 * gui.scale)
			else:
				ddt.text((xx, yy, 1), _("Will save with playlist name:") + f" {self.pctl.resolve_full_playlist_path(playlist, get_name=True)}", colours.grey(190), 10)
				yy += round(13 * gui.scale)
		elif not ext:
			ddt.text((xx, yy, 1), _("No file extension?"), colours.grey(190), 10)
			yy += round(13 * gui.scale)
		elif ext:
			if playlist.export_type == "xspf" and ext.lower() != ".xspf":
				ddt.text((xx, yy, 1), _("Incorrect extension?"), colours.grey(190), 10)
				yy += round(13 * gui.scale)
			if playlist.export_type == "m3u" and ext.lower() not in (".m3u", ".m3u8"):
				ddt.text((xx, yy, 1), _("Incorrect extension?"), colours.grey(190), 10)
				yy += round(13 * gui.scale)
		if not Path(self.pctl.resolve_full_playlist_path(playlist)).parent.is_dir():
			ddt.text((xx, yy, 1), _("Will create directory"), colours.grey(190), 10)

		y += round(30 * gui.scale)
		if playlist.playlist_file.lower().endswith(".xspf"):
			playlist.export_type = "xspf"
		if playlist.playlist_file.lower().endswith(".m3u") or playlist.playlist_file.lower().endswith(".m3u8"):
			playlist.export_type = "m3u"

		old = playlist.export_type
		if self.pref_box.toggle_square(x, y, playlist.export_type == "xspf", "XSPF", gui.level_2_click):
			playlist.export_type = "xspf"
		if self.pref_box.toggle_square(x + round(80 * gui.scale), y, playlist.export_type == "m3u", "M3U", gui.level_2_click):
			playlist.export_type = "m3u"

		# fix ext if user changed it
		new = playlist.export_type
		if old != new and ext and ext in (".m3u", ".xspf", ".m3u8"):
			path = Path(text).with_suffix("." + playlist.export_type)
			playlist.playlist_file = str(path)

		y += round(30 * gui.scale)
		playlist.relative_export = self.pref_box.toggle_square(
			x, y, playlist.relative_export, _("Use relative paths"),
			gui.level_2_click)
		ww = ddt.get_text_w(_("Use relative paths"), 211)
		if self.draw.button(_("?"), x + ww + round(45*gui.scale), y - (3*gui.scale), press=gui.level_2_click):
			self.show_message(
						_("Enable relative paths when keeping playlist files together with audio"),
						_("Disable to move playlist files while keeping audio in one location"))


		y += round(30 * gui.scale)
		playlist.auto_export = self.pref_box.toggle_square(x, y, playlist.auto_export, _("Auto-export"), gui.level_2_click)
		playlist.auto_import = self.pref_box.toggle_square(x + round(130*gui.scale), y, playlist.auto_import, _("Auto-import"), gui.level_2_click)


		y += round(0 * gui.scale)
		ww = ddt.get_text_w(_("Export"), 211)
		x = ((int(self.window_size[0] / 2) - int(w / 2)) + w) - (ww + round(40 * gui.scale))

		if self.draw.button(_("Export"), x, y - (2*gui.scale), press=gui.level_2_click):
			self.run_export(playlist_id, warnings=True)

	def run_export(self, id, warnings: bool = True) -> None:
		logging.info("Exporting playlist")

		# Fetch corresponding TauonPlaylist object
		pl = None
		pl = self.pctl.id_to_pl(id)
		if pl is None:
			return
		playlist = self.pctl.multi_playlist[pl]

		# Resolve full path
		path = Path(self.pctl.resolve_full_playlist_path(playlist))
		logging.info(f"Export path: {path}")

		if not path.exists():
			logging.warning("Path does not exist, attempting to create")

		try:
			if not path.parent.is_dir():
				path.parent.mkdir(parents=True)
		except PermissionError:
			logging.error("Export failed, cannot create dirs due to permissions")  # noqa: TRY400
			return



		target = ""
		try:
			if playlist.export_type == "xspf":
				target = self.tauon.export_xspf(self.pctl.id_to_pl(id), pl_file=path, relative=playlist.relative_export)
			if playlist.export_type == "m3u":
				target = self.tauon.export_m3u(self.pctl.id_to_pl(id), pl_file=path, relative=playlist.relative_export)
		except PermissionError:
			logging.error("Export failed due to permissions")  # noqa: TRY400

		if target and isinstance(target, Path):
			playlist.file_size = target.stat().st_size
			playlist.playlist_file = str( target )

		if warnings and target != 1:
			self.show_message(_("Playlist exported"), str(target), mode="done")

class SearchOverlay:


	def __init__(self, tauon: Tauon) -> None:
		self.tauon:    Tauon = tauon
		self.ddt:      TDraw = tauon.ddt
		self.gui:     GuiVar = tauon.gui
		self.inp:      Input = tauon.inp
		self.coll            = tauon.coll
		self.pctl: PlayerCtl = tauon.pctl
		self.prefs:    Prefs = tauon.prefs
		self.fields:  Fields = tauon.fields
		self.window_size: list[int] = tauon.window_size
		self.worker2_lock  = tauon.worker2_lock
		self.show_message  = tauon.show_message
		self.smooth_scroll: SmoothScroll = tauon.smooth_scroll

		self.active: bool = False
		self.search_text: TextBox = TextBox(tauon)

		self.results: list[tuple[int, list[int | str | None]]] = []
		self.searched_text: str = ""
		self.on: int = 0
		self.force_select: int = -1
		self.old_mouse = [0, 0]
		self.sip: bool = False
		self.delay_enter: bool = False
		self.last_animate_time: float = 0
		self.animate_timer: Timer = Timer(100)
		self.input_timer: Timer = Timer(100)
		self.all_folders: bool = False

	def clear(self) -> None:
		self.search_text.text = ""
		self.results.clear()
		self.searched_text = ""
		self.on = 0
		self.all_folders = False

	def click_artist(self, name: str, get_list: bool = False, search_lists: list[list[int]] | None = None) -> list[int] | None:
		playlist: list[int] = []

		if search_lists is None:
			search_lists = []
			for pl in self.pctl.multi_playlist:
				search_lists.append(pl.playlist_ids)

		for pl in search_lists:
			for item in pl:
				tr = self.pctl.master_library[item]
				n = name.lower()
				if tr.artist.lower() == n \
						or tr.album_artist.lower() == n \
						or (tr.artists is not None and name in tr.artists):
					if item not in playlist:
						playlist.append(item)

		if get_list:
			return playlist

		self.pctl.multi_playlist.append(self.tauon.pl_gen(
			title=_("Artist: ") + name,
			playlist_ids=copy.deepcopy(playlist),
			hide_title=False))

		if self.gui.combo_mode:
			self.tauon.exit_combo()
		self.pctl.switch_playlist(len(self.pctl.multi_playlist) - 1)
		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "a\"" + name + "\""

		self.inp.key_return_press = False
		return None

	def click_year(self, name, get_list: bool = False) -> list[int] | None:
		playlist: list [int] = []
		for pl in self.pctl.multi_playlist:
			for item in pl.playlist_ids:
				if name in self.pctl.master_library[item].date and item not in playlist:
					playlist.append(item)

		if get_list:
			return playlist

		self.pctl.multi_playlist.append(self.tauon.pl_gen(
			title=_("Year: ") + name,
			playlist_ids=copy.deepcopy(playlist),
			hide_title=False))

		if self.gui.combo_mode:
			self.tauon.exit_combo()

		self.pctl.switch_playlist(len(self.pctl.multi_playlist) - 1)
		self.inp.key_return_press = False
		return None

	def click_composer(self, name: str, get_list: bool = False) -> list[int] | None:
		playlist: list[int] = []
		for pl in self.pctl.multi_playlist:
			for item in pl.playlist_ids:
				if self.pctl.master_library[item].composer.lower() == name.lower():
					if item not in playlist:
						playlist.append(item)

		if get_list:
			return playlist

		self.pctl.multi_playlist.append(self.tauon.pl_gen(
			title=_("Composer: ") + name,
			playlist_ids=copy.deepcopy(playlist),
			hide_title=False))

		if self.gui.combo_mode:
			self.tauon.exit_combo()

		self.pctl.switch_playlist(len(self.pctl.multi_playlist) - 1)

		self.inp.key_return_press = False
		return None

	def click_meta(self, name: str, get_list: bool = False, search_lists: list[list[int]] | None = None) -> list[int] | None:
		if search_lists is None:
			search_lists = []
			for pl in self.pctl.multi_playlist:
				search_lists.append(pl.playlist_ids)

		playlist: list[int] = []
		for pl in search_lists:
			for item in pl:
				if name in self.pctl.master_library[item].parent_folder_path and item not in playlist:
					playlist.append(item)

		if get_list:
			return playlist

		self.pctl.multi_playlist.append(self.tauon.pl_gen(
			title=os.path.basename(name).upper(),
			playlist_ids=copy.deepcopy(playlist),
			hide_title=False))

		if self.gui.combo_mode:
			self.tauon.exit_combo()

		self.pctl.switch_playlist(len(self.pctl.multi_playlist) - 1)

		self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "p\"" + name + "\""

		self.inp.key_return_press = False
		return None

	def click_genre(self, name: str, get_list: bool = False, search_lists: list[list[int]] | None = None) -> list[int] | None:
		playlist: list[int] = []

		if search_lists is None:
			search_lists = []
			for pl in self.pctl.multi_playlist:
				search_lists.append(pl.playlist_ids)

		include_multi = False
		if name.endswith("+") or not self.prefs.sep_genre_multi:
			name = name.rstrip("+")
			include_multi = True

		for pl in search_lists:
			for item in pl:
				track = self.pctl.master_library[item]
				if track.genre.lower().replace("-", "").replace(" ", "") == name.lower().replace("-", "").replace(" ", ""):
					if item not in playlist:
						playlist.append(item)
				elif include_multi and ("/" in track.genre or "," in track.genre or ";" in track.genre):
					for split in track.genre.replace(",", "/").replace(";", "/").split("/"):
						split = split.strip()
						if name.lower().replace("-", "").replace(" ", "") == split.lower().replace("-", "").replace(" ", ""):
							if item not in playlist:
								playlist.append(item)

		if get_list:
			return playlist

		self.pctl.multi_playlist.append(self.tauon.pl_gen(
			title=_("Genre: ") + name,
			playlist_ids=copy.deepcopy(playlist),
			hide_title=False))

		if self.gui.combo_mode:
			self.tauon.exit_combo()

		self.pctl.switch_playlist(len(self.pctl.multi_playlist) - 1)

		if include_multi:
			self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "gm\"" + name + "\""
		else:
			self.pctl.gen_codes[self.pctl.pl_to_id(len(self.pctl.multi_playlist) - 1)] = "g=\"" + name + "\""

		self.inp.key_return_press = False
		return None

	def click_album(self, index) -> None:
		self.pctl.jump(index)
		if self.gui.combo_mode:
			self.tauon.exit_combo()

		self.pctl.show_current()
		self.inp.key_return_press = False

	def queue_track_result(self, track_id: int, playlist_id: int) -> None:
		pl = self.pctl.id_to_pl(playlist_id)
		if pl is None:
			return

		playlist = self.pctl.multi_playlist[pl].playlist_ids
		if track_id not in playlist:
			return

		queue_object = queue_item_gen(track_id, playlist.index(track_id), playlist_id)
		self.pctl.force_queue.append(queue_object)
		self.tauon.queue_timer_set(queue_object=queue_object)
		if self.prefs.stop_end_queue:
			self.pctl.stop_mode = StopMode.OFF

	def queue_album_result(self, track_id: int, playlist_id: int) -> None:
		pl = self.pctl.id_to_pl(playlist_id)
		if pl is None:
			return

		playlist = self.pctl.multi_playlist[pl].playlist_ids
		if track_id not in playlist:
			return

		self.tauon.add_album_to_queue(track_id, playlist.index(track_id), playlist_id)

	def tracks_for_result(self, item: list[int | str | None]) -> list[int]:
		n = item[0]
		match n:
			case 0:
				if isinstance(item[1], str):
					return self.click_artist(item[1], get_list=True) or []
			case 1:
				if isinstance(item[2], int):
					for k, pl in enumerate(self.pctl.multi_playlist):
						if item[2] in pl.playlist_ids:
							return self.tauon.get_album_from_first_track(pl.playlist_ids.index(item[2]), item[2], k)
			case 2:
				if isinstance(item[2], int):
					return [item[2]]
			case 3:
				if isinstance(item[1], str):
					return self.click_genre(item[1], get_list=True) or []
			case 5:
				if isinstance(item[1], str):
					return self.click_meta(item[1], get_list=True) or []
			case 6:
				if isinstance(item[1], str):
					return self.click_composer(item[1], get_list=True) or []
			case 7:
				if isinstance(item[1], str):
					return self.click_year(item[1], get_list=True) or []
			case 8:
				if isinstance(item[3], int):
					pl = self.pctl.id_to_pl(item[3])
					if pl is not None:
						return list(self.pctl.multi_playlist[pl].playlist_ids)
		return []

	def toast_playlist_add(self, added_count: int) -> None:
		playlist_name = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].title
		if added_count == 1:
			text = _("Added 1 track to playlist: {name}").format(name=playlist_name)
		else:
			text = _("Added {N} tracks to playlist: {name}").format(N=added_count, name=playlist_name)
		self.tauon.toast(text, duration=2.5)

	def render_help_legend(self) -> None:
		gui = self.gui
		icon_size = round(26 * gui.scale)
		margin = round(18 * gui.scale)
		icon_rect = (
			self.window_size[0] - margin - icon_size,
			self.window_size[1] - margin - icon_size,
			icon_size,
			icon_size,
		)
		self.fields.add(icon_rect)

		hover = self.coll(icon_rect)
		icon_text = ColourRGBA(210, 210, 215, 105)
		if hover:
			icon_text = ColourRGBA(245, 245, 245, 230)

		cx = icon_rect[0] + icon_size // 2
		self.ddt.text(
			(cx, icon_rect[1] + round(3 * gui.scale), 2),
			"?",
			icon_text,
			214,
		)

		if not hover:
			return

		lines = [
			(_("Go / open:"), _("left-click or press Enter.")),
			(_("Show / reveal:"), _("right-click or press Shift+Enter.")),
			(_("Add to current playlist:"), _("Ctrl+left-click.")),
			(_("Add to queue:"), _("middle-click (track or album).")),
		]
		font = 13
		label_font = 213
		pad = round(12 * gui.scale)
		label_gap = round(5 * gui.scale)
		line_h = round(22 * gui.scale)
		width = max(
			self.ddt.get_text_w(label, label_font) + label_gap + self.ddt.get_text_w(text, font)
			for label, text in lines
		) + pad * 2
		height = line_h * len(lines) + pad * 2
		tooltip_x = max(margin, self.window_size[0] - margin - width)
		tooltip_y = max(margin, icon_rect[1] - height - round(10 * gui.scale))
		tooltip_bg = ColourRGBA(18, 18, 22, 245)

		self.ddt.rect((tooltip_x, tooltip_y, width, height), tooltip_bg)
		for i, (label, text) in enumerate(lines):
			line_x = tooltip_x + pad
			line_y = tooltip_y + pad + i * line_h
			label_width = self.ddt.text(
				(line_x, line_y),
				label,
				ColourRGBA(245, 245, 245, 255),
				label_font,
				bg=tooltip_bg,
			)
			self.ddt.text(
				(line_x + label_width + label_gap, line_y),
				text,
				ColourRGBA(245, 245, 245, 255),
				font,
				bg=tooltip_bg,
			)

	def render(self) -> None:
		prefs = self.prefs
		inp   = self.inp
		gui   = self.gui
		control_down = inp.key_ctrl_down or inp.key_rctrl_down

		if self.active is False:
			# Activate search overlay on key presses
			if prefs.search_on_letter and inp.input_text and gui.layer_focus == 0 and \
					not inp.key_lalt and not inp.key_ralt and \
					not control_down and not self.tauon.radiobox.active and not self.tauon.rename_track_box.active and \
					not gui.quick_search_mode and not self.tauon.pref_box.enabled and not gui.rename_playlist_box \
					and not gui.rename_folder_box and inp.input_text.isalnum() and not gui.box_over \
					and not self.tauon.trans_edit_box.active and not gui.timed_lyrics_editing_now:

				# Divert to artist list if mouse over
				if gui.lsp and prefs.left_panel_mode == "artist list" and gui.lsp_x + 2 < inp.mouse_position[0] < gui.lsp_x + gui.lspw \
						and gui.panelY < inp.mouse_position[1] < self.window_size[1] - gui.panelBY:
					self.tauon.artist_list_box.locate_artist_letter(inp.input_text)
					return

				self.tauon.activate_search_overlay()
				self.old_mouse = copy.deepcopy(inp.mouse_position)

		if self.active:
			x = 0
			y = 0
			w = self.window_size[0]
			h = self.window_size[1]

			if gui.keymaps.test("add-to-queue"):
				inp.input_text = ""

			if inp.backspace_press:
				# self.searched_text = ""
				# self.results.clear()

				if len(self.search_text.text) - inp.backspace_press < 1:
					self.active = False
					self.search_text.text = ""
					self.results.clear()
					self.searched_text = ""
					return

			if inp.key_esc_press:
				if self.delay_enter:
					self.delay_enter = False
				else:
					self.active = False
					self.search_text.text = ""
					self.results.clear()
					self.searched_text = ""
					return

			if gui.level_2_click and inp.mouse_position[0] > 350 * gui.scale:
				self.active = False
				self.search_text.text = ""

			mouse_change = False
			if not point_proximity_test(self.old_mouse, inp.mouse_position, 25):
				mouse_change = True
			# mouse_change = True

			overlay_background = ColourRGBA(12, 12, 12, 255)
			track_in_bar_colour = ColourRGBA(244, 209, 66, 255)
			self.ddt.rect((x, y, w, h), ColourRGBA(3, 3, 3, 235))
			if control_down:
				badge_margin = round(14 * gui.scale) + 5
				badge_pad = round(5 * gui.scale)
				badge_h = round(20 * gui.scale)
				badge_text_y = round(18 * gui.scale)
				icon_size = round(8 * gui.scale)
				icon_thickness = max(round(2 * gui.scale), 1)
				icon_gap = round(5 * gui.scale)
				badge_text = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].title
				max_badge_w = max(round(120 * gui.scale), self.window_size[0] - badge_margin * 2)
				badge_w = min(
					icon_size + icon_gap + self.ddt.get_text_w(badge_text, 312) + badge_pad * 2,
					max_badge_w,
				)
				badge_rect = (self.window_size[0] - badge_margin - badge_w, badge_margin, badge_w, badge_h)
				self.ddt.rect(badge_rect, track_in_bar_colour)
				icon_x = badge_rect[0] + badge_pad
				icon_y = badge_rect[1] + (badge_h - icon_size) // 2
				self.ddt.rect(
					(icon_x, icon_y + (icon_size - icon_thickness) // 2, icon_size, icon_thickness),
					overlay_background,
				)
				self.ddt.rect(
					(icon_x + (icon_size - icon_thickness) // 2, icon_y, icon_thickness, icon_size),
					overlay_background,
				)
				self.ddt.text_background_colour = track_in_bar_colour
				self.ddt.text(
					(icon_x + icon_size + icon_gap, badge_text_y),
					badge_text,
					overlay_background,
					312,
					max_w=badge_rect[2] - badge_pad * 2 - icon_size - icon_gap,
					bg=track_in_bar_colour,
				)
			self.ddt.text_background_colour = overlay_background


			input_text_x = 80 * gui.scale
			highlight_x = 30 * gui.scale
			thumbnail_rx = 100 * gui.scale
			text_lx = 120 * gui.scale

			s_font = 15
			s_b_font = 214
			b_font = 215

			if self.window_size[0] < 400 * gui.scale:
				input_text_x = 30 * gui.scale
				highlight_x = 4 * gui.scale
				thumbnail_rx = 65 * gui.scale
				text_lx = 80 * gui.scale
				s_font = 415
				s_b_font = 514
				d_font = 515

			#album_art_size_s = 0 * gui.scale

			# Search active animation
			if self.sip:
				x = round(15 * gui.scale)
				y = x
				s = round(7 * gui.scale)
				g = round(4 * gui.scale)

				t = self.animate_timer.get()
				if abs(t - self.last_animate_time) > 0.3:
					self.animate_timer.set()
					t = 0

				self.last_animate_time = t

				for item in range(4):
					a = 100
					if round(t * 14) % 4 == item:
						a = 255
					colour = ColourRGBA(140, 100, 255, a)

					self.ddt.rect((x, y, s, s), colour)
					x += g + s

				gui.update += 1

			# No results found message
			elif not self.results and len(self.search_text.text) > 1:
				if self.input_timer.get() > 0.5 and not self.sip:
					self.ddt.text((self.window_size[0] // 2, 200 * gui.scale, 2), _("No results found"), ColourRGBA(250, 250, 250, 255), 216,
						bg=ColourRGBA(12, 12, 12, 255))

			self.search_text.draw(input_text_x, 60 * gui.scale, ColourRGBA(230, 230, 230, 255), True, False, 30,
				self.window_size[0] - 100, big=True, click=gui.level_2_click, selection_height=30)

			if inp.input_text or inp.key_backspace_press:
				self.input_timer.set()

				gui.update += 1
			elif self.input_timer.get() >= 0.20 and \
					(len(self.search_text.text) > 1 or (len(self.search_text.text) == 1 and ord(self.search_text.text) > 128)) \
					and self.search_text.text != self.searched_text:
				self.sip = True
				if self.worker2_lock.locked():
					try:
						self.worker2_lock.release()
					except RuntimeError as e:
						if str(e) == "release unlocked lock":
							logging.error("RuntimeError: Attempted to release already unlocked worker2_lock")  # noqa: TRY400
						else:
							logging.exception("Unknown RuntimeError trying to release worker2_lock")
					except Exception:
						logging.exception("Unknown error trying to release worker2_lock")

			if self.input_timer.get() < 10:
				gui.frame_callback_list.append(TestTimer(0.1))

			yy = 110 * gui.scale

			if inp.key_down_press:
				self.force_select += 1
				if self.force_select > 4:
					self.on = self.force_select - 4
				self.force_select = min(self.force_select, len(self.results) - 1)
				self.old_mouse = copy.deepcopy(inp.mouse_position)

			if inp.key_up_press:
				if self.force_select > -1:
					self.force_select -= 1
					self.force_select = max(self.force_select, 0)

					if self.force_select < self.on + 4:
						self.on = self.force_select - 4
						self.on = max(self.on, 0)

				self.old_mouse = copy.deepcopy(inp.mouse_position)

			scroll_distance = self.smooth_scroll.scroll("search overlay")
			self.on = max( (self.on - scroll_distance), 0)
			self.force_select = max( (self.force_select - scroll_distance), 0)

			enter = False

			if self.delay_enter and not self.sip and self.search_text.text == self.searched_text:
				enter = True
				self.delay_enter = False
			elif inp.key_return_press:
				if self.results:
					enter = True
					self.delay_enter = False
				elif self.sip or self.input_timer.get() < 0.25:
					self.delay_enter = True
				else:
					enter = True
					self.delay_enter = False

			inp.key_return_press = False

			bar_colour = ColourRGBA(140, 80, 240, 255)

			self.on = max(self.on, 0)
			self.on = min(len(self.results) - 1, self.on)

			full_count = 0

			sec = False

			p = -1

			if self.on > 4:
				p += self.on - 4
			p = self.on - 1
			clear = False

			for i, item in enumerate(self.results):
				p += 1

				if p > len(self.results) - 1:
					break

				item: list[int] = self.results[p]

				fade = 1
				selected = self.on
				if self.force_select > -1:
					selected = self.force_select

				#logging.info(selected)

				if selected != p:
					fade = 0.8

				start = yy

				n = item[0]

				names = {
					0: "Artist",
					1: "Album",
					2: "Track",
					3: "Genre",
					5: "Folder",
					6: "Composer",
					7: "Year",
					8: "Playlist",
					9: "Lyrics",
				}
				type_colours = {
					0:  ColourRGBA(250, 140, 190, 255),  # Artist
					1:  ColourRGBA(250, 140, 190, 255),  # Album
					2:  ColourRGBA(250, 220, 190, 255),  # Track
					3:  ColourRGBA(240, 240, 160, 255),  # Genre
					5:  ColourRGBA(250, 100,  50, 255),   # Folder
					6:  ColourRGBA(180, 250, 190, 255),  # Composer
					7:  ColourRGBA(250, 50,  140, 255),   # Year
					8:  ColourRGBA(100, 210, 250, 255),  # Playlist
					9:  ColourRGBA(250, 220, 190, 255),  # Track from lyrics
				}
				if n not in names:
					name = "NYI"
					colour = ColourRGBA(255, 255, 255, 255)
				else:
					name = names[n]
					colour = type_colours[n]
					colour.a = int(colour.a * fade)

				pad = round(4 * gui.scale)
				height = round(25 * gui.scale)
				if n == 1:
					height = round(50 * gui.scale)
				album_art_size = height


				# Selection bar
				s_rect = (highlight_x, yy, 600 * gui.scale, height + pad + pad - 1)
				self.fields.add(s_rect)
				if fade == 1:
					self.ddt.rect((highlight_x, yy + pad, 4 * gui.scale, height), bar_colour)
				if n in (2,):
					if control_down and item[2] in self.pctl.default_playlist:
						self.ddt.rect((highlight_x + round(5 * gui.scale), yy + pad, 4 * gui.scale, height), track_in_bar_colour)

				# Type text
				if n in (0, 3, 5, 6, 7, 8, 9):
					self.ddt.text((thumbnail_rx, yy + pad + round(3 * gui.scale), 1), names[n], type_colours[n], 214)

				# Thumbnail
				if n in (1, 2):
					thl = thumbnail_rx - album_art_size
					self.ddt.rect((thl, yy + pad, album_art_size, album_art_size), ColourRGBA(50, 50, 50, 150))
					self.tauon.gall_ren.render(self.pctl.get_track(item[2]), (thl, yy + pad), album_art_size)
					if fade != 1:
						self.ddt.rect((thl, yy + pad, album_art_size, album_art_size), ColourRGBA(0, 0, 0, 70))
				# Result text
				if n in (0, 5, 6, 7, 8):  # Bold
					xx = self.ddt.text((text_lx, yy + pad + round(3 * gui.scale)), item[1], ColourRGBA(255, 255, 255, int(255 * fade)), b_font)
				if n in (3,):  # Genre
					xx = self.ddt.text((text_lx, yy + pad + round(3 * gui.scale)), item[1].rstrip("+"), ColourRGBA(255, 255, 255, int(255 * fade)), b_font)
					if item[1].endswith("+"):
						self.ddt.text(
							(xx + text_lx + 13 * gui.scale, yy + pad + round(3 * gui.scale)), _("(Include multi-tag results)"),
							ColourRGBA(255, 255, 255, int(255 * fade) // 2), 313)
				if n in (2,9,):  # Local library track
					track = self.pctl.get_track(item[2])
					title_val = track.title or clean_string(track.filename)
					artist_val = track.artist
					album_val = track.album

					yyy = yy + pad + round(3 * gui.scale)
					text_max_w = max(round(80 * gui.scale), self.window_size[0] - text_lx - round(24 * gui.scale))
					text_max_w = min(round(560 * gui.scale), text_max_w)
					metadata_val = artist_val or album_val
					if metadata_val:
						gap = round(8 * gui.scale)
						by_label = "BY" if artist_val else ""
						by_width = self.ddt.get_text_w(by_label, 212) if by_label else 0
						metadata_width = self.ddt.get_text_w(metadata_val, s_font)
						if by_label:
							metadata_width += by_width + gap
						min_title_width = round(120 * gui.scale)
						if metadata_width + min_title_width + gap > text_max_w:
							metadata_width = max(round(70 * gui.scale), text_max_w - min_title_width - gap)
						title_width = max(round(40 * gui.scale), text_max_w - metadata_width - gap)
						drawn_title_width = self.ddt.text(
							(text_lx, yyy),
							title_val,
							ColourRGBA(255, 255, 255, int(255 * fade)),
							s_b_font,
							max_w=title_width,
						) or 0
						meta_x = text_lx + drawn_title_width + gap
						if by_label:
							self.ddt.text((meta_x, yyy), by_label, ColourRGBA(250, 240, 110, int(255 * fade)), 212)
							meta_x += by_width + gap
						self.ddt.text(
							(meta_x, yyy),
							metadata_val,
							ColourRGBA(250, 250, 250, int(255 * fade)) if artist_val else ColourRGBA(220, 220, 220, int(255 * fade)),
							s_font,
							max_w=max(round(1 * gui.scale), text_max_w - (meta_x - text_lx)),
						)
					else:
						self.ddt.text(
							(text_lx, yyy),
							title_val,
							ColourRGBA(255, 255, 255, int(255 * fade)),
							s_b_font,
							max_w=text_max_w,
						)

				if n in (1,):  # Two line album
					track = self.pctl.master_library[item[2]]
					artist = track.album_artist
					if not artist:
						artist = track.artist

					xx = self.ddt.text((text_lx, yy + pad + round(5 * gui.scale)), item[1], ColourRGBA(255, 255, 255, int(255 * fade)), s_b_font)

					self.ddt.text((text_lx + 5 * gui.scale, yy + 30 * gui.scale), "BY", ColourRGBA(250, 240, 110, int(255 * fade)), 212)
					xx += 8 * gui.scale
					xx += self.ddt.text((text_lx + 30 * gui.scale, yy + 30 * gui.scale), artist, ColourRGBA(250, 250, 250, int(255 * fade)), s_font)

				yy += height + pad + pad

				show = False
				go = False
				extend = False
				queue_result = False
				if self.coll(s_rect) and mouse_change:
					if self.force_select != p:
						self.force_select = p
						gui.update = 2

					if gui.level_2_click:
						if control_down:
							extend = True
						else:
							go = True
							clear = True

					if inp.level_2_right_click:
						show = True
						clear = True

					if inp.middle_click and n in (1, 2, 9):
						queue_result = True
						inp.middle_click = False

				if enter and inp.key_shift_down and fade == 1:
					show = True
					clear = True

				elif enter and fade == 1:
					if inp.key_shift_down or inp.key_shiftr_down:
						show = True
						clear = True
					else:
						go = True
						clear = True

				if queue_result:
					match n:
						case 1:
							self.queue_album_result(item[2], item[3])
						case 2:
							self.queue_track_result(item[2], item[3])
						case 9:
							self.queue_track_result(item[2], item[3])
				elif extend:
					tracks = self.tracks_for_result(item)
					if tracks:
						self.pctl.default_playlist.extend(tracks)
						self.tauon.reload_albums(True)
						self.pctl.notify_database_changed()
						self.toast_playlist_add(len(tracks))
				elif show:
					match n:
						case 0 | 1 | 2 | 3 | 5 | 6 | 7 | 9:
							self.pctl.show_current(index=item[2], playing=False)

						case 8:
							pl = self.pctl.id_to_pl(item[3])
							if pl is not None:
								self.pctl.switch_playlist(pl)
				elif go:
					match n:
						case 0:
							self.click_artist(item[1])
						case 1 | 2 | 9:
							self.click_album(item[2])
							self.pctl.show_current(index=item[2])
							self.pctl.playlist_view_position = self.pctl.selected_in_playlist
						case 3:
							self.click_genre(item[1])
						case 5:
							self.click_meta(item[1])
						case 6:
							self.click_composer(item[1])
						case 7:
							self.click_year(item[1])
						case 8:
							pl = self.pctl.id_to_pl(item[3])
							if pl is not None:
								self.pctl.switch_playlist(pl)
				if n in (2,9,) and gui.keymaps.test("add-to-queue") and fade == 1:
					self.queue_track_result(item[2], item[3])

				# ----

				# ---
				if i > 40:
					break
				if yy > self.window_size[1] - (100 * gui.scale):
					break

				continue

			if clear:
				self.active = False
				self.search_text.text = ""
				self.results.clear()
				self.searched_text = ""

			if self.active:
				self.render_help_legend()

class MessageBox:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon       = tauon
		self.ddt         = tauon.ddt
		self.gui         = tauon.gui
		self.inp         = tauon.inp
		self.draw        = tauon.draw
		self.colours     = tauon.colours
		self.window_size = tauon.window_size
		bag = tauon.bag
		self.message_info_icon     = asset_loader(bag, bag.loaded_asset_dc, "notice.png")
		self.message_warning_icon  = asset_loader(bag, bag.loaded_asset_dc, "warning.png")
		self.message_tick_icon     = asset_loader(bag, bag.loaded_asset_dc, "done.png")
		self.message_arrow_icon    = asset_loader(bag, bag.loaded_asset_dc, "ext.png")
		self.message_error_icon    = asset_loader(bag, bag.loaded_asset_dc, "error.png")
		self.message_bubble_icon   = asset_loader(bag, bag.loaded_asset_dc, "bubble.png")
		self.message_download_icon = asset_loader(bag, bag.loaded_asset_dc, "ddl.png")

	def get_rect(self) -> tuple[int, int, float, int]:
		w1 = self.ddt.get_text_w(self.gui.message_text, 15) + 74 * self.gui.scale
		w2 = self.ddt.get_text_w(self.gui.message_subtext, 12) + 74 * self.gui.scale
		w3 = self.ddt.get_text_w(self.gui.message_subtext2, 12) + 74 * self.gui.scale
		w = max(w1, w2, w3)

		w = max(w, 210 * self.gui.scale)

		h = round(60 * self.gui.scale)
		if self.gui.message_subtext2:
			h += round(15 * self.gui.scale)

		x = int(self.window_size[0] / 2) - int(w / 2)
		y = int(self.window_size[1] / 2) - int(h / 2)

		return x, y, w, h

	def render(self) -> None:
		inp = self.inp
		gui = self.gui
		ddt = self.ddt
		if inp.mouse_click or inp.key_return_press or inp.right_click or inp.key_esc_press or inp.backspace_press \
				or gui.keymaps.test("quick-find") or (inp.k_input and self.tauon.message_box_min_timer.get() > 1.2):

			if not inp.key_focused and self.tauon.message_box_min_timer.get() > 0.4:
				gui.message_box = False
				gui.update += 1
				inp.key_return_press = False

		x, y, w, h = self.get_rect()

		ddt.rect_a((x - 2 * gui.scale, y - 2 * gui.scale), (w + 4 * gui.scale, h + 4 * gui.scale),
			self.colours.box_text_border)
		ddt.rect_a((x, y), (w, h), self.colours.message_box_bg)

		ddt.text_background_colour = self.colours.message_box_bg

		if gui.message_mode == "info":
			self.message_info_icon.render(x + 14 * gui.scale, y + int(h / 2) - int(self.message_info_icon.h / 2) - 1)
		elif gui.message_mode == "warning":
			self.message_warning_icon.render(x + 14 * gui.scale, y + int(h / 2) - int(self.message_info_icon.h / 2) - 1)
		elif gui.message_mode == "done":
			self.message_tick_icon.render(x + 14 * gui.scale, y + int(h / 2) - int(self.message_info_icon.h / 2) - 1)
		elif gui.message_mode == "arrow":
			self.message_arrow_icon.render(x + 14 * gui.scale, y + int(h / 2) - int(self.message_info_icon.h / 2) - 1)
		elif gui.message_mode == "download":
			self.message_download_icon.render(x + 14 * gui.scale, y + int(h / 2) - int(self.message_info_icon.h / 2) - 1)
		elif gui.message_mode == "error":
			self.message_error_icon.render(x + 14 * gui.scale, y + int(h / 2) - int(self.message_error_icon.h / 2) - 1)
		elif gui.message_mode == "bubble":
			self.message_bubble_icon.render(x + 14 * gui.scale, y + int(h / 2) - int(self.message_bubble_icon.h / 2) - 1)
		elif gui.message_mode == "link":
			self.message_info_icon.render(x + 14 * gui.scale, y + int(h / 2) - int(self.message_bubble_icon.h / 2) - 1)
		elif gui.message_mode == "confirm":
			self.message_info_icon.render(x + 14 * gui.scale, y + int(h / 2) - int(self.message_info_icon.h / 2) - 1)
			ddt.text((x + 62 * gui.scale, y + 9 * gui.scale), gui.message_text, self.colours.message_box_text, 15)
			if self.draw.button("Yes", (w // 2 + x) - 70 * gui.scale, y + 32 * gui.scale, w=60*gui.scale):
				gui.message_box = False
				if gui.message_box_confirm_callback:
					gui.message_box_confirm_callback(*gui.message_box_confirm_reference)
			if self.draw.button("No", (w // 2 + x) + 25 * gui.scale, y + 32 * gui.scale, w=60*gui.scale):
				gui.message_box = False
				if gui.message_box_no_callback:
					gui.message_box_no_callback(*gui.message_box_confirm_reference)
			return

		if gui.message_subtext:
			ddt.text((x + 62 * gui.scale, y + 11 * gui.scale), gui.message_text, self.colours.message_box_text, 15)
			if gui.message_mode in ("bubble", "link"):
				link_pa = self.tauon.draw_linked_text((x + 63 * gui.scale, y + (9 + 22) * gui.scale), gui.message_subtext,
					self.colours.message_box_text, 12)
				self.tauon.link_activate(x + 63 * gui.scale, y + (9 + 22) * gui.scale, link_pa)
			else:
				ddt.text((x + 63 * gui.scale, y + (9 + 22) * gui.scale), gui.message_subtext, self.colours.message_box_text,
					12)

			if gui.message_subtext2:
				ddt.text((x + 63 * gui.scale, y + (9 + 42) * gui.scale), gui.message_subtext2, self.colours.message_box_text,
					12)
		else:
			ddt.text((x + 62 * gui.scale, y + 20 * gui.scale), gui.message_text, self.colours.message_box_text, 15)

class PresetDownloadBox:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon = tauon
		self.gui = tauon.gui
		self.inp = tauon.inp
		self.ddt = tauon.ddt
		self.draw = tauon.draw
		self.colours = tauon.colours
		self.window_size = tauon.window_size
		self.active: bool = False
		self.cancel_requested: bool = False
		self.progress: float = 0.0
		self.title: str = ""
		self.status: str = ""
		self.detail: str = ""
		self.done: bool = False
		self.cancel_detail: str = ""

	def start(self, *, title: str, status: str, cancel_detail: str) -> None:
		self.active = True
		self.cancel_requested = False
		self.done = False
		self.progress = 0.0
		self.title = title
		self.status = status
		self.detail = ""
		self.cancel_detail = cancel_detail
		self.gui.update = max(self.gui.update, 2)

	def update(self, progress: float | None, status: str, detail: str = "") -> None:
		if progress is not None:
			self.progress = max(0.0, min(1.0, progress))
		self.status = status
		self.detail = detail
		self.gui.update = max(self.gui.update, 2)

	def finish(self) -> None:
		self.active = False
		self.cancel_requested = False
		self.done = False
		self.gui.update = max(self.gui.update, 2)

	def complete(self, status: str, detail: str = "", toast_text: str | None = None) -> None:
		self.progress = 1.0
		self.status = status
		self.detail = detail
		self.finish()
		if toast_text:
			self.tauon.toast(toast_text)

	def fail(self, status: str, detail: str = "") -> None:
		self.active = True
		self.cancel_requested = False
		self.done = True
		self.status = status
		self.detail = detail
		self.gui.update = max(self.gui.update, 2)

	def cancel(self) -> None:
		self.cancel_requested = True
		self.status = _("Cancelling...")
		self.detail = self.cancel_detail
		self.gui.update = max(self.gui.update, 2)

	def render(self) -> None:
		if not self.active:
			return

		gui = self.gui
		ddt = self.ddt
		scale = gui.scale
		w = round(430 * scale)
		h = round(134 * scale)
		x = int(self.window_size[0] / 2) - int(w / 2)
		y = int(self.window_size[1] / 2) - int(h / 2)

		panel = self.colours.message_box_bg
		border = self.colours.box_text_border
		ddt.rect_a((x - 2 * scale, y - 2 * scale), (w + 4 * scale, h + 4 * scale), border)
		ddt.rect_a((x, y), (w, h), panel)
		ddt.text_background_colour = panel

		ddt.text((x + 22 * scale, y + 16 * scale), self.title, self.colours.message_box_text, 15)
		ddt.text((x + 22 * scale, y + 43 * scale), self.status, self.colours.box_text_label, 12, max_w=w - 44 * scale)
		if self.detail:
			ddt.text((x + 22 * scale, y + 62 * scale), self.detail, self.colours.box_text_label, 12, max_w=w - 44 * scale)

		bar_x = x + round(22 * scale)
		bar_y = y + round(86 * scale)
		bar_w = w - round(44 * scale)
		bar_h = round(10 * scale)
		fill_w = round(bar_w * self.progress)
		ddt.rect((bar_x, bar_y, bar_w, bar_h), self.colours.box_button_background)
		if fill_w > 0:
			ddt.rect((bar_x, bar_y, fill_w, bar_h), self.colours.link_text)
		percent = f"{round(self.progress * 100)}%"
		ddt.text((bar_x + bar_w, bar_y + round(15 * scale), 1), percent, self.colours.box_text_label, 12)

		if self.done:
			label = _("Close")
			press = None
		elif self.cancel_requested:
			label = _("Cancelling...")
			press = False
		else:
			label = _("Cancel")
			press = None
		button_w = round((112 if self.cancel_requested else 74) * scale)
		if self.draw.button(label, x + w - button_w - round(22 * scale), y + h - round(34 * scale), w=button_w, press=press):
			if self.done:
				self.finish()
			else:
				self.cancel()

class NagBox:
	SPLASH_VERSION = "11.0.0"
	RELEASE_NOTES_URL = "https://github.com/Taiko2k/TauonMusicBox/releases"
	DONATE_URL = "https://github.com/sponsors/Taiko2k"
	PATREON_URL = "https://www.patreon.com/taiko2k"
	CHANGELOG_ITEMS = (
		("New layout engine supporting custom layouts!", False),
		("Added rounded corners setting", False),
		("Added lyrics search to global search", False),
		("Added new widget: Spectogram", False),
		("Added new widget: Track Details", False),
		("Added new widget: Compact Gallery", False),
		("Improvements to network buffering", False),
	)

	def __init__(self, tauon: Tauon) -> None:
		self.tauon        = tauon
		self.gui          = tauon.gui
		self.inp          = tauon.inp
		self.ddt          = tauon.ddt
		self.prefs        = tauon.prefs
		self.colours      = tauon.colours
		self.window_size  = tauon.window_size
		self.drawer       = tauon.draw

	def dismiss(self) -> None:
		self.prefs.show_nag = False
		self.gui.update += 1
		self.gui.level_2_click = False
		self.inp.mouse_click = False
		self.inp.key_return_press = False
		self.inp.key_esc_press = False

	def show(self) -> None:
		self.prefs.show_nag = True
		self.gui.update += 1
		self.gui.level_2_click = False
		self.inp.mouse_click = False

	def open_release_notes(self) -> None:
		webbrowser.open(self.RELEASE_NOTES_URL, new=2, autoraise=True)

	def open_donate_link(self) -> None:
		webbrowser.open(self.DONATE_URL, new=2, autoraise=True)

	def thank_donor(self) -> None:
		self.dismiss()
		self.tauon.show_message(_("Yay! Thank you!! 🎉 ✨"), mode="done")

	def draw_left_accent_gradient(self, x: int, y: int, w: int, h: int) -> None:
		top = ColourRGBA(205, 226, 92, 255)
		bottom = ColourRGBA(86, 190, 104, 255)
		steps = max(1, round(h / max(self.gui.scale, 1)))
		for step in range(steps):
			ratio = step / max(steps - 1, 1)
			colour = ColourRGBA(
				round(top.r + (bottom.r - top.r) * ratio),
				round(top.g + (bottom.g - top.g) * ratio),
				round(top.b + (bottom.b - top.b) * ratio),
				255,
			)
			y1 = y + round((h * step) / steps)
			y2 = y + round((h * (step + 1)) / steps)
			self.ddt.rect((x, y1, w, max(1, y2 - y1)), colour)

	def draw(self) -> None:
		w = round(640 * self.gui.scale)
		h = round(384 * self.gui.scale)
		x = int(self.window_size[0] / 2) - int(w / 2)
		y = int(self.window_size[1] / 2) - int(h / 2)

		if self.inp.key_esc_press or self.inp.key_return_press or self.inp.backspace_press:
			self.dismiss()
			return

		if self.gui.level_2_click and not self.tauon.coll((x, y, w, h)):
			self.dismiss()
			return

		scale = self.gui.scale
		accent = self.colours.link_text
		accent_warm = ColourRGBA(255, 158, 94, 255)
		panel_border = alpha_blend(ColourRGBA(255, 255, 255, 20), self.colours.box_text_border)
		panel_fill = self.colours.message_box_bg
		section_fill = alpha_blend(ColourRGBA(255, 255, 255, 7), self.colours.box_button_background)
		divider = alpha_blend(ColourRGBA(255, 255, 255, 24), self.colours.box_text_border)
		inner_pad = round(24 * scale)
		inner_x = x + inner_pad
		inner_y = y + round(22 * scale)
		inner_w = w - inner_pad * 2

		self.ddt.bordered_rect((x, y, w, h), panel_fill, panel_border, round(1 * scale))
		self.draw_left_accent_gradient(x, y, round(5 * scale), h)
		self.ddt.text_background_colour = panel_fill

		version_text = "Tauon v11"
		self.ddt.text((inner_x, inner_y), version_text, self.colours.box_title_text, 217, bg=panel_fill)
		self.ddt.text(
			(inner_x, inner_y + round(27 * scale), 4, inner_w, round(42 * scale)),
			_("Your ultimate Tauon upgrade is here!"),
			self.colours.box_title_text,
			12,
			bg=panel_fill,
		)

		changelog_y = inner_y + round(58 * scale)
		changelog_h = round(172 * scale)
		self.ddt.rect((inner_x, changelog_y, inner_w, changelog_h), section_fill)
		self.ddt.rect((inner_x, changelog_y, inner_w, round(1 * scale)), divider)
		self.ddt.rect((inner_x, changelog_y + changelog_h - round(1 * scale), inner_w, round(1 * scale)), divider)
		self.ddt.text(
			(inner_x + round(16 * scale), changelog_y + round(13 * scale)),
			_("Changelog Highlights"),
			self.colours.box_text,
			213,
			bg=section_fill,
		)

		row_y = changelog_y + round(38 * scale)
		row_gap = round(18 * scale)
		bullet_colour = ColourRGBA(176, 214, 104, 255)
		for item, removed in self.CHANGELOG_ITEMS:
			text_colour = self.colours.box_text_label if removed else self.colours.box_text
			self.ddt.rect(
				(inner_x + round(17 * scale), row_y + round(5 * scale), round(6 * scale), round(6 * scale)),
				bullet_colour,
			)
			self.ddt.text(
				(inner_x + round(34 * scale), row_y),
				item,
				text_colour,
				13,
				bg=section_fill,
				max_w=inner_w - round(52 * scale),
			)
			row_y += row_gap

		support_y = changelog_y + changelog_h + round(16 * scale)
		self.ddt.text((inner_x, support_y), _("Please help support me make free software. ❤️  "), self.colours.box_text, 213, bg=panel_fill)
		self.ddt.text(
			(inner_x, support_y + round(19 * scale)),
			_("Special thanks to everyone who donated."),
			self.colours.box_text,
			12,
			bg=panel_fill,
			max_w=inner_w,
		)
		donate_y = support_y + round(37 * scale)
		donate_link = self.tauon.draw_linked_text(
			(inner_x, donate_y),
			_("If you haven't, please consider donating at https://github.com/sponsors/Taiko2k"),
			self.colours.box_text,
			12,
			replace=_("GitHub Sponsors"),
		)
		self.tauon.link_activate(inner_x, donate_y, donate_link, click=self.gui.level_2_click)
		patreon_x = inner_x + donate_link[0] + donate_link[1] + self.ddt.get_text_w(" ", 12)
		patreon_prefix = _("or on my ")
		self.ddt.text(
			(patreon_x, donate_y),
			patreon_prefix,
			self.colours.box_text,
			12,
			bg=panel_fill,
		)
		patreon_link_x = patreon_x + self.ddt.get_text_w(patreon_prefix, 12)
		patreon_link = self.tauon.draw_linked_text(
			(patreon_link_x, donate_y),
			self.PATREON_URL,
			self.colours.box_text,
			12,
			force=True,
			replace="Patreon.",
		)
		self.tauon.link_activate(patreon_link_x, donate_y, patreon_link, click=self.gui.level_2_click)
		self.ddt.text(
			(inner_x, support_y + round(55 * scale)),
			_("Your continued support helps keep this app alive."),
			self.colours.box_text,
			12,
			bg=panel_fill,
			max_w=inner_w,
		)

		button_y = y + h - round(46 * scale)
		button_h = round(30 * scale)
		close_w = max(round(96 * scale), self.ddt.get_text_w(_("Close"), 212) + round(22 * scale))
		close_x = x + w - close_w - inner_pad

		if self.drawer.button(_("Close"), close_x, button_y, w=close_w, h=button_h, press=self.gui.level_2_click):
			self.dismiss()

class PowerTag:

	def __init__(self) -> None:
		self.name: str = "BLANK"
		self.path: str = ""
		self.position: int = 0
		self.colour: ColourRGBA | None = None

		#self.peak_x: int = 0
		self.ani_timer: Timer = Timer()
		self.ani_timer.force_set(10)

class Over:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon:             Tauon = tauon
		self.bag:                 Bag = tauon.bag
		self.gui:              GuiVar = tauon.gui
		self.inp:               Input = tauon.inp
		self.ddt:               TDraw = tauon.ddt
		self.renderer                 = tauon.renderer
		self.coll                     = tauon.coll
		self.pctl:          PlayerCtl = tauon.pctl
		self.dirs:        Directories = tauon.dirs
		self.prefs:             Prefs = tauon.prefs
		self.fields:           Fields = tauon.fields
		self.lastfm:        LastFMapi = tauon.lastfm
		self.formats:         Formats = tauon.formats
		self.colours:    ColoursClass = tauon.colours
		self.window_size              = tauon.window_size
		self.show_message             = tauon.show_message
		self.album_mode_art_size: int = tauon.album_mode_art_size
		self.platform_system:     str = tauon.platform_system
		self.user_directory:     Path = tauon.user_directory
		self.flatpak_mode:       bool = tauon.flatpak_mode
		self.star_store:    StarStore = tauon.star_store
		self.snap_mode:          bool = tauon.snap_mode
		self.t_version:           str = tauon.t_version
		self.wayland:            bool = tauon.wayland
		self.macos:              bool = tauon.macos
		self.windows:               bool = tauon.windows
		self.phazor_found:       bool = phazor_exists(tauon.pctl)
		self.init2done:          bool = False

		# self.tab_width = round(115 * self.gui.scale)
		self.w = 100
		self.h = 100

		self.box_x = 100
		self.box_y = 100
		self.item_x_offset = round(25 * self.gui.scale)

		self.ext_ratio = {}
		self.last_db_size = -1

		self.enabled = False
		self.click = False
		self.right_click = False
		self.scroll = 0
		self.lock = False

		self.func_page = 0
		self.tab_active = 0
		self.settings_nav_scroll = 0.0
		self.settings_nav_scroll_bar = ScrollBox(tauon=tauon, pctl=tauon.pctl)
		self.settings_content_scroll = 0.0
		self.settings_content_scroll_bar = ScrollBox(tauon=tauon, pctl=tauon.pctl)
		self.settings_scale_preview_value: float | None = None
		# True until the first card of the settings category being rendered
		# has drawn its accent bar; only that card gets one (see
		# draw_settings_section / render_settings_category)
		self.settings_accent_bar_pending: bool = True
		self.settings_category_offsets: list[float] = []
		self.settings_doc_texture: sdl3.LP_SDL_Texture | None = None
		self.settings_doc_texture_size = (0, 0)
		self.app_icon = asset_loader(self.bag, self.bag.loaded_asset_dc, "app-icon.png")
		self.tabs = [
			_("General"),
			_("Connections"),
			_("Audio"),
			_("Theme"),
			_("View"),
			_("Transcode"),
			_("Services"),
			_("Advanced"),
			_("Stats"),
			_("About"),
		]

		self.stats_timer = Timer()
		self.stats_timer.force_set(1000)
		self.stats_pl_timer = Timer()
		self.stats_pl_timer.force_set(1000)
		self.total_albums = 0
		self.stats_pl = 0
		self.stats_pl_albums = 0
		self.stats_pl_length = 0

		self.device_scroll_bar_position = 0

		self.lyrics_panel = False
		self.account_view = 0

		self.settings_text_focus: TextBox2 | None = None
		self.settings_text_order: list[TextBox2] = []
		self.settings_text_seen: list[TextBox2] = []
		self.settings_text_hit = False

		self.themes = []
		self.theme_editor_enabled = False
		self.theme_editor_selected_attr = THEME_EDITOR_COMPONENTS[0][1][0]
		self.theme_editor_selected_attrs = THEME_EDITOR_COMPONENTS[0][1]
		self.theme_editor_dropdown_expansions = [False] * len(THEME_EDITOR_COMPONENTS)
		self.theme_editor_clipboard: ColourRGBA | None = None
		self.theme_editor_list_scroll = 0.0
		self.theme_editor_list_scroll_bar = ScrollBox(tauon=tauon, pctl=tauon.pctl)
		self.theme_editor_title_box: TextBox2 = TextBox2(tauon=tauon)
		self.theme_editor_original_colours: ColoursClass | None = None
		self.theme_editor_draft_colours: ColoursClass | None = None
		self.theme_editor_original_theme_name = ""
		self.theme_editor_original_theme_number = 0
		self.theme_editor_target_path: Path | None = None
		self.theme_editor_is_new = False
		self.theme_editor_dirty = False
		self.theme_editor_sv_texture = None
		self.theme_editor_sv_texture_key: tuple[int, int, int] | None = None
		self.theme_editor_hue_texture = None
		self.theme_editor_hue_texture_key: tuple[int, int] | None = None
		self.theme_editor_drag_target: str | None = None
		self.theme_editor_window_position: tuple[int, int] | None = None
		self.theme_editor_window_drag_start_mouse: tuple[int, int] = (0, 0)
		self.theme_editor_window_drag_start_position: tuple[int, int] = (0, 0)
		self.theme_editor_hue_value = 0.0
		self.theme_editor_sat_value = 1.0
		self.theme_editor_val_value = 1.0
		self.theme_editor_alpha_value = 1.0
		self.view_supporters = False

	def destroy_settings_texture(self) -> None:
		if self.settings_doc_texture is not None:
			sdl3.SDL_DestroyTexture(self.settings_doc_texture)
			self.settings_doc_texture = None
			self.settings_doc_texture_size = (0, 0)

	def destroy_theme_editor_gradient_textures(self) -> None:
		if self.theme_editor_sv_texture is not None:
			sdl3.SDL_DestroyTexture(self.theme_editor_sv_texture)
			self.theme_editor_sv_texture = None
		if self.theme_editor_hue_texture is not None:
			sdl3.SDL_DestroyTexture(self.theme_editor_hue_texture)
			self.theme_editor_hue_texture = None
		self.theme_editor_sv_texture_key = None
		self.theme_editor_hue_texture_key = None

	def ensure_settings_texture(self, size: tuple[int, int]) -> sdl3.LP_SDL_Texture:
		if self.settings_doc_texture is not None and self.settings_doc_texture_size == size:
			return self.settings_doc_texture

		self.destroy_settings_texture()
		self.settings_doc_texture = sdl3.SDL_CreateTexture(
			self.renderer,
			sdl3.SDL_PIXELFORMAT_ARGB8888,
			sdl3.SDL_TEXTUREACCESS_TARGET,
			size[0],
			size[1],
		)
		sdl3.SDL_SetTextureBlendMode(self.settings_doc_texture, sdl3.SDL_BLENDMODE_BLEND)
		self.settings_doc_texture_size = size
		return self.settings_doc_texture

	def settings_tab_accent(self, index: int) -> ColourRGBA:
		accents = (
			ColourRGBA(174, 118, 255, 255),
			ColourRGBA(196, 102, 244, 255),
			ColourRGBA(154, 124, 255, 255),
			ColourRGBA(226, 102, 216, 255),
			ColourRGBA(204, 122, 238, 255),
			ColourRGBA(236, 92, 190, 255),
			ColourRGBA(180, 112, 244, 255),
			ColourRGBA(216, 116, 230, 255),
			ColourRGBA(198, 136, 255, 255),
			ColourRGBA(229, 125, 214, 255),
			ColourRGBA(184, 184, 196, 255),
		)
		return accents[index % len(accents)]

	def sync_settings_nav_scroll(self, scroll_area: tuple[int, int, int, int], row_height: int, visible_rows: int) -> float:
		scroll_source = "settings nav"
		max_scroll = max(len(self.tabs) - visible_rows, 0)
		wheel_delta = self.scroll if self.scroll else self.inp.mouse_wheel
		if max_scroll <= 0:
			self.settings_nav_scroll = 0
			self.tauon.smooth_scroll.reset_motion(scroll_source)
			return 0

		touch_scroll = self.inp.touch_scroll_y != 0 and coll_point(self.inp.touch_position, scroll_area)
		use_smooth_scroll = (
			self.tauon.smooth_scroll.enabled()
			or touch_scroll
			or self.tauon.smooth_scroll.active(scroll_source)
		)
		if use_smooth_scroll:
			if self.coll(scroll_area) and wheel_delta:
				self.tauon.smooth_scroll.add_wheel_motion(scroll_source, -wheel_delta, row_height)
			if self.inp.touch_released:
				self.tauon.smooth_scroll.release_touch(scroll_source)
			elif touch_scroll:
				self.tauon.smooth_scroll.apply_touch_drag(scroll_source, -self.inp.touch_scroll_y)
			self.settings_nav_scroll += self.tauon.smooth_scroll.step_motion(scroll_source) / max(row_height, 1)
		elif self.coll(scroll_area) and wheel_delta:
			self.settings_nav_scroll -= wheel_delta

		self.settings_nav_scroll = min(max(self.settings_nav_scroll, 0), max_scroll)
		return max_scroll

	def draw_audio_device_selector(self, rect: tuple[int, int, int, int], accent: ColourRGBA | None = None) -> None:
		gui = self.gui
		ddt = self.ddt
		prefs = self.prefs
		colours = self.colours
		if accent is None:
			accent = self.settings_page_accent()

		x, y, w, h = rect
		inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
			rect,
			_("Audio Output Device"),
			_("Choose where Tauon sends playback."),
			accent,
		)

		section_bottom = y + h - round(14 * gui.scale)
		panel_fill = alpha_blend(ColourRGBA(255, 255, 255, 5), colours.box_background)
		panel_border = alpha_blend(ColourRGBA(255, 255, 255, 16), colours.box_text_border)
		selected_fill = alpha_blend(alpha_mod(accent, 34), panel_fill)
		hover_fill = alpha_blend(ColourRGBA(255, 255, 255, 12), panel_fill)
		badge_fill = alpha_blend(alpha_mod(accent, 22), panel_fill)
		badge_border = alpha_blend(alpha_mod(accent, 90), panel_border)
		panel_gap = round(8 * gui.scale)

		selected_device = prefs.phazor_device_selected
		if selected_device not in prefs.phazor_devices and prefs.phazor_devices:
			selected_device = prefs.phazor_devices[0]
		if not selected_device:
			selected_device = _("No device selected")

		stack_label_h = round(16 * gui.scale) if self.platform_system == "Linux" else 0
		stack_choice_h = round(42 * gui.scale) if self.platform_system == "Linux" else 0
		stack_gap = round(8 * gui.scale) if self.platform_system == "Linux" else 0
		list_y = inner_y
		list_bottom = section_bottom
		if self.platform_system == "Linux":
			list_bottom -= stack_choice_h + stack_gap + stack_label_h + round(10 * gui.scale)

		list_rect = (
			inner_x,
			list_y,
			inner_w,
			max(round(92 * gui.scale), list_bottom - list_y),
		)
		ddt.bordered_rect(list_rect, panel_fill, panel_border, round(1 * gui.scale))
		self.fields.add(list_rect)

		content_pad_x = round(8 * gui.scale)
		content_pad_y = round(8 * gui.scale)
		row_h = round(34 * gui.scale)
		row_gap = round(6 * gui.scale)
		row_step = row_h + row_gap
		scrollbar_w = round(10 * gui.scale)
		scrollbar_gap = round(8 * gui.scale)

		visible_rows = int(max(1, (list_rect[3] - content_pad_y * 2 + row_gap) // max(row_step, 1)))
		max_device_scroll = max(len(prefs.phazor_devices) - visible_rows, 0)
		if self.coll(list_rect) and self.scroll:
			self.device_scroll_bar_position -= self.scroll
		self.device_scroll_bar_position = min(max(self.device_scroll_bar_position, 0), max_device_scroll)
		scroll_index = int(self.device_scroll_bar_position)

		row_w = list_rect[2] - content_pad_x * 2
		if max_device_scroll > 0:
			row_w -= scrollbar_w + scrollbar_gap
			scrollbar_x = list_rect[0] + list_rect[2] - scrollbar_w - content_pad_x
			self.device_scroll_bar_position = self.tauon.device_scroll.draw(
				scrollbar_x,
				list_rect[1] + content_pad_y,
				scrollbar_w,
				list_rect[3] - content_pad_y * 2,
				self.device_scroll_bar_position,
				max_device_scroll,
				click=self.click,
			)
			self.device_scroll_bar_position = min(max(self.device_scroll_bar_position, 0), max_device_scroll)
			scroll_index = int(self.device_scroll_bar_position)

		if not prefs.phazor_devices:
			ddt.text(
				(list_rect[0] + list_rect[2] // 2, list_rect[1] + list_rect[3] // 2 - round(8 * gui.scale), 2),
				_("No output devices detected"),
				colours.box_text_label,
				212,
				bg=panel_fill,
				max_w=list_rect[2] - content_pad_x * 2,
			)
			ddt.text(
				(list_rect[0] + list_rect[2] // 2, list_rect[1] + list_rect[3] // 2 + round(10 * gui.scale), 2),
				_("Try refreshing audio or restarting Tauon."),
				colours.box_text_label,
				10,
				bg=panel_fill,
				max_w=list_rect[2] - content_pad_x * 2,
			)
		else:
			reload = False
			row_y = list_rect[1] + content_pad_y
			slice_end = int(scroll_index + visible_rows)
			for name in prefs.phazor_devices[scroll_index:slice_end]:
				row_rect = (list_rect[0] + content_pad_x, row_y, row_w, row_h)
				row_hover = self.coll(row_rect)
				row_selected = selected_device == name

				if self.click and row_hover and not row_selected:
					prefs.phazor_device_selected = name
					selected_device = name
					row_selected = True
					reload = True

				row_fill = panel_fill
				if row_hover:
					row_fill = hover_fill
				if row_selected:
					row_fill = selected_fill

				row_border = badge_border if row_selected else panel_border
				ddt.bordered_rect(row_rect, row_fill, row_border, round(1 * gui.scale))

				indicator_rect = (
					row_rect[0] + round(10 * gui.scale),
					row_rect[1] + (row_rect[3] - round(12 * gui.scale)) // 2,
					round(12 * gui.scale),
					round(12 * gui.scale),
				)
				ddt.bordered_rect(
					indicator_rect,
					alpha_blend(ColourRGBA(255, 255, 255, 8), row_fill),
					row_border,
					round(1 * gui.scale),
				)
				if row_selected:
					ddt.rect(
						(
							indicator_rect[0] + round(3 * gui.scale),
							indicator_rect[1] + round(3 * gui.scale),
							indicator_rect[2] - round(6 * gui.scale),
							indicator_rect[3] - round(6 * gui.scale),
						),
						accent,
					)

				label_x = indicator_rect[0] + indicator_rect[2] + round(10 * gui.scale)
				label_max_w = row_rect[2] - (label_x - row_rect[0]) - round(12 * gui.scale)
				device_name = self.tauon.trunc_line(name, 11, label_max_w)
				text_colour = colours.box_sub_text if row_selected or row_hover else colours.box_text
				ddt.text(
					(label_x, row_rect[1] + round(8 * gui.scale)),
					device_name,
					text_colour,
					11,
					bg=row_fill,
					max_w=label_max_w,
				)
				self.fields.add(row_rect)
				row_y += row_step

			if reload:
				self.pctl.playerCommand = "set-device"
				self.pctl.playerCommandReady = True

		if self.platform_system == "Linux":
			def set_pipewire(enabled: bool) -> None:
				old_pipewire = prefs.pipewire
				prefs.pipewire = enabled
				if prefs.pipewire != old_pipewire:
					self.show_message(_("Please restart Tauon for this change to take effect"))

			footer_label_y = section_bottom - stack_choice_h - stack_gap - stack_label_h
			ddt.text((inner_x, footer_label_y), _("Audio stack"), colours.box_text_label, 11, bg=self.ddt.text_background_colour)
			choice_y = footer_label_y + stack_label_h
			choice_w = (inner_w - panel_gap) // 2
			self.settings_choice_tile(
				(inner_x, choice_y, choice_w, stack_choice_h),
				_("PulseAudio"),
				"",
				not prefs.pipewire,
				lambda: set_pipewire(False),
				accent,
			)
			self.settings_choice_tile(
				(inner_x + choice_w + panel_gap, choice_y, choice_w, stack_choice_h),
				_("PipeWire"),
				"",
				prefs.pipewire,
				lambda: set_pipewire(True),
				accent,
			)

	def reload_device(self, _unused=None) -> None:
		self.pctl.playerCommand = "reload"
		self.pctl.playerCommandReady = True

	def settings_page_accent(self, page: int | None = None) -> ColourRGBA:
		accents = (
			ColourRGBA(174, 118, 255, 255),
			ColourRGBA(196, 102, 244, 255),
			ColourRGBA(226, 102, 216, 255),
			ColourRGBA(180, 112, 244, 255),
			ColourRGBA(204, 122, 238, 255),
		)
		if page is None:
			page = self.func_page
		return accents[page % len(accents)]

	def refresh_theme_presets(self) -> None:
		self.themes = [(ColoursClass(), "Mindaro", 0)]
		for index, theme in enumerate(get_themes(self.dirs)):
			colours = ColoursClass()
			try:
				load_theme(colours, Path(theme[0]))
			except Exception:
				logging.exception("Error loading theme preset preview")
				continue
			self.themes.append((colours, theme[1], index + 1))

	def get_active_theme_item(self) -> tuple[str, str] | None:
		if self.prefs.theme <= 0:
			return None
		themes = get_themes(self.dirs)
		theme_index = self.prefs.theme - 1
		if theme_index < 0 or theme_index >= len(themes):
			return None
		return themes[theme_index]

	def theme_path_is_user(self, path: Path) -> bool:
		if self.dirs.user_directory == self.dirs.install_directory:
			return False
		try:
			path.resolve().relative_to((self.dirs.user_directory / "theme").resolve())
		except ValueError:
			return False
		return True

	def active_theme_path(self) -> Path | None:
		theme_item = self.get_active_theme_item()
		if theme_item is None:
			return None
		return Path(theme_item[0])

	def active_theme_is_user_editable(self) -> bool:
		path = self.active_theme_path()
		return path is not None and self.theme_path_is_user(path)

	def unique_theme_name(self, base_name: str) -> str:
		existing_names = {theme[1] for theme in get_themes(self.dirs)}
		if base_name not in existing_names and base_name != "Mindaro":
			return base_name
		index = 2
		while True:
			candidate = f"{base_name} {index}"
			if candidate not in existing_names and candidate != "Mindaro":
				return candidate
			index += 1

	def theme_editor_selected_label(self) -> str:
		for label, attr in THEME_EDITOR_COMPONENTS:
			if attr[0] in self.theme_editor_selected_attrs:
				return _(label)
		return _("Component")

	def theme_colour_to_hex(self, colour: ColourRGBA) -> str:
		return f"#{colour.r:02X}{colour.g:02X}{colour.b:02X}{colour.a:02X}"

	def parse_theme_colour_text(self, text: str) -> ColourRGBA | None:
		match = re.fullmatch(r"#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})", text.strip())
		if not match:
			return None
		value = match.group(1)
		if len(value) == 6:
			value += "FF"
		return ColourRGBA(
			int(value[0:2], 16),
			int(value[2:4], 16),
			int(value[4:6], 16),
			int(value[6:8], 16),
		)

	def theme_editor_current_colour(self) -> ColourRGBA:
		source = self.theme_editor_draft_colours or self.colours
		colour = getattr(source, self.theme_editor_selected_attr, None)
		if colour is None:
			return ColourRGBA(255, 255, 255, 255)
		return ColourRGBA(colour.r, colour.g, colour.b, colour.a)

	def apply_theme_preview_colours(self, source: ColoursClass) -> None:
		preview = clone_theme_colours(source)
		if self.prefs.transparent_mode:
			preview.apply_transparency()
		self.colours.__dict__.clear()
		self.colours.__dict__.update(copy.deepcopy(preview.__dict__))
		if self.colours.deco:
			self.tauon.deco.load(self.colours.deco)
		else:
			self.tauon.deco.unload()
		if self.colours.lm:
			self.gui.info_icon.colour = ColourRGBA(60, 60, 60, 255)
			self.gui.folder_icon.colour = ColourRGBA(255, 190, 80, 255)
			self.gui.settings_icon.colour = ColourRGBA(85, 187, 250, 255)
			self.gui.radiorandom_icon.colour = ColourRGBA(120, 200, 120, 255)
		else:
			self.gui.info_icon.colour = ColourRGBA(61, 247, 163, 255)
			self.gui.folder_icon.colour = ColourRGBA(244, 220, 66, 255)
			self.gui.settings_icon.colour = ColourRGBA(232, 200, 96, 255)
			self.gui.radiorandom_icon.colour = ColourRGBA(153, 229, 133, 255)
		self.ddt.text_background_colour = self.colours.playlist_panel_background
		self.gui.update_layout = True

	def sync_theme_editor_controls_from_current_colour(self) -> None:
		current_colour = self.theme_editor_current_colour()
		hue, sat, val = colorsys.rgb_to_hsv(
			current_colour.r / 255,
			current_colour.g / 255,
			current_colour.b / 255,
		)
		self.theme_editor_hue_value = hue
		self.theme_editor_sat_value = sat
		self.theme_editor_val_value = val
		self.theme_editor_alpha_value = current_colour.a / 255

	def begin_theme_editor(self, title: str, target_path: Path | None, is_new: bool) -> None:
		self.enabled = True
		self.theme_editor_enabled = True
		self.tauon.fader.fall()
		self.theme_editor_list_scroll = 0.0
		self.theme_editor_dirty = False
		self.theme_editor_is_new = is_new
		self.theme_editor_target_path = target_path
		self.theme_editor_window_position = None
		self.theme_editor_original_colours = clone_theme_colours(self.colours)
		self.theme_editor_draft_colours = clone_theme_colours(self.colours)
		self.theme_editor_original_theme_name = self.gui.theme_name
		self.theme_editor_original_theme_number = self.prefs.theme
		self.theme_editor_title_box.set_text(title)
		self.theme_editor_title_box.cursor_position = 0
		self.theme_editor_title_box.selection = 0
		self.sync_theme_editor_controls_from_current_colour()
		self.apply_theme_preview_colours(self.theme_editor_draft_colours)

	def clear_theme_editor_state(self) -> None:
		self.theme_editor_enabled = False
		self.theme_editor_draft_colours = None
		self.theme_editor_original_colours = None
		self.theme_editor_target_path = None
		self.theme_editor_dirty = False
		self.theme_editor_drag_target = None
		self.theme_editor_window_position = None
		self.theme_editor_hue_value = 0.0
		self.theme_editor_sat_value = 1.0
		self.theme_editor_val_value = 1.0
		self.theme_editor_alpha_value = 1.0
		self.destroy_theme_editor_gradient_textures()

	def open_theme_editor(self) -> None:
		if not self.active_theme_is_user_editable():
			self.show_message(
				_("Bundled themes are read-only"),
				_("Create a new theme first to edit a copy of the active look."),
				mode="warning",
			)
			return
		self.begin_theme_editor(self.gui.theme_name, self.active_theme_path(), is_new=False)

	def close_theme_editor(self) -> None:
		self.clear_theme_editor_state()
		self.tauon.fader.rise()
		self.destroy_settings_texture()
		self.gui.update_layout = True

	def create_user_theme(self) -> None:
		base_name = filename_safe(f"{self.gui.theme_name} Copy" if self.gui.theme_name else _("Custom Theme")).strip()
		if not base_name:
			base_name = _("Custom Theme")
		target_name = self.unique_theme_name(base_name)
		target_path = self.dirs.user_directory / "theme" / f"{target_name}.ttheme"
		try:
			save_theme(clone_theme_colours(self.colours), target_path)
		except OSError:
			logging.exception("Failed saving duplicated theme file")
			self.show_message(_("Could not save theme file"), mode="error")
			return

		self.refresh_theme_presets()
		self.prefs.theme = get_theme_number(self.dirs, target_name)
		self.prefs.theme_name = target_name
		self.gui.theme_name = target_name
		self.gui.reload_theme = True
		self.gui.update_layout = True
		self.destroy_settings_texture()
		self.show_message(_("Duplicated theme"), target_name, mode="done")

	def delete_active_user_theme(self) -> None:
		path = self.active_theme_path()
		if path is None or not self.theme_path_is_user(path):
			self.show_message(_("Only user themes can be deleted"), mode="warning")
			return

		try:
			path.unlink()
		except OSError:
			logging.exception("Failed deleting theme file")
			self.show_message(_("Could not delete theme file"), mode="error")
			return

		self.refresh_theme_presets()
		self.theme_editor_enabled = False
		self.prefs.theme = 0
		self.prefs.theme_name = "Mindaro"
		self.gui.theme_name = "Mindaro"
		self.gui.reload_theme = True
		self.gui.update_layout = True
		self.show_message(_("Deleted theme"), path.stem, mode="done")

	def save_active_user_theme(self) -> bool:
		if self.theme_editor_draft_colours is None:
			return False
		target_name = filename_safe(self.theme_editor_title_box.text.strip())
		if not target_name:
			self.show_message(_("Theme title cannot be empty"), mode="error")
			return False

		target_dir = self.dirs.user_directory / "theme"
		target_path = target_dir / f"{target_name}.ttheme"
		original_path = self.theme_editor_target_path
		if original_path is not None and target_path != original_path and target_path.exists():
			self.show_message(_("A theme with that title already exists"), mode="error")
			return False
		if original_path is None and target_path.exists():
			self.show_message(_("A theme with that title already exists"), mode="error")
			return False

		try:
			save_theme(self.theme_editor_draft_colours, target_path)
			if original_path is not None and original_path != target_path and original_path.exists():
				original_path.unlink()
		except OSError:
			logging.exception("Failed saving theme file")
			self.show_message(_("Could not save theme file"), mode="error")
			return False

		self.theme_editor_target_path = target_path
		self.theme_editor_original_colours = clone_theme_colours(self.theme_editor_draft_colours)
		self.theme_editor_original_theme_name = target_name
		self.theme_editor_original_theme_number = get_theme_number(self.dirs, target_name)
		self.theme_editor_is_new = False
		self.theme_editor_dirty = False
		self.refresh_theme_presets()
		self.prefs.theme = get_theme_number(self.dirs, target_name)
		self.prefs.theme_name = target_name
		self.gui.theme_name = target_name
		self.apply_theme_preview_colours(self.theme_editor_draft_colours)
		self.gui.update_layout = True
		self.show_message(_("Saved theme"), target_name, mode="done")
		return True

	def apply_theme_editor_colour(self, thing: tuple[str], colour: ColourRGBA) -> None:
		if self.theme_editor_draft_colours is None:
			return
		for attr in thing:
			setattr(self.theme_editor_draft_colours, attr, ColourRGBA(colour.r, colour.g, colour.b, colour.a))
		self.theme_editor_dirty = True
		self.apply_theme_preview_colours(self.theme_editor_draft_colours)

	def theme_editor_current_hsv(self) -> tuple[float, float, float]:
		colour = self.theme_editor_current_colour()
		return colorsys.rgb_to_hsv(colour.r / 255, colour.g / 255, colour.b / 255)

	def apply_theme_editor_hsv(self, hue: float, sat: float, val: float) -> None:
		colour = self.theme_editor_current_colour()
		hue = min(max(hue, 0.0), 1.0)
		sat = min(max(sat, 0.0), 1.0)
		val = min(max(val, 0.0), 1.0)
		self.theme_editor_hue_value = hue
		self.theme_editor_sat_value = sat
		self.theme_editor_val_value = val
		r, g, b = colorsys.hsv_to_rgb(hue, sat, val)
		self.apply_theme_editor_colour(
			self.theme_editor_selected_attrs,
			ColourRGBA(int(r * 255), int(g * 255), int(b * 255), colour.a),
		)

	def apply_theme_editor_alpha(self, alpha: float) -> None:
		colour = self.theme_editor_current_colour()
		alpha = min(max(alpha, 0.0), 1.0)
		self.theme_editor_alpha_value = alpha
		self.apply_theme_editor_colour(
			self.theme_editor_selected_attrs,
			ColourRGBA(colour.r, colour.g, colour.b, int(alpha * 255)),
		)

	def theme_editor_texture_from_pixels(self, width: int, height: int, pixel_bytes: bytes) -> sdl3.LP_SDL_Texture | None:
		buffer = ctypes.create_string_buffer(pixel_bytes)
		surface = sdl3.SDL_CreateSurfaceFrom(
			width,
			height,
			sdl3.SDL_PIXELFORMAT_ARGB8888,
			ctypes.cast(buffer, c_void_p),
			width * 4,
		)
		texture = sdl3.SDL_CreateTextureFromSurface(self.renderer, surface)
		sdl3.SDL_DestroySurface(surface)
		if texture is not None:
			sdl3.SDL_SetTextureBlendMode(texture, sdl3.SDL_BLENDMODE_BLEND)
			if hasattr(sdl3, "SDL_SetTextureScaleMode") and hasattr(sdl3, "SDL_SCALEMODE_LINEAR"):
				sdl3.SDL_SetTextureScaleMode(texture, sdl3.SDL_SCALEMODE_LINEAR)
		return texture

	def render_theme_editor_texture(self, texture, rect: tuple[int, int, int, int]) -> None:
		if texture is None:
			return
		dest_rect = sdl3.SDL_FRect(float(rect[0]), float(rect[1]), float(rect[2]), float(rect[3]))
		sdl3.SDL_RenderTexture(self.renderer, texture, None, dest_rect)

	def ensure_theme_editor_sv_texture(self, width: int, height: int, hue: float):
		downscale = 10
		hue_buckets = max(48, min(width // 2, 144))
		quantized_hue = round(hue * hue_buckets) / max(hue_buckets, 1)
		texture_key = (width, height, round(quantized_hue * hue_buckets))
		if self.theme_editor_sv_texture is not None and self.theme_editor_sv_texture_key == texture_key:
			return self.theme_editor_sv_texture

		if self.theme_editor_sv_texture is not None:
			sdl3.SDL_DestroyTexture(self.theme_editor_sv_texture)
			self.theme_editor_sv_texture = None

		source_width = max(1, width // downscale)
		source_height = max(1, height // downscale)
		pixels = bytearray(source_width * source_height * 4)
		for y_pos in range(source_height):
			value = 1 - (y_pos / max(source_height - 1, 1))
			for x_pos in range(source_width):
				saturation = x_pos / max(source_width - 1, 1)
				r, g, b = colorsys.hsv_to_rgb(quantized_hue, saturation, value)
				offset = (y_pos * source_width + x_pos) * 4
				pixels[offset] = int(b * 255)
				pixels[offset + 1] = int(g * 255)
				pixels[offset + 2] = int(r * 255)
				pixels[offset + 3] = 255

		self.theme_editor_sv_texture = self.theme_editor_texture_from_pixels(source_width, source_height, bytes(pixels))
		self.theme_editor_sv_texture_key = texture_key
		return self.theme_editor_sv_texture

	def ensure_theme_editor_hue_texture(self, width: int, height: int):
		downscale = 10
		texture_key = (width, height)
		if self.theme_editor_hue_texture is not None and self.theme_editor_hue_texture_key == texture_key:
			return self.theme_editor_hue_texture

		if self.theme_editor_hue_texture is not None:
			sdl3.SDL_DestroyTexture(self.theme_editor_hue_texture)
			self.theme_editor_hue_texture = None

		source_width = max(1, width // downscale)
		source_height = max(1, height // downscale)
		row_bytes = bytearray(source_width * 4)
		for x_pos in range(source_width):
			hue_value = x_pos / max(source_width - 1, 1)
			r, g, b = colorsys.hsv_to_rgb(hue_value, 1, 1)
			offset = x_pos * 4
			row_bytes[offset] = int(b * 255)
			row_bytes[offset + 1] = int(g * 255)
			row_bytes[offset + 2] = int(r * 255)
			row_bytes[offset + 3] = 255
		pixels = bytes(row_bytes) * source_height

		self.theme_editor_hue_texture = self.theme_editor_texture_from_pixels(source_width, source_height, pixels)
		self.theme_editor_hue_texture_key = texture_key
		return self.theme_editor_hue_texture

	def theme_editor_copy_colour(self) -> None:
		self.theme_editor_clipboard = self.theme_editor_current_colour()
		copy_to_clipboard(self.theme_colour_to_hex(self.theme_editor_clipboard))
		self.show_message(_("Copied colour"), self.theme_colour_to_hex(self.theme_editor_clipboard), mode="done")

	def theme_editor_paste_colour(self) -> None:
		colour = self.theme_editor_clipboard
		if colour is None:
			colour = self.parse_theme_colour_text(copy_from_clipboard())
		if colour is None:
			self.show_message(_("There is no colour in the clipboard"), mode="error")
			return
		self.apply_theme_editor_colour(self.theme_editor_selected_attrs, colour)
		self.theme_editor_clipboard = ColourRGBA(colour.r, colour.g, colour.b, colour.a)
		self.sync_theme_editor_controls_from_current_colour()
		self.show_message(_("Pasted colour"), self.theme_editor_selected_label(), mode="done")

	def sync_settings_content_scroll(self, scroll_area: tuple[int, int, int, int], content_height: int) -> float:
		scroll_source = "settings content"
		max_scroll = max(content_height - scroll_area[3], 0)
		wheel_delta = self.scroll if self.scroll else self.inp.mouse_wheel
		if max_scroll <= 0:
			self.settings_content_scroll = 0
			self.tauon.smooth_scroll.reset_motion(scroll_source)
			return 0

		touch_scroll = self.inp.touch_scroll_y != 0 and coll_point(self.inp.touch_position, scroll_area)
		use_smooth_scroll = (
			self.tauon.smooth_scroll.enabled()
			or touch_scroll
			or self.tauon.smooth_scroll.active(scroll_source)
		)
		scroll_step = round(56 * self.gui.scale)
		if use_smooth_scroll:
			if self.coll(scroll_area) and wheel_delta:
				self.tauon.smooth_scroll.add_wheel_motion(scroll_source, -wheel_delta, scroll_step)
			if self.inp.touch_released:
				self.tauon.smooth_scroll.release_touch(scroll_source)
			elif touch_scroll:
				self.tauon.smooth_scroll.apply_touch_drag(scroll_source, -self.inp.touch_scroll_y)
			self.settings_content_scroll += self.tauon.smooth_scroll.step_motion(scroll_source)
		elif self.coll(scroll_area) and wheel_delta:
			self.settings_content_scroll -= wheel_delta * scroll_step

		self.settings_content_scroll = min(max(self.settings_content_scroll, 0), max_scroll)
		return max_scroll

	def draw_settings_range_slider(
		self,
		rect: tuple[int, int, int, int],
		title: str,
		value: float,
		min_value: float,
		max_value: float,
		step: float,
		accent: ColourRGBA | None = None,
		formatter=None,
		callback=None,
		log_scale: bool = False,
		disabled: bool = False,
	) -> float:
		if accent is None:
			accent = self.settings_page_accent()
		if disabled:
			accent = alpha_blend(ColourRGBA(128, 128, 128, 90), self.colours.box_background)

		x, y, w, h = tuple(round(v) for v in rect)
		fill = alpha_blend(ColourRGBA(255, 255, 255, 6), self.colours.box_background)
		border = alpha_blend(ColourRGBA(255, 255, 255, 18), self.colours.box_text_border)
		if disabled:
			fill = alpha_blend(ColourRGBA(128, 128, 128, 8), self.colours.box_background)
			border = alpha_blend(ColourRGBA(128, 128, 128, 36), self.colours.box_text_border)
		elif self.coll((x, y, w, h)):
			fill = alpha_blend(ColourRGBA(255, 255, 255, 8), fill)
		self.ddt.bordered_rect((x, y, w, h), fill, border, round(1 * self.gui.scale))

		self.ddt.text(
			(x + round(12 * self.gui.scale), y + round(8 * self.gui.scale)),
			title,
			alpha_mod(self.colours.box_text_label, 150) if disabled else self.colours.box_text,
			12,
			bg=fill,
			max_w=w - round(110 * self.gui.scale),
		)

		if formatter is None:
			display = str(value)
		else:
			display = formatter(value)
		self.ddt.text(
			(x + w - round(12 * self.gui.scale), y + round(8 * self.gui.scale), 1),
			display,
			alpha_mod(self.colours.box_text_label, 130) if disabled else self.colours.box_sub_text,
			211,
			bg=fill,
		)

		slider_x = x + round(12 * self.gui.scale)
		slider_y = y + h - round(16 * self.gui.scale)
		slider_w = w - round(24 * self.gui.scale)
		slider_h = round(2 * self.gui.scale)
		slider_rect = (slider_x, slider_y, slider_w, slider_h)
		grip_w = round(8 * self.gui.scale)
		grip_h = round(14 * self.gui.scale)
		use_log_scale = log_scale and min_value > 0 and max_value > min_value
		if use_log_scale:
			log_min = math.log(min_value)
			log_max = math.log(max_value)
			safe_value = min(max(value, min_value), max_value)
			ratio = 0.0 if log_max == log_min else (math.log(safe_value) - log_min) / (log_max - log_min)
		else:
			ratio = 0.0 if max_value == min_value else (value - min_value) / (max_value - min_value)
		ratio = min(max(ratio, 0.0), 1.0)
		grip_x = slider_x + round(slider_w * ratio) - grip_w // 2
		grip_rect = (grip_x, slider_y - (grip_h // 2) + slider_h // 2, grip_w, grip_h)

		self.ddt.rect(slider_rect, alpha_blend(ColourRGBA(255, 255, 255, 20), border))
		self.ddt.rect((slider_x, slider_y, round(slider_w * ratio), slider_h), accent)
		self.ddt.rect(grip_rect, accent)

		if disabled:
			return value

		hit_rect = grow_rect((slider_x, slider_y - round(10 * self.gui.scale), slider_w, round(20 * self.gui.scale)), round(4 * self.gui.scale))
		self.fields.add(hit_rect)
		if self.coll(hit_rect) and self.inp.mouse_down:
			portion = (self.inp.mouse_position[0] - slider_x) / max(slider_w, 1)
			portion = min(max(portion, 0.0), 1.0)
			if use_log_scale:
				raw_value = math.exp(log_min + (log_max - log_min) * portion)
			else:
				raw_value = min_value + (max_value - min_value) * portion
			if step:
				raw_value = round(round(raw_value / step) * step, 3)
			value = min(max(raw_value, min_value), max_value)
			self.gui.update_layout = True
			if callback is not None:
				callback(value)

		return value

	def draw_settings_section(
		self,
		rect: tuple[int, int, int, int],
		title: str,
		subtitle: str = "",
		accent: ColourRGBA | None = None,
	) -> tuple[int, int, int, int]:
		if accent is None:
			accent = self.settings_page_accent()

		x, y, w, h = rect
		fill = alpha_blend(ColourRGBA(255, 255, 255, 6), self.colours.box_background)
		border = alpha_blend(ColourRGBA(255, 255, 255, 18), self.colours.box_text_border)
		# Border drawn INSIDE the rect: the settings document texture is
		# blitted at exactly the view rect, so bordered_rect's outside border
		# would be cropped on cards at the document's left edge
		border_size = round(1 * self.gui.scale)
		self.ddt.rect(rect, border)
		self.ddt.rect((x + border_size, y + border_size, w - border_size * 2, h - border_size * 2), fill)
		# Accent bar only on the first card of the category
		if self.settings_accent_bar_pending:
			self.ddt.rect((x, y, round(4 * self.gui.scale), h), accent)
			self.settings_accent_bar_pending = False
		self.ddt.text_background_colour = fill

		pad_x = round(16 * self.gui.scale)
		inner_x = x + pad_x + round(4 * self.gui.scale)
		inner_y = y + round(14 * self.gui.scale)
		inner_w = w - pad_x * 2 - round(4 * self.gui.scale)

		if title:
			self.ddt.text((inner_x, inner_y), title, self.colours.box_text, 213, bg=fill)
			inner_y += round(20 * self.gui.scale)
		if subtitle:
			sub_h = self.ddt.text(
				(inner_x, inner_y, 4, inner_w, round(40 * self.gui.scale)),
				subtitle,
				self.colours.box_text_label,
				11,
				bg=fill,
			) or 0
			inner_y += max(sub_h, round(14 * self.gui.scale))

		if title or subtitle:
			divider = alpha_blend(alpha_mod(accent, 80), self.colours.box_text_border)
			inner_y += round(6 * self.gui.scale)
			self.ddt.rect((inner_x, inner_y, inner_w, round(1 * self.gui.scale)), divider)
			inner_y += round(12 * self.gui.scale)

		return inner_x, inner_y, inner_w, y + h - round(14 * self.gui.scale) - inner_y

	def settings_switch_row(
		self,
		rect: tuple[int, int, int, int],
		function: Callable[[int], bool | None] | bool,
		title: str,
		subtitle: str = "",
		accent: ColourRGBA | None = None,
		click: bool = False,
		show_active_bar: bool = False,
		disabled: bool = False,
		disabled_click: bool = False,
	) -> bool:
		if accent is None:
			accent = self.settings_page_accent()

		x, y, w, h = tuple(round(v) for v in rect)
		active = function if type(function) is bool else function(1)
		hover = self.coll((x, y, w, h))

		fill = alpha_blend(ColourRGBA(255, 255, 255, 6), self.colours.box_background)
		if disabled:
			fill = alpha_blend(ColourRGBA(128, 128, 128, 8), self.colours.box_background)
		elif active:
			fill = alpha_blend(alpha_mod(accent, 26), fill)
		if hover and not disabled:
			fill = alpha_blend(ColourRGBA(255, 255, 255, 10), fill)

		border = alpha_blend(ColourRGBA(255, 255, 255, 18), self.colours.box_text_border)
		if disabled:
			border = alpha_blend(ColourRGBA(128, 128, 128, 36), self.colours.box_text_border)
		elif active:
			border = alpha_blend(alpha_mod(accent, 90), border)

		self.ddt.bordered_rect((x, y, w, h), fill, border, round(1 * self.gui.scale))
		if active and show_active_bar and not disabled:
			self.ddt.rect((x, y, round(4 * self.gui.scale), h), accent)

		self.fields.add((x, y, w, h))
		if (self.click or click) and hover:
			self.inp.global_clicked = True
			if disabled and not disabled_click:
				pass
			elif type(function) is bool:
				active ^= True
			else:
				function()
				active = function(1)

		text_x = x + round(14 * self.gui.scale)
		title_font = 13
		title_max_w = w - round(84 * self.gui.scale)
		title_y = y + round(6 * self.gui.scale) if subtitle else y + round(7 * self.gui.scale)
		title_colour = alpha_mod(self.colours.box_text_label, 150) if disabled else self.colours.box_text
		subtitle_colour = alpha_mod(self.colours.box_text_label, 130) if disabled else self.colours.box_text_label
		self.ddt.text((text_x, title_y), title, title_colour, title_font, bg=fill, max_w=title_max_w)
		if subtitle:
			self.ddt.text(
				(text_x, y + round(21 * self.gui.scale)),
				subtitle,
				subtitle_colour,
				11,
				bg=fill,
				max_w=title_max_w,
			)

		switch_w = round(42 * self.gui.scale)
		switch_h = round(18 * self.gui.scale)
		switch_x = x + w - switch_w - round(12 * self.gui.scale)
		switch_y = y + (h - switch_h) // 2
		switch_fill = (
			alpha_blend(ColourRGBA(128, 128, 128, 24), fill)
			if disabled else
			alpha_blend(alpha_mod(accent, 54), fill)
			if active else
			alpha_blend(ColourRGBA(255, 255, 255, 16), fill)
		)
		switch_border = (
			border
			if disabled else
			alpha_blend(alpha_mod(accent, 120), border)
			if active else
			border
		)
		self.ddt.bordered_rect((switch_x, switch_y, switch_w, switch_h), switch_fill, switch_border, round(1 * self.gui.scale))

		knob_w = round(16 * self.gui.scale)
		knob_h = switch_h - round(6 * self.gui.scale)
		knob_x = switch_x + (switch_w - knob_w - round(3 * self.gui.scale) if active else round(3 * self.gui.scale))
		knob_y = switch_y + round(3 * self.gui.scale)
		knob_colour = alpha_mod(self.colours.box_text_label, 120) if disabled else self.colours.box_title_text if active else self.colours.box_text_label
		self.ddt.rect((knob_x, knob_y, knob_w, knob_h), knob_colour)

		return active

	def settings_toggle_chip(
		self,
		rect: tuple[int, int, int, int],
		function: Callable[[int], bool | None] | bool,
		title: str,
		subtitle: str = "",
		accent: ColourRGBA | None = None,
		show_active_bar: bool = True,
	) -> bool:
		if accent is None:
			accent = self.settings_page_accent()

		x, y, w, h = tuple(round(v) for v in rect)
		active = function if type(function) is bool else function(1)
		hover = self.coll((x, y, w, h))

		fill = alpha_blend(ColourRGBA(255, 255, 255, 6), self.colours.box_background)
		if active:
			fill = alpha_blend(alpha_mod(accent, 22), fill)
		if hover:
			fill = alpha_blend(ColourRGBA(255, 255, 255, 10), fill)

		border = alpha_blend(ColourRGBA(255, 255, 255, 18), self.colours.box_text_border)
		if active:
			border = alpha_blend(alpha_mod(accent, 90), border)

		self.ddt.bordered_rect((x, y, w, h), fill, border, round(1 * self.gui.scale))
		if active and show_active_bar:
			self.ddt.rect((x, y, w, round(3 * self.gui.scale)), accent)

		self.fields.add((x, y, w, h))
		if hover and self.click:
			self.inp.global_clicked = True
			if type(function) is bool:
				active ^= True
			else:
				function()
				active = function(1)

		indicator_w = round(12 * self.gui.scale)
		indicator_h = round(12 * self.gui.scale)
		indicator_y = y + round(10 * self.gui.scale) if subtitle else y + max(0, (h - indicator_h) // 2)
		indicator_rect = (
			x + w - indicator_w - round(10 * self.gui.scale),
			indicator_y,
			indicator_w,
			indicator_h,
		)
		self.ddt.bordered_rect(
			indicator_rect,
			alpha_blend(ColourRGBA(255, 255, 255, 8), fill),
			alpha_blend(ColourRGBA(255, 255, 255, 18), border),
			round(1 * self.gui.scale),
		)
		if active:
			self.ddt.rect(
				(
					indicator_rect[0] + round(3 * self.gui.scale),
					indicator_rect[1] + round(3 * self.gui.scale),
					indicator_rect[2] - round(6 * self.gui.scale),
					indicator_rect[3] - round(6 * self.gui.scale),
				),
				accent,
			)

		title_max_w = w - round(32 * self.gui.scale)
		title_font = 12
		title_y = y + round(11 * self.gui.scale)
		if not subtitle:
			title_y -= round(2 * self.gui.scale)
		self.ddt.text(
			(x + round(12 * self.gui.scale), title_y),
			title,
			self.colours.box_text,
			title_font,
			bg=fill,
			max_w=title_max_w,
		)
		if subtitle:
			self.ddt.text(
				(x + round(12 * self.gui.scale), y + round(25 * self.gui.scale), 4, title_max_w, h - round(28 * self.gui.scale)),
				subtitle,
				self.colours.box_text_label,
				10,
				bg=fill,
			)

		return active

	def settings_action_tile(
		self,
		rect: tuple[int, int, int, int],
		title: str,
		plug: Callable[[], None] | None = None,
		accent: ColourRGBA | None = None,
		emphasis: bool = False,
		show_arrow: bool = True,
	) -> bool:
		if accent is None:
			accent = self.settings_page_accent()

		x, y, w, h = tuple(round(v) for v in rect)
		button_h = round(34 * self.gui.scale)
		button_y = y + max(0, (h - button_h) // 2)
		button_rect = (x, button_y, w, button_h)
		hover = self.coll(button_rect)
		fill = alpha_blend(ColourRGBA(255, 255, 255, 8), self.colours.box_button_background)
		if emphasis:
			fill = alpha_blend(ColourRGBA(255, 255, 255, 4), fill)
		if hover:
			fill = alpha_blend(ColourRGBA(255, 255, 255, 10), fill)
		border = alpha_blend(ColourRGBA(255, 255, 255, 18), self.colours.box_text_border)
		if emphasis:
			border = alpha_blend(ColourRGBA(255, 255, 255, 10), border)

		self.ddt.bordered_rect(button_rect, fill, border, round(1 * self.gui.scale))

		self.fields.add(button_rect)
		hit = False
		if hover and self.click:
			self.inp.global_clicked = True
			hit = True
			if plug is not None:
				plug()

		text_colour = self.colours.box_text if hover or emphasis else self.colours.box_button_text
		text_font = 212
		text_max_w = w - (round(44 * self.gui.scale) if show_arrow else round(28 * self.gui.scale))
		# text_metrics = self.ddt.get_text_wh(title, text_font, text_max_w)
		text_y = button_y + round(8 * self.gui.scale)
		self.ddt.text((x + round(14 * self.gui.scale), text_y), title, text_colour, text_font, bg=fill, max_w=text_max_w)

		if show_arrow:
			arrow_y = button_y + button_h // 2
			arrow_colour = self.colours.box_text if hover else self.colours.box_text_label
			self.ddt.rect((x + w - round(20 * self.gui.scale), arrow_y - round(3 * self.gui.scale), round(7 * self.gui.scale), round(2 * self.gui.scale)), arrow_colour)
			self.ddt.rect((x + w - round(16 * self.gui.scale), arrow_y - round(1 * self.gui.scale), round(7 * self.gui.scale), round(2 * self.gui.scale)), arrow_colour)

		return hit

	def settings_icon_button(
		self,
		rect: tuple[int, int, int, int],
		plug: Callable[[], None] | None = None,
		accent: ColourRGBA | None = None,
		emphasis: bool = False,
		icon: MenuIcon | None = None,
		draw_icon: Callable[[tuple[int, int, int, int], ColourRGBA], None] | None = None,
		tooltip: str = "",
	) -> bool:
		if accent is None:
			accent = self.settings_page_accent()

		x, y, w, h = tuple(round(v) for v in rect)
		button_size = min(w, h, round(30 * self.gui.scale))
		button_x = x + max(0, (w - button_size) // 2)
		button_y = y + max(0, (h - button_size) // 2)
		button_rect = (button_x, button_y, button_size, button_size)
		hover = self.coll(button_rect)
		if hover and tooltip:
			self.tauon.tool_tip.test(button_rect[0] + 15 * self.gui.scale, button_rect[1] - 28 * self.gui.scale, tooltip)
		self.fields.add(button_rect)
		hit = False
		if hover and self.click:
			self.inp.global_clicked = True
			hit = True
			if plug is not None:
				plug()

		icon_colour = self.colours.box_text if hover or emphasis else self.colours.box_text_label
		if draw_icon is not None:
			draw_icon(button_rect, icon_colour)
		elif icon is not None:
			icon_x = button_rect[0] + (button_rect[2] - round(16 * self.gui.scale)) // 2 + round(icon.xoff * self.gui.scale)
			icon_y = button_rect[1] + (button_rect[3] - round(16 * self.gui.scale)) // 2 + round(icon.yoff * self.gui.scale)
			if icon.base_asset is None:
				icon.asset.render(icon_x, icon_y, icon_colour)
			else:
				icon.asset.render(icon_x, icon_y)

		return hit

	def draw_duplicate_theme_icon(self, rect: tuple[int, int, int, int], colour: ColourRGBA) -> None:
		size = round(10 * self.gui.scale)
		shift = round(3 * self.gui.scale)
		x = rect[0] + (rect[2] - size - shift) // 2
		y = rect[1] + (rect[3] - size - shift) // 2
		back_rect = (x, y, size, size)
		front_rect = (x + shift, y + shift, size, size)
		self.ddt.rect_s(back_rect, colour, round(1 * self.gui.scale))
		self.ddt.rect_s(front_rect, colour, round(1 * self.gui.scale))

	def settings_stepper_row(
		self,
		rect: tuple[int, int, int, int],
		title: str,
		value: int,
		lower_limit: int,
		upper_limit: int,
		units: str = "",
		step: int = 1,
		accent: ColourRGBA | None = None,
		callback=None,
		formatter=None,
	) -> int:
		if accent is None:
			accent = self.settings_page_accent()

		x, y, w, h = tuple(round(v) for v in rect)
		fill = alpha_blend(ColourRGBA(255, 255, 255, 6), self.colours.box_background)
		border = alpha_blend(ColourRGBA(255, 255, 255, 18), self.colours.box_text_border)
		if self.coll((x, y, w, h)):
			fill = alpha_blend(ColourRGBA(255, 255, 255, 8), fill)
		self.ddt.bordered_rect((x, y, w, h), fill, border, round(1 * self.gui.scale))

		self.ddt.text(
			(x + round(12 * self.gui.scale), y + round(8 * self.gui.scale)),
			title,
			self.colours.box_text,
			12,
			bg=fill,
			max_w=w - round(130 * self.gui.scale),
		)

		button_w = round(22 * self.gui.scale)
		value_w = round(54 * self.gui.scale)
		control_gap = round(4 * self.gui.scale)
		control_h = round(20 * self.gui.scale)
		control_x = x + w - (button_w * 2 + value_w + control_gap * 2) - round(10 * self.gui.scale)
		control_y = y + (h - control_h) // 2
		dec_rect = (control_x, control_y, button_w, control_h)
		value_rect = (control_x + button_w + control_gap, control_y, value_w, control_h)
		inc_rect = (value_rect[0] + value_rect[2] + control_gap, control_y, button_w, control_h)

		def draw_button(button_rect: tuple[int, int, int, int], label: str) -> bool:
			hovered = self.coll(button_rect)
			button_fill = alpha_blend(ColourRGBA(255, 255, 255, 8), fill)
			if hovered:
				button_fill = alpha_blend(alpha_mod(accent, 18), button_fill)
			self.ddt.bordered_rect(button_rect, button_fill, border, round(1 * self.gui.scale))
			self.fields.add(button_rect)
			self.ddt.text(
				(button_rect[0] + button_rect[2] // 2, button_rect[1] + round(2 * self.gui.scale), 2),
				label,
				accent if hovered else self.colours.box_text_label,
				211,
				bg=button_fill,
			)
			return hovered and self.click

		changed = False
		if draw_button(dec_rect, "−") and value > lower_limit:
			value -= step
			changed = True

		value_fill = alpha_blend(ColourRGBA(255, 255, 255, 4), fill)
		self.ddt.bordered_rect(value_rect, value_fill, border, round(1 * self.gui.scale))
		if formatter is not None:
			display_text = formatter(value)
		else:
			display_text = f"{value}{units}"
		self.ddt.text(
			(value_rect[0] + value_rect[2] // 2, value_rect[1] + round(2 * self.gui.scale), 2),
			display_text,
			self.colours.box_sub_text,
			211,
			bg=value_fill,
		)

		if draw_button(inc_rect, "+") and value < upper_limit:
			value += step
			changed = True

		if changed:
			self.gui.update_layout = True
			if callback is not None:
				callback(value)

		return value

	def settings_choice_tile(
		self,
		rect: tuple[int, int, int, int],
		title: str,
		subtitle: str,
		active: bool,
		callback: Callable[[], None] | None = None,
		accent: ColourRGBA | None = None,
		show_active_bar: bool = False,
	) -> bool:
		if accent is None:
			accent = self.settings_page_accent()

		x, y, w, h = tuple(round(v) for v in rect)
		hover = self.coll((x, y, w, h))
		fill = alpha_blend(ColourRGBA(255, 255, 255, 6), self.colours.box_background)
		if active:
			fill = alpha_blend(alpha_mod(accent, 24), fill)
		if hover:
			fill = alpha_blend(ColourRGBA(255, 255, 255, 10), fill)
		border = alpha_blend(ColourRGBA(255, 255, 255, 18), self.colours.box_text_border)
		if active:
			border = alpha_blend(alpha_mod(accent, 90), border)

		self.ddt.bordered_rect((x, y, w, h), fill, border, round(1 * self.gui.scale))
		if active and show_active_bar:
			self.ddt.rect((x, y, round(4 * self.gui.scale), h), accent)

		self.fields.add((x, y, w, h))
		hit = False
		if hover and self.click:
			self.inp.global_clicked = True
			hit = True
			if callback is not None:
				callback()

		indicator_h = round(12 * self.gui.scale)
		indicator_y = y + round(12 * self.gui.scale) if subtitle else y + max(0, (h - indicator_h) // 2)
		indicator_rect = (x + round(14 * self.gui.scale), indicator_y, round(12 * self.gui.scale), indicator_h)
		self.ddt.bordered_rect(
			indicator_rect,
			alpha_blend(ColourRGBA(255, 255, 255, 8), fill),
			alpha_blend(ColourRGBA(255, 255, 255, 18), border),
			round(1 * self.gui.scale),
		)
		if active:
			self.ddt.rect(
				(
					indicator_rect[0] + round(3 * self.gui.scale),
					indicator_rect[1] + round(3 * self.gui.scale),
					indicator_rect[2] - round(6 * self.gui.scale),
					indicator_rect[3] - round(6 * self.gui.scale),
				),
				accent,
			)

		text_x = x + round(34 * self.gui.scale)
		title_font = 13
		title_max_w = w - round(46 * self.gui.scale)
		title_y = y + round(9 * self.gui.scale)
		self.ddt.text((text_x, title_y), title, self.colours.box_text, title_font, bg=fill, max_w=title_max_w)
		if subtitle:
			self.ddt.text(
				(text_x, y + round(27 * self.gui.scale), 4, title_max_w, round(34 * self.gui.scale)),
				subtitle,
				self.colours.box_text_label,
				11,
				bg=fill,
			)

		return hit

	def settings_segmented_bar(
		self,
		pos: tuple[int, int],
		options: Sequence[tuple[str, bool, Callable[[], None]]],
		accent: ColourRGBA | None = None,
		width: int | None = None,
	) -> int:
		"""Compact radio switcher: a single bordered bar of short-label buttons,
		the active one filled with the accent colour. Segment widths follow the
		labels; pass ``width`` to stretch the bar to a fixed total width (the
		slack is shared between segments). Returns the bar height."""
		if accent is None:
			accent = self.settings_page_accent()
		gui = self.gui
		x, y = round(pos[0]), round(pos[1])
		h = round(32 * gui.scale)
		pad = round(16 * gui.scale)
		font = 212
		widths = [self.ddt.get_text_w(label, font) + pad * 2 for label, _active, _callback in options]
		if width is not None:
			extra = round(width) - sum(widths)
			if extra > 0:
				share = extra // len(widths)
				widths = [seg_w + share for seg_w in widths]
				widths[-1] += extra - share * len(widths)
		fill = alpha_blend(ColourRGBA(255, 255, 255, 6), self.colours.box_background)
		border = alpha_blend(ColourRGBA(255, 255, 255, 18), self.colours.box_text_border)
		self.ddt.bordered_rect((x, y, sum(widths), h), fill, border, round(1 * gui.scale))

		seg_x = x
		prev_active = False
		for (label, active, callback), seg_w in zip(options, widths):
			rect = (seg_x, y, seg_w, h)
			hover = self.coll(rect)
			self.fields.add(rect)
			bg = fill
			if active:
				bg = accent
				self.ddt.rect(rect, bg)
			elif hover:
				bg = alpha_blend(ColourRGBA(255, 255, 255, 10), fill)
				self.ddt.rect(rect, bg)
			# Separators between plain segments only; the accent block provides
			# its own edges
			if seg_x != x and not active and not prev_active:
				self.ddt.rect((seg_x, y, round(1 * gui.scale), h), border)
			text_colour = ColourRGBA(20, 20, 25, 255) if active else self.colours.box_text
			self.ddt.text((seg_x + seg_w // 2, y + round(7 * gui.scale), 2), label, text_colour, font, bg=bg)
			if hover and self.click and not active:
				self.inp.global_clicked = True
				callback()
			prev_active = active
			seg_x += seg_w

		return h

	def settings_switcher_tile(
		self,
		rect: tuple[int, int, int, int],
		title: str,
		active: bool,
		callback: Callable[[], None] | None = None,
		accent: ColourRGBA | None = None,
	) -> bool:
		if accent is None:
			accent = self.settings_page_accent()

		x, y, w, h = tuple(round(v) for v in rect)
		hover = self.coll((x, y, w, h))
		fill = alpha_blend(ColourRGBA(255, 255, 255, 5), self.colours.box_background)
		if active:
			fill = alpha_blend(alpha_mod(accent, 24), fill)
		if hover:
			fill = alpha_blend(ColourRGBA(255, 255, 255, 9), fill)

		border = alpha_blend(ColourRGBA(255, 255, 255, 18), self.colours.box_text_border)
		if active:
			border = alpha_blend(alpha_mod(accent, 90), border)

		self.ddt.bordered_rect((x, y, w, h), fill, border, round(1 * self.gui.scale))

		self.fields.add((x, y, w, h))
		hit = False
		if hover and self.click:
			self.inp.global_clicked = True
			hit = True
			if callback is not None:
				callback()

		text_colour = self.colours.box_text if active or hover else self.colours.box_button_text
		self.ddt.text(
			(x + round(12 * self.gui.scale), y + round(8 * self.gui.scale)),
			title,
			text_colour,
			13,
			bg=fill,
			max_w=w - round(40 * self.gui.scale),
		)

		arrow_colour = accent if active else (self.colours.box_text if hover else self.colours.box_text_label)
		arrow_x = x + w - round(20 * self.gui.scale)
		arrow_y = y + h // 2
		self.ddt.rect(
			(arrow_x - round(4 * self.gui.scale), arrow_y - round(3 * self.gui.scale), round(7 * self.gui.scale), round(2 * self.gui.scale)),
			arrow_colour,
		)
		self.ddt.rect(
			(arrow_x, arrow_y - round(1 * self.gui.scale), round(7 * self.gui.scale), round(2 * self.gui.scale)),
			arrow_colour,
		)

		return hit

	def draw_settings_note(
		self,
		rect: tuple[int, int, int, int],
		text: str,
		accent: ColourRGBA | None = None,
		title: str = "",
	) -> None:
		x, y, w, h = rect
		fill = alpha_blend(ColourRGBA(255, 255, 255, 6), self.colours.box_background)
		border = alpha_blend(ColourRGBA(255, 255, 255, 18), self.colours.box_text_border)
		self.ddt.bordered_rect(rect, fill, border, round(1 * self.gui.scale))

		text_x = x + round(14 * self.gui.scale)
		text_y = y + round(7 * self.gui.scale)
		if title:
			self.ddt.text((text_x, text_y), title, self.colours.box_text, 212, bg=fill)
			text_y += round(17 * self.gui.scale)

		self.ddt.text(
			(text_x, text_y, 4, w - round(24 * self.gui.scale), h - round(14 * self.gui.scale)),
			text,
			self.colours.box_text_label,
			11,
			bg=fill,
		)

	def select_account_view(self, view: int) -> None:
		self.account_view = view
		self.settings_text_focus = None
		self.gui.update_layout = True

	def begin_settings_text_inputs(self) -> None:
		self.settings_text_seen = []
		self.settings_text_hit = False
		if self.inp.key_tab_press and self.settings_text_order:
			step = -1 if self.inp.key_shift_down or self.inp.key_shiftr_down else 1
			if self.settings_text_focus not in self.settings_text_order:
				self.settings_text_focus = self.settings_text_order[-1] if step < 0 else self.settings_text_order[0]
			else:
				index = self.settings_text_order.index(self.settings_text_focus)
				self.settings_text_focus = self.settings_text_order[(index + step) % len(self.settings_text_order)]

	def finish_settings_text_inputs(self) -> None:
		self.settings_text_order = self.settings_text_seen[:]
		if self.settings_text_focus not in self.settings_text_order:
			self.settings_text_focus = None
		elif (self.click or self.inp.level_2_right_click) and not self.settings_text_hit:
			self.settings_text_focus = None

	def settings_text_field_state(
		self,
		field_rect: tuple[int, int, int, int],
		text_box: TextBox2,
		accent: ColourRGBA,
		editable: bool = True,
	) -> tuple[bool, ColourRGBA, ColourRGBA]:
		if text_box not in self.settings_text_seen:
			self.settings_text_seen.append(text_box)

		hover = self.coll(field_rect)
		active = editable and self.settings_text_focus is text_box
		fill = alpha_blend(ColourRGBA(255, 255, 255, 6), self.colours.box_background)
		if hover or active:
			fill = alpha_blend(ColourRGBA(255, 255, 255, 8), fill)
		border = alpha_blend(ColourRGBA(255, 255, 255, 18), self.colours.box_text_border)
		active_border = alpha_blend(alpha_mod(accent, 90), border)
		if active:
			border = active_border

		self.fields.add(field_rect)
		if hover and ((self.click and editable) or self.inp.level_2_right_click):
			self.inp.global_clicked = True
			self.settings_text_focus = text_box
			self.settings_text_hit = True
			active = editable
			if editable:
				border = active_border
		elif hover and editable:
			self.settings_text_hit = self.settings_text_hit or self.inp.mouse_down

		return active, fill, border

	def draw_settings_text_field(
		self,
		rect: tuple[int, int, int, int],
		text_box: TextBox2,
		accent: ColourRGBA,
		stored_value: str | None = None,
		text_colour: ColourRGBA | None = None,
		secret: bool = False,
		placeholder: str = "",
		editable: bool = True,
		border_override: ColourRGBA | None = None,
	) -> str:
		x, y, w, h = tuple(round(v) for v in rect)
		if stored_value is not None and self.settings_text_focus is not text_box and text_box.text != stored_value:
			text_box.text = stored_value

		active, fill, border = self.settings_text_field_state((x, y, w, h), text_box, accent, editable=editable)
		if border_override is not None and not active:
			border = border_override
		self.ddt.bordered_rect((x, y, w, h), fill, border, round(1 * self.gui.scale))

		if placeholder and not text_box.text and not active:
			self.ddt.text(
				(x + round(5 * self.gui.scale), y + round(3 * self.gui.scale)),
				placeholder,
				self.colours.box_text_label,
				12,
				bg=fill,
			)

		text_box.draw(
			x + round(4 * self.gui.scale),
			y + round(3 * self.gui.scale),
			text_colour or self.colours.box_input_text,
			active,
			secret=secret,
			width=w - round(8 * self.gui.scale),
			click=self.click,
		)
		return text_box.text

	def draw_settings_action_row(
		self,
		rect: tuple[int, int, int, int],
		items: list[tuple[str, Callable[[], None] | None, bool]],
		accent: ColourRGBA | None = None,
	) -> None:
		if accent is None:
			accent = self.settings_page_accent()
		if not items:
			return

		x, y, w, h = tuple(round(v) for v in rect)
		gap = round(8 * self.gui.scale)
		count = len(items)
		tile_w = (w - gap * (count - 1)) // max(count, 1)
		tile_x = x
		for index, (title, callback, emphasis) in enumerate(items):
			current_w = tile_w if index < count - 1 else x + w - tile_x
			self.settings_action_tile((tile_x, y, current_w, h), title, callback, accent, emphasis=emphasis)
			tile_x += current_w + gap

	def settings_text_input(
		self,
		rect: tuple[int, int, int, int],
		title: str,
		text_box: TextBox2,
		stored_value: str,
		accent: ColourRGBA | None = None,
		secret: bool = False,
		placeholder: str = "",
	) -> str:
		if accent is None:
			accent = self.settings_page_accent()

		x, y, w, h = tuple(round(v) for v in rect)
		label_y = y
		field_y = y + round(16 * self.gui.scale)
		field_h = max(round(22 * self.gui.scale), h - round(18 * self.gui.scale))
		self.ddt.text((x, label_y), title, self.colours.box_text_label, 11, bg=self.ddt.text_background_colour)
		return self.draw_settings_text_field(
			(x, field_y, w, field_h),
			text_box,
			accent,
			stored_value=stored_value,
			secret=secret,
			placeholder=placeholder,
		)

	def toggle_lyrics_view(self) -> None:
		self.lyrics_panel ^= True

	def draw_lyrics_source_settings(self, rect: tuple[int, int, int, int], accent: ColourRGBA | None = None) -> None:
		if accent is None:
			accent = self.settings_page_accent()

		x, y, w, h = rect
		content_x, content_y, content_w, content_h = self.draw_settings_section(
			rect,
			_("Lyrics lookup"),
			_("Choose when and where Tauon searches for lyrics."),
			accent,
		)

		row_h = round(40 * self.gui.scale)
		row_gap = round(6 * self.gui.scale)
		self.prefs.auto_lyrics = self.settings_switch_row(
			(content_x, content_y, content_w, row_h),
			self.prefs.auto_lyrics,
			_("Auto-search lyrics"),
			_("Auto-search when lyrics enabled"),
			accent,
		)
		content_y += row_h + row_gap

		source_names = [name for name in lyric_sources if name != "Genius"]
		if "Genius" in lyric_sources:
			source_names.append("Genius")
		for name in source_names:
			enabled = name in self.prefs.lyrics_enables
			subtitle = _("Scraping source") if name in uses_scraping else _("API source")
			new = self.settings_switch_row(
				(content_x, content_y, content_w, row_h),
				enabled,
				name,
				subtitle,
				accent,
			)
			if new != enabled:
				if enabled:
					if name in self.prefs.lyrics_enables:
						self.prefs.lyrics_enables.remove(name)
				else:
					self.prefs.lyrics_enables.append(name)
			content_y += row_h + row_gap

	def funcs(self, x0: int, y0: int, w0: int, h0: int, accent_override: ColourRGBA | None = None) -> None:
		tauon   = self.tauon
		prefs   = self.prefs
		gui     = self.gui
		ddt     = self.ddt
		colours = self.colours
		accent  = accent_override if accent_override is not None else self.settings_page_accent()

		ddt.text_background_colour = colours.box_background

		if self.func_page != 4 and not Path(prefs.playlist_folder_path).is_dir():
			# reset options if user leaves a bad path in the box
			prefs.playlist_folder_path = ""
			prefs.autoscan_playlist_folder = False

		column_gap = round(12 * gui.scale)
		card_y = y0
		card_h = h0
		left_w = max(round(270 * gui.scale), min(round(w0 * 0.56), w0 - round(220 * gui.scale)))
		if self.func_page == 1:
			left_w = (w0 - column_gap) // 2
		right_w = w0 - left_w - column_gap
		left_rect = (x0, card_y, left_w, card_h)
		right_rect = (x0 + left_w + column_gap, card_y, right_w, card_h)
		row_gap = round(6 * gui.scale)
		row_h = round(42 * gui.scale)
		small_row_h = round(30 * gui.scale)

		if self.func_page == 0:
			x, y, w, section_h = self.draw_settings_section(
				left_rect,
				_("Common settings"),
				_("Common library and panel settings."),
				accent,
			)

			# ("Show artist info panel" switch removed — the panel is toggled
			# with its keyboard shortcut (Ctrl+O) / the View Switcher instead.)
			self.settings_switch_row(
				(x, y, w, row_h),
				tauon.toggle_auto_artist_dl,
				_("Auto fetch artist data"),
				_("Fetch artist data while the panel is open."),
				accent,
			)

			y += row_h + row_gap
			prefs.always_auto_update_playlists = self.settings_switch_row(
				(x, y, w, row_h),
				prefs.always_auto_update_playlists,
				_("Auto reload playlists"),
				_("Rescan playlists when you return to them."),
				accent,
			)

			# ("Tabs in top panel" switch moved to the main menu under
			# Top Panel Layout…)

			y += row_h + row_gap + round(4 * gui.scale)
			ddt.text((x, y), _("End of playlist action"), colours.box_text_label, 11)
			y += round(20 * gui.scale)
			self.settings_segmented_bar(
				(x, y),
				(
					(_("Stop"), self.set_playlist_stop(1), self.set_playlist_stop),
					(_("Repeat"), self.set_playlist_repeat(1), self.set_playlist_repeat),
					(_("Next playlist"), self.set_playlist_advance(1), self.set_playlist_advance),
					(_("Cycle all"), self.set_playlist_cycle(1), self.set_playlist_cycle),
				),
				accent,
				width=w,
			)

			x, y, w, section_h = self.draw_settings_section(
				right_rect,
				_("Quick access"),
				_("Open the files and folders Tauon uses."),
				accent,
			)
			tile_h = round(36 * gui.scale)
			self.settings_action_tile((x, y, w, tile_h), _("Open config file"), tauon.open_config_file, accent, emphasis=gui.opened_config_file)
			y += tile_h + row_gap
			if gui.opened_config_file:
				self.settings_action_tile((x, y, w, tile_h), _("Reload edited config"), tauon.reload_config_file, accent, emphasis=True)
				y += tile_h + row_gap
			self.settings_action_tile((x, y, w, tile_h), _("Open data folder"), tauon.open_data_directory, accent)
			y += tile_h + row_gap
			self.settings_action_tile((x, y, w, tile_h), _("Open keymap file"), tauon.open_keymap_file, accent)

		elif self.func_page == 1:
			# ("End of playlist action" lives in Common settings now)
			x, y, w, section_h = self.draw_settings_section(
				left_rect,
				_("Session rules"),
				_("Playback behavior for restarts, sleep, wake and radio."),
				accent,
			)
			play_lock_old = prefs.block_suspend
			prefs.block_suspend = self.settings_switch_row(
				(x, y, w, row_h),
				prefs.block_suspend,
				_("Block suspend"),
				_("Keep the system awake during playback."),
				accent,
			)
			y += row_h + row_gap
			prefs.reload_play_state = self.settings_switch_row(
				(x, y, w, row_h),
				prefs.reload_play_state,
				_("Resume on restart"),
				_("Resume playback when Tauon starts."),
				accent,
			)
			y += row_h + row_gap
			prefs.resume_play_wake = self.settings_switch_row(
				(x, y, w, row_h),
				prefs.resume_play_wake,
				_("Resume from suspend"),
				_("Resume playback after waking."),
				accent,
			)

			y += row_h + row_gap
			auto_rec_old = prefs.auto_rec
			prefs.auto_rec = self.settings_switch_row(
				(x, y, w, row_h),
				prefs.auto_rec,
				_("Record radio"),
				_("Record internet radio and split tracks."),
				accent,
			)
			if prefs.auto_rec != auto_rec_old and prefs.auto_rec:
				self.show_message(
					_("Tracks will now be recorded. Restart any playback for change to take effect."),
					_("Tracks will be saved to \"Saved Radio Tracks\" playlist."),
					mode="info")

			if tauon.update_play_lock is None:
				prefs.block_suspend = False
			elif play_lock_old != prefs.block_suspend:
				tauon.update_play_lock()

			bottom = left_rect[1] + left_rect[3] - round(14 * gui.scale)
			remaining_h = bottom - y - row_h
			if prefs.auto_rec and remaining_h > round(50 * gui.scale):
				self.draw_settings_note(
					(x, y + row_h + row_gap, w, remaining_h - row_gap),
					_("Recorded tracks are added to \"Saved Radio Tracks\"."),
					accent,
					_("Radio capture"),
				)

			# (The "Track menu extras" search-provider switches moved to the
			# track menu's Layout submenu.)
			x, y, w, section_h = self.draw_settings_section(
				right_rect,
				_("Archive imports"),
				_("How Tauon handles archives and Downloads."),
				accent,
			)
			self.settings_switch_row(
				(x, y, w, row_h),
				tauon.toggle_extract,
				_("Extract archives"),
				_("Extract supported archives on drag and drop."),
				accent,
			)
			y += row_h + row_gap
			extract_archives_enabled = tauon.toggle_extract(1)
			self.settings_switch_row(
				(x, y, w, row_h),
				tauon.toggle_dl_mon,
				_("Enable download monitor"),
				_("Watch Downloads for one-click imports."),
				accent,
				disabled=not extract_archives_enabled,
			)
			y += row_h + row_gap
			self.settings_switch_row(
				(x, y, w, small_row_h),
				tauon.toggle_ex_del,
				_("Trash archive after extraction"),
				accent=accent,
				disabled=not extract_archives_enabled,
			)
			y += small_row_h + row_gap
			self.settings_switch_row(
				(x, y, w, small_row_h),
				tauon.toggle_music_ex,
				_("Always extract to Music folder"),
				accent=accent,
				disabled=not extract_archives_enabled,
			)

			bottom = right_rect[1] + right_rect[3] - round(14 * gui.scale)
			remaining_h = bottom - y - small_row_h
			if remaining_h > round(50 * gui.scale):
				self.draw_settings_note(
					(x, y + small_row_h + row_gap, w, remaining_h - row_gap),
					_("Useful if you often import from archives or Downloads."),
					accent,
					_("Import flow"),
				)

		elif self.func_page == 3:
			x, y, w, section_h = self.draw_settings_section(
				left_rect,
				_("Remote, presence and sharing"),
				_("Network and sharing features."),
				accent,
			)
			remote_old = prefs.enable_remote
			prefs.enable_remote = self.settings_switch_row(
				(x, y, w, row_h),
				prefs.enable_remote,
				_("Enable remote control"),
				_("Change requires restart."),
				accent,
			)
			y += row_h + row_gap

			if prefs.enable_remote and prefs.enable_remote != remote_old:
				self.show_message(
					_("Notice: This API is not security hardened."),
					_("Only enable in a trusted LAN and do not expose port (7814) to the internet"),
					mode="warning")

			listen_along_enabled = self.settings_switch_row(
				(x, y, w, row_h),
				tauon.toggle_enable_web,
				_("Enable Listen Along"),
				_("Start the web server for remote playback."),
				accent,
			)
			y += row_h + row_gap
			link = f"http://localhost:{prefs.metadata_page_port!s}/listenalong"

			def open_listenalong() -> None:
				webbrowser.open(link, new=2, autoraise=True)

			if listen_along_enabled:
				self.settings_action_tile((x, y, w, round(36 * gui.scale)), _("Open Listen Along page"), open_listenalong, accent, emphasis=True)
				y += round(36 * gui.scale) + row_gap

			discord_old = prefs.discord_enable
			discord_state = gui.discord_status if prefs.discord_enable else _("Disabled")
			discord_subtitle = _("Discord status: {state}").format(state=discord_state)
			prefs.discord_enable = self.settings_switch_row(
				(x, y, w, row_h),
				prefs.discord_enable,
				_("Enable Discord Rich Presence"),
				discord_subtitle,
				accent,
			)

			# if self.flatpak_mode and self.button(x + 215 * gui.scale, y, _("?")):
			# 	self.show_message(
			# 		_("For troubleshooting Discord RP"),
			# 		"https://github.com/Taiko2k/TauonMusicBox/wiki/Discord-RP", mode="link")

			if prefs.discord_enable and not discord_old:
				if self.snap_mode:
					self.show_message(_("Sorry, this feature is unavailable with snap"), mode="error")
					prefs.discord_enable = False
				elif not self.prefs.discord_allow:
					self.show_message(_("Missing dependency python-pypresence"))
					prefs.discord_enable = False
				else:
					try:
						tauon._signal_discord()
					except Exception:
						tauon.hit_discord()

			if discord_old and not prefs.discord_enable and prefs.discord_active:
				prefs.disconnect_discord = True

			self.draw_lyrics_source_settings(right_rect, accent)

		elif self.func_page == 4:
			x, y, w, section_h = self.draw_settings_section(
				left_rect,
				_("Exports and input"),
				_("Playlist export and input settings."),
				accent,
			)
			prefs.use_gamepad = self.settings_switch_row(
				(x, y, w, row_h),
				prefs.use_gamepad,
				_("Enable gamepad input"),
				_("Change requires restart."),
				accent,
			)
			y += row_h + round(12 * gui.scale)

			ddt.text((x, y), _("Default playlist export folder"), colours.box_text_label, 11)
			y += round(18 * gui.scale)
			rect1 = (x, y, w, round(22 * gui.scale))
			field_path = tauon.playlist_folder_box.text
			if self.prefs.playlist_folder_path:
				tauon.playlist_folder_box.text = self.prefs.playlist_folder_path
				field_path = tauon.playlist_folder_box.text
			path_invalid = bool(field_path) and not Path(field_path).is_dir()
			field_border = colours.status_text_over if path_invalid else alpha_blend(ColourRGBA(255, 255, 255, 18), colours.box_text_border)
			field_colour = colours.status_text_over if path_invalid else colours.box_input_text
			self.prefs.playlist_folder_path = self.draw_settings_text_field(
				rect1,
				tauon.playlist_folder_box,
				accent,
				stored_value=self.prefs.playlist_folder_path,
				text_colour=field_colour,
				border_override=field_border,
			)
			y += round(31 * gui.scale)

			helper_text = (
				_("Folder must exist before auto-import can use it.")
				if path_invalid else
				_("Leave blank to export beside the playlist, or set a folder here.")
			)
			helper_colour = colours.status_text_over if path_invalid else colours.box_text_label
			ddt.text((x + round(2 * gui.scale), y), helper_text, helper_colour, 11, max_w=w)
			y += round(18 * gui.scale)

			prefs.autoscan_playlist_folder = self.settings_switch_row(
				(x, y, w, row_h),
				prefs.autoscan_playlist_folder,
				_("Auto-import playlists from this folder"),
				_("Runs during \"Rescan All Folders\" only."),
				accent,
			)

			x, y, w, section_h = self.draw_settings_section(
				right_rect,
				_("Editing and diagnostics"),
				_("Lyrics writeback and debug settings."),
				accent,
			)
			prefs.save_synced_to_lrc = self.settings_switch_row(
				(x, y, w, small_row_h),
				prefs.save_synced_to_lrc,
				_("Save Synced to .lrc"),
				accent=accent,
			)

			y += small_row_h + row_gap
			prefs.save_lyrics_changes_to_files = self.settings_switch_row(
				(x, y, w, small_row_h),
				prefs.save_lyrics_changes_to_files,
				_("Save lyrics edits to tags"),
				accent=accent,
			)

			y += small_row_h + row_gap
			debug_path = self.user_directory / "debug"
			debug_state = debug_path.exists()
			old = debug_state
			debug_state = self.settings_switch_row(
				(x, y, w, small_row_h),
				debug_state,
				_("Enable debug mode"),
				accent=accent,
			)
			if old is False and debug_state is True:
				with debug_path.open("a"):
					pass
			elif old is True and debug_state is False:
				os.remove(debug_path)
			y += small_row_h + row_gap

			tauon.console.show = self.settings_switch_row(
				(x, y, w, small_row_h),
				tauon.console.show,
				_("Toggle Console"),
				accent=accent,
			)

	def button(self, x: int, y: int, text: str, plug: Callable[[], None] | None = None, width: int = 0, bg: ColourRGBA | None = None) -> bool:
		"""PSA for anyone making a new button function: use fields.add(rect) to make the gui
		refresh when you pan the mouse over it
		"""
		w = width
		if w == 0:
			w = self.ddt.get_text_w(text, 211) + round(10 * self.gui.scale)

		h = round(20 * self.gui.scale)
		border_size = round(2 * self.gui.scale)

		rect = (round(x), round(y), round(w), round(h))
		rect2 = (rect[0] - border_size, rect[1] - border_size, rect[2] + border_size * 2, rect[3] + border_size * 2)

		if bg is None:
			bg = self.colours.box_background

		real_bg = bg
		hit = False

		self.ddt.rect(rect2, self.colours.box_check_border)
		self.ddt.rect(rect, bg)

		self.fields.add(rect)
		if self.coll(rect):
			self.ddt.rect(rect, ColourRGBA(255, 255, 255, 15))
			real_bg = alpha_blend(ColourRGBA(255, 255, 255, 15), bg)
			self.ddt.text((x + int(w / 2), rect[1] + 1 * self.gui.scale, 2), text, self.colours.box_title_text, 211, bg=real_bg)
			if self.click:
				hit = True
				if plug is not None:
					plug()
		else:
			self.ddt.text((x + int(w / 2), rect[1] + 1 * self.gui.scale, 2), text, self.colours.box_sub_text, 211, bg=real_bg)

		return hit

	def button2(self, x: int, y: int, text: str, width: int = 0, center_text: bool = False, force_on: bool = False) -> bool:
		"""PSA for anyone making a new button function: use fields.add(rect) to make the gui
		refresh when you pan the mouse over it
		"""
		w = width
		if w == 0:
			w = self.ddt.get_text_w(text, 211) + 10 * self.gui.scale
		rect = (x, y, w, 20 * self.gui.scale)

		border_colour = alpha_blend(ColourRGBA(255, 255, 255, 18), self.colours.box_text_border)
		bg_colour = alpha_blend(ColourRGBA(255, 255, 255, 8), self.colours.box_button_background)
		real_bg = bg_colour

		self.ddt.bordered_rect(rect, bg_colour, border_colour, round(1 * self.gui.scale))
		self.fields.add(rect)
		hit = False

		text_position = (x + int(7 * self.gui.scale), rect[1] + 1 * self.gui.scale)
		if center_text:
			text_position = (x + rect[2] // 2, rect[1] + 1 * self.gui.scale, 2)

		if self.coll(rect) or force_on:
			bg_colour = alpha_blend(self.colours.box_button_background_highlight, bg_colour)
			real_bg = bg_colour
			self.ddt.bordered_rect(rect, bg_colour, border_colour, round(1 * self.gui.scale))
			self.ddt.text(text_position, text, self.colours.box_button_text_highlight, 211, bg=real_bg)
			if self.click and not force_on:
				hit = True
		else:
			self.ddt.text(text_position, text, self.colours.box_button_text, 211, bg=real_bg)
		return hit

	def toggle_square(self, x: int, y: int, function: Callable[[int], bool | None] | bool, text: str , click: bool = False, subtitle: str = "") -> bool:
		gui     = self.gui
		colours = self.colours
		x = round(x)
		y = round(y)

		border = round(2 * gui.scale)
		gap = round(2 * gui.scale)
		inner_square = round(6 * gui.scale)

		full_w = border * 2 + gap * 2 + inner_square

		if subtitle:
			le = self.ddt.text((x + 20 * gui.scale, y - 1 * gui.scale), text, colours.box_text, 13)
			se = self.ddt.text((x + 20 * gui.scale, y + 14 * gui.scale), subtitle, colours.box_text_label, 13)
			hit_rect = (x - 10 * gui.scale, y - 3 * gui.scale, max(le, se) + 30 * gui.scale, 34 * gui.scale)
			y += round(8 * gui.scale)
		else:
			le = self.ddt.text((x + 20 * gui.scale, y - 1 * gui.scale), text, colours.box_text, 13)
			hit_rect = (x - 10 * gui.scale, y - 3 * gui.scale, le + 30 * gui.scale, 22 * gui.scale)

		# Border outline
		self.ddt.rect_a((x, y), (full_w, full_w), colours.box_check_border)
		# Inner background
		self.ddt.rect_a(
			(x + border, y + border), (gap * 2 + inner_square, gap * 2 + inner_square),
			alpha_blend(ColourRGBA(255, 255, 255, 14), colours.box_background))

		# Check if box clicked
		self.inp.global_clicked = False
		if (self.click or click) and self.coll(hit_rect):
			self.inp.global_clicked = True

		# There are two mode, function type, and passthrough bool type
		active = False
		active = function if type(function) is bool else function(1)

		if self.inp.global_clicked:
			if type(function) is bool:
				active ^= True
			else:
				function()
				active = function(1)

		# Draw inner check mark if enabled
		if active:
			self.ddt.rect_a((x + border + gap, y + border + gap), (inner_square, inner_square), colours.toggle_box_on)

		return active

	def clear_local_loves(self) -> None:
		if not self.inp.key_shift_down:
			self.show_message(
				_("This will mark all tracks in local database as unloved!"),
				_("Press button again while holding shift key if you're sure you want to do that."),
				mode="warning")
			return

		for key, star in self.star_store.db.items():
			star.loved = False
			self.star_store.db[key] = star

		self.gui.pl_update += 1
		self.show_message(_("Cleared all loves"), mode="done")

	def get_scrobble_counts(self) -> None:
		if not self.inp.key_shift_down:
			t = self.lastfm.get_all_scrobbles_estimate_time()
			if not t:
				self.show_message(_("Error, not connected to last.fm"))
				return
			self.show_message(
				_("Warning: This process will take approximately {T} minutes to complete.").format(T=(t // 60)),
				_("Press again while holding Shift if you understand"), mode="warning")
			return

		if not self.lastfm.scanning_friends and not self.lastfm.scanning_scrobbles and not self.lastfm.scanning_loves:
			shoot_dl = threading.Thread(target=self.lastfm.get_all_scrobbles)
			shoot_dl.daemon = True
			shoot_dl.start()
		else:
			self.show_message(_("A process is already running. Wait for it to finish."))

	def clear_scrobble_counts(self) -> None:
		for track in self.pctl.master_library.values():
			track.lfm_scrobbles = 0

		self.show_message(_("Cleared all scrobble counts"), mode="done")

	def get_friend_love(self) -> None:
		if not self.inp.key_shift_down:
			self.show_message(
				_("Warning: This process can take a long time to complete! (up to an hour or more)"),
				_("This feature is not recommended for accounts that have many friends."),
				_("Press again while holding Shift if you understand"), mode="warning")
			return

		if not self.lastfm.scanning_friends and not self.lastfm.scanning_scrobbles and not self.lastfm.scanning_loves:
			logging.info("Launch friend love thread")
			shoot_dl = threading.Thread(target=self.lastfm.get_friends_love)
			shoot_dl.daemon = True
			shoot_dl.start()
		else:
			self.show_message(_("A process is already running. Wait for it to finish."))

	def get_user_love(self) -> None:
		if not self.lastfm.scanning_friends and not self.lastfm.scanning_scrobbles and not self.lastfm.scanning_loves:
			shoot_dl = threading.Thread(target=self.lastfm.dl_love)
			shoot_dl.daemon = True
			shoot_dl.start()
		else:
			self.show_message(_("A process is already running. Wait for it to finish."))

	def previous_theme(self) -> None:
		self.prefs.theme -= 1
		self.gui.reload_theme = True
		if self.prefs.theme < 0:
			self.prefs.theme = len(get_themes(self.dirs))

	def toggle_x_scale(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.x_scale
		self.prefs.x_scale ^= True
		auto_scale(self.bag)
		self.gui.update_layout = True
		return None

	def topchart(
		self,
		x0: int,
		y0: int,
		w0: int,
		h0: int,
		show_return: bool = True,
		accent: ColourRGBA | None = None,
	) -> None:
		gui = self.gui
		prefs = self.prefs
		colours = self.colours

		if accent is None:
			accent = self.settings_tab_accent(max(len(self.tabs) - 2, 0))

		self.ddt.text_background_colour = colours.box_background

		def set_chart_mode(cascade: bool) -> None:
			prefs.chart_cascade = cascade
			self.gui.update_layout = True

		def randomise_bg() -> None:
			r = round(random.random() * 40)
			g = round(random.random() * 40)
			b = round(random.random() * 40)
			prefs.chart_bg = [r, g, b]
			if random.randrange(0, 4) == 1:
				c = 5 + round(random.random() * 20)
				prefs.chart_bg = [c, c, c]

		def start_generate() -> None:
			if self.gui.generating_chart:
				self.show_message(_("Be patient!"))
			elif not prefs.chart_font:
				self.show_message(_("No font set in config"), mode="error")
			else:
				shoot = threading.Thread(target=self.tauon.gen_chart)
				shoot.daemon = True
				shoot.start()
				self.gui.generating_chart = True

		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			(x0, y0, w0, h0),
			_("Grid generator"),
			_("Build an album grid from the current playlist."),
			accent,
		)

		row_gap = round(8 * gui.scale)
		column_gap = round(10 * gui.scale)
		tile_h = round(46 * gui.scale)
		row_h = round(30 * gui.scale)
		action_h = round(36 * gui.scale)
		info_h = round(44 * gui.scale)

		dex = self.tauon.reload_albums(quiet=True, return_playlist=self.pctl.active_playlist_viewing)
		count = prefs.chart_rows * prefs.chart_columns
		if prefs.chart_cascade:
			count = prefs.chart_c1 * prefs.chart_d1 + prefs.chart_c2 * prefs.chart_d2 + prefs.chart_c3 * prefs.chart_d3

		info_fill = alpha_blend(ColourRGBA(255, 255, 255, 6), colours.box_background)
		info_border = alpha_blend(ColourRGBA(255, 255, 255, 18), colours.box_text_border)
		info_rect = (inner_x, inner_y, inner_w, info_h)
		self.ddt.bordered_rect(info_rect, info_fill, info_border, round(1 * gui.scale))
		text_x = info_rect[0] + round(14 * gui.scale)
		text_y = info_rect[1] + round(5 * gui.scale)
		self.ddt.text((text_x, text_y), _("Target playlist"), colours.box_text_label, 11, bg=info_fill)
		self.ddt.text(
			(text_x, text_y + round(17 * gui.scale)),
			self.pctl.multi_playlist[self.pctl.active_playlist_viewing].title,
			colours.box_text,
			13,
			bg=info_fill,
			max_w=info_rect[2] - round(120 * gui.scale),
		)
		self.ddt.text(
			(info_rect[0] + info_rect[2] - round(14 * gui.scale), text_y + round(17 * gui.scale), 1),
			_("{N} albums").format(N=str(len(dex))),
			colours.box_sub_text,
			12,
			bg=info_fill,
		)
		inner_y += info_h + row_gap

		left_w = max(round(250 * gui.scale), min(round(inner_w * 0.52), inner_w - round(200 * gui.scale)))
		right_w = inner_w - left_w - column_gap
		left_x = inner_x
		right_x = inner_x + left_w + column_gap
		mode_w = (left_w - column_gap) // 2
		right_y = inner_y

		self.settings_choice_tile(
			(left_x, inner_y, mode_w, tile_h),
			_("Grid"),
			_("Rows and columns"),
			not prefs.chart_cascade,
			callback=lambda: set_chart_mode(False),
			accent=accent,
			show_active_bar=False,
		)
		self.settings_choice_tile(
			(left_x + mode_w + column_gap, inner_y, mode_w, tile_h),
			_("Cascade"),
			_("Stacked groups"),
			prefs.chart_cascade,
			callback=lambda: set_chart_mode(True),
			accent=accent,
			show_active_bar=False,
		)
		inner_y += tile_h + row_gap

		if prefs.chart_cascade:
			step_w = (left_w - column_gap) // 2
			for level_name, count_key, by_key in (
				(_("Level 1"), "chart_c1", "chart_d1"),
				(_("Level 2"), "chart_c2", "chart_d2"),
				(_("Level 3"), "chart_c3", "chart_d3"),
			):
				count_value = int(getattr(prefs, count_key))
				count_value = int(self.settings_stepper_row((left_x, inner_y, step_w, row_h), level_name, count_value, 2, 20, accent=accent))
				setattr(prefs, count_key, count_value)

				by_value = int(getattr(prefs, by_key))
				by_value = int(self.settings_stepper_row((left_x + step_w + column_gap, inner_y, step_w, row_h), _("By"), by_value, 0, 10, accent=accent))
				setattr(prefs, by_key, by_value)
				inner_y += row_h + row_gap
		else:
			prefs.chart_rows = int(self.settings_stepper_row((left_x, inner_y, left_w, row_h), _("Rows"), int(prefs.chart_rows), 1, 100, accent=accent))
			inner_y += row_h + row_gap
			prefs.chart_columns = int(self.settings_stepper_row((left_x, inner_y, left_w, row_h), _("Columns"), int(prefs.chart_columns), 1, 100, accent=accent))
			inner_y += row_h + row_gap

		swatch_w = max(round(108 * gui.scale), right_w - round(126 * gui.scale))
		random_w = right_w - swatch_w - column_gap
		swatch_rect = (right_x, right_y, swatch_w, tile_h)
		self.ddt.bordered_rect(swatch_rect, info_fill, info_border, round(1 * gui.scale))
		self.ddt.text((swatch_rect[0] + round(12 * gui.scale), swatch_rect[1] + round(8 * gui.scale)), _("Background"), colours.box_text_label, 11, bg=info_fill)
		display_colour = ColourRGBA(prefs.chart_bg[0], prefs.chart_bg[1], prefs.chart_bg[2], 255)
		preview_rect = (swatch_rect[0] + round(12 * gui.scale), swatch_rect[1] + round(25 * gui.scale), round(34 * gui.scale), round(12 * gui.scale))
		self.ddt.rect(preview_rect, display_colour)
		self.ddt.rect_s(preview_rect, alpha_blend(ColourRGBA(255, 255, 255, 40), colours.box_text_border), round(1 * gui.scale))
		self.ddt.text(
			(preview_rect[0] + preview_rect[2] + round(10 * gui.scale), swatch_rect[1] + round(22 * gui.scale)),
			f"{prefs.chart_bg[0]}, {prefs.chart_bg[1]}, {prefs.chart_bg[2]}",
			colours.box_sub_text,
			11,
			bg=info_fill,
			max_w=swatch_rect[2] - preview_rect[2] - round(34 * gui.scale),
		)
		self.settings_action_tile((right_x + swatch_w + column_gap, right_y, random_w, tile_h), _("Randomize"), randomise_bg, accent)
		right_y += tile_h + row_gap

		use_padding = not prefs.chart_tile
		new_padding = self.settings_switch_row((right_x, right_y, right_w, row_h), use_padding, _("Use padding"), accent=accent)
		if new_padding != use_padding:
			prefs.chart_tile = not new_padding
		right_y += row_h + row_gap

		prefs.chart_text = self.settings_switch_row((right_x, right_y, right_w, row_h), prefs.chart_text, _("Include album titles"), accent=accent)
		right_y += row_h + row_gap
		prefs.topchart_sorts_played = self.settings_switch_row((right_x, right_y, right_w, row_h), prefs.topchart_sorts_played, _("Sort by top played"), accent=accent)

		bottom_y = max(inner_y, right_y + row_h) + row_gap
		generate_w = round(136 * gui.scale)
		return_w = round(98 * gui.scale) if show_return else 0
		status_x = inner_x
		if show_return:
			if self.settings_action_tile((inner_x, bottom_y, return_w, action_h), _("Return"), None, accent):
				self.chart_view = 0
			status_x += return_w + column_gap

		self.settings_action_tile((status_x, bottom_y, generate_w, action_h), _("Generate"), start_generate, accent, emphasis=True)
		status_x += generate_w + column_gap
		status_w = inner_x + inner_w - status_x

		if status_w > round(120 * gui.scale):
			status_rect = (status_x, bottom_y, status_w, action_h)
			self.ddt.bordered_rect(status_rect, info_fill, info_border, round(1 * gui.scale))
			line_y = status_rect[1] + round(4 * gui.scale)
			if self.gui.generating_chart:
				self.ddt.text((status_rect[0] + round(12 * gui.scale), line_y), _("Generating..."), colours.box_text, 12, bg=info_fill)
				self.ddt.text((status_rect[0] + round(12 * gui.scale), line_y + round(14 * gui.scale)), _("Please wait."), colours.box_text_label, 11, bg=info_fill)
			else:
				self.ddt.text(
					(status_rect[0] + round(12 * gui.scale), line_y),
					_("{N} Album chart").format(N=str(count)),
					colours.box_text,
					12,
					bg=info_fill,
				)
				if len(dex) < count:
					self.ddt.text(
						(status_rect[0] + round(12 * gui.scale), line_y + round(14 * gui.scale)),
						_("Not enough albums in the playlist!"),
						ColourRGBA(255, 120, 125, 255),
						11,
						bg=info_fill,
					)
				else:
					self.ddt.text(
						(status_rect[0] + round(12 * gui.scale), line_y + round(14 * gui.scale)),
						_("Ready"),
						colours.box_text_label,
						11,
						bg=info_fill,
					)

	def set_playlist_cycle(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.end_setting == "cycle"
		self.prefs.end_setting = "cycle"
		# pl_follow = False
		return None

	def set_playlist_advance(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.end_setting == "advance"
		self.prefs.end_setting = "advance"
		# pl_follow = False
		return None

	def set_playlist_stop(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.end_setting == "stop"
		self.prefs.end_setting = "stop"
		return None

	def set_playlist_repeat(self, mode: int = 0) -> bool | None:
		if mode == 1:
			return self.prefs.end_setting == "repeat"
		self.prefs.end_setting = "repeat"
		return None

	def small_preset(self) -> None:
		self.prefs.playlist_row_height = round(22 * self.prefs.ui_scale)
		self.prefs.playlist_font_size = 15
		self.prefs.tracklist_y_text_offset = 0
		self.gui.update_layout = True

	def large_preset(self) -> None:
		self.prefs.playlist_row_height = round(27 * self.prefs.ui_scale)
		self.prefs.playlist_font_size = 15
		self.gui.update_layout = True

	def render_settings_func_category(
		self,
		page: int,
		x: int,
		y: int,
		w: int,
		draw: bool = True,
		accent_override: ColourRGBA | None = None,
	) -> int:
		heights = (
			round(245 * self.gui.scale),
			round(275 * self.gui.scale),
			round(262 * self.gui.scale),
			round(300 * self.gui.scale),
			round(350 * self.gui.scale),
		)
		height = heights[page]
		if draw:
			old_page = self.func_page
			self.func_page = page
			self.funcs(x, y, w, height, accent_override=accent_override)
			self.func_page = old_page
		return height

	def render_settings_general_category(self, x: int, y: int, w: int, draw: bool = True) -> int:
		# (func page 2 is empty now — Archive imports moved beside Session
		# rules on page 1)
		block_gap = round(12 * self.gui.scale)
		general_accent = self.settings_page_accent(0)
		general_h = self.render_settings_func_category(0, x, y, w, draw, accent_override=general_accent)
		behaviour_y = y + general_h + block_gap
		behaviour_h = self.render_settings_func_category(1, x, behaviour_y, w, draw, accent_override=general_accent)
		return general_h + behaviour_h + block_gap

	def render_settings_connections_category(self, x: int, y: int, w: int, accent: ColourRGBA, draw: bool = True) -> int:
		gui = self.gui
		tauon = self.tauon
		prefs = self.prefs
		ddt = self.ddt
		colours = self.colours

		column_gap = round(12 * gui.scale)
		block_gap = round(12 * gui.scale)
		row_gap = round(6 * gui.scale)
		row_h = round(42 * gui.scale)
		compact_row_h = round(30 * gui.scale)
		choice_h = round(42 * gui.scale)
		label_gap = round(16 * gui.scale)
		tile_gap = round(8 * gui.scale)
		left_w = max(round(270 * gui.scale), min(round(w * 0.5), w - round(220 * gui.scale)))
		right_w = w - left_w - column_gap
		top_row_h = round(286 * gui.scale)
		discord_h = round(252 * gui.scale) if prefs.discord_enable else 0
		total_h = top_row_h + (block_gap + discord_h if discord_h else 0)
		if not draw:
			return total_h

		left_rect = (x, y, left_w, top_row_h)
		right_rect = (x + left_w + column_gap, y, right_w, top_row_h)

		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			left_rect,
			_("Remote, presence and sharing"),
			_("Network and sharing features."),
			accent,
		)
		remote_old = prefs.enable_remote
		prefs.enable_remote = self.settings_switch_row(
			(inner_x, inner_y, inner_w, row_h),
			prefs.enable_remote,
			_("Enable remote control"),
			_("Change requires restart."),
			accent,
		)
		inner_y += row_h + row_gap

		if prefs.enable_remote and prefs.enable_remote != remote_old:
			self.show_message(
				_("Notice: This API is not security hardened."),
				_("Only enable in a trusted LAN and do not expose port (7814) to the internet"),
				mode="warning",
			)

		listen_along_enabled = self.settings_switch_row(
			(inner_x, inner_y, inner_w, row_h),
			tauon.toggle_enable_web,
			_("Enable Listen Along"),
			_("Start the web server for remote playback."),
			accent,
		)
		inner_y += row_h + row_gap
		link = f"http://localhost:{prefs.metadata_page_port!s}/listenalong"

		def open_listenalong() -> None:
			webbrowser.open(link, new=2, autoraise=True)

		if listen_along_enabled:
			self.settings_action_tile(
				(inner_x, inner_y, inner_w, round(36 * gui.scale)),
				_("Open Listen Along page"),
				open_listenalong,
				accent,
				emphasis=True,
			)
			inner_y += round(36 * gui.scale) + row_gap

		discord_old = prefs.discord_enable
		discord_state = gui.discord_status if prefs.discord_enable else _("Disabled")
		discord_subtitle = _("Discord status: {state}").format(state=discord_state)
		prefs.discord_enable = self.settings_switch_row(
			(inner_x, inner_y, inner_w, row_h),
			prefs.discord_enable,
			_("Enable Discord Rich Presence"),
			discord_subtitle,
			accent,
		)

		if prefs.discord_enable and not discord_old:
			if self.snap_mode:
				self.show_message(_("Sorry, this feature is unavailable with snap"), mode="error")
				prefs.discord_enable = False
			elif not self.prefs.discord_allow:
				self.show_message(_("Missing dependency python-pypresence"))
				prefs.discord_enable = False
			else:
				try:
					tauon._signal_discord()
				except Exception:
					tauon.hit_discord()

		if discord_old and not prefs.discord_enable and prefs.discord_active:
			prefs.disconnect_discord = True

		self.draw_lyrics_source_settings(right_rect, accent)

		if not prefs.discord_enable:
			return total_h

		discord_accent = ColourRGBA(88, 145, 255, 255)
		discord_rect = (x, y + top_row_h + block_gap, w, discord_h)
		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			discord_rect,
			_("Discord"),
			_("Layout, buttons and idle behavior."),
			discord_accent,
		)

		left_col_w = (inner_w - column_gap) // 2
		right_col_x = inner_x + left_col_w + column_gap
		right_col_w = inner_w - left_col_w - column_gap
		choice_w = (left_col_w - tile_gap) // 2

		def set_discord_card_layout(layout: str) -> None:
			prefs.discord_card_layout = layout

		def set_member_list_display(display: str) -> None:
			prefs.discord_member_list_display = display

		ddt.text((inner_x, inner_y), _("Card order"), colours.box_text_label, 11)
		choice_y = inner_y + label_gap
		self.settings_choice_tile(
			(inner_x, choice_y, choice_w, choice_h),
			_("Song first"),
			"",
			prefs.discord_card_layout == "title_artist",
			lambda: set_discord_card_layout("title_artist"),
			discord_accent,
		)
		self.settings_choice_tile(
			(inner_x + choice_w + tile_gap, choice_y, choice_w, choice_h),
			_("Artist first"),
			"",
			prefs.discord_card_layout == "artist_title",
			lambda: set_discord_card_layout("artist_title"),
			discord_accent,
		)

		member_y = choice_y + choice_h + round(12 * gui.scale)
		ddt.text((inner_x, member_y), _("Member list shows:"), colours.box_text_label, 11)
		member_choice_y = member_y + label_gap
		self.settings_choice_tile(
			(inner_x, member_choice_y, choice_w, choice_h),
			_("Song"),
			"",
			prefs.discord_member_list_display == "song",
			lambda: set_member_list_display("song"),
			discord_accent,
		)
		self.settings_choice_tile(
			(inner_x + choice_w + tile_gap, member_choice_y, choice_w, choice_h),
			_("Artist"),
			"",
			prefs.discord_member_list_display == "artist",
			lambda: set_member_list_display("artist"),
			discord_accent,
		)

		ddt.text((right_col_x, inner_y), _("Options"), colours.box_text_label, 11)
		option_y = inner_y + label_gap
		prefs.discord_clean_title = self.settings_switch_row(
			(right_col_x, option_y, right_col_w, compact_row_h),
			prefs.discord_clean_title,
			_("Clean title (Removes .feat etc)"),
			accent=discord_accent,
		)
		option_y += compact_row_h + row_gap
		prefs.discord_lastfm_button = self.settings_switch_row(
			(right_col_x, option_y, right_col_w, compact_row_h),
			prefs.discord_lastfm_button,
			_("Last.fm link button"),
			accent=discord_accent,
		)
		option_y += compact_row_h + row_gap
		prefs.discord_show_tauon_button = self.settings_switch_row(
			(right_col_x, option_y, right_col_w, compact_row_h),
			prefs.discord_show_tauon_button,
			_("Tauon website button"),
			accent=discord_accent,
		)
		option_y += compact_row_h + row_gap
		prefs.discord_keep_idle = self.settings_switch_row(
			(right_col_x, option_y, right_col_w, compact_row_h),
			prefs.discord_keep_idle,
			_("Keep idle"),
			accent=discord_accent,
		)

		return total_h

	def render_settings_view_category(self, x: int, y: int, w: int, accent: ColourRGBA, draw: bool = True) -> int:
		# (The View card is gone: its scroll settings moved into the Window
		# card; "Centered metadata side panel", "Zoom album art to fit" and
		# "MilkDrop visualiser" moved to the side panel album art right-click
		# menu; "Showcase visualisation" to the showcase view's right-click
		# menu; the Gallery section to the gallery's background right-click
		# menu.)
		return self.render_settings_window_category(x, y, w, accent, draw)

	def render_settings_theme_category(self, x: int, y: int, w: int, accent: ColourRGBA, draw: bool = True) -> int:
		gui = self.gui
		prefs = self.prefs
		column_gap = round(12 * gui.scale)
		left_w = max(round(270 * gui.scale), min(round(w * 0.48), w - round(250 * gui.scale)))
		right_w = w - left_w - column_gap
		row_h = round(30 * gui.scale)
		row_gap = round(6 * gui.scale)
		action_h = round(36 * gui.scale)
		preset_gap = round(6 * gui.scale)
		preset_w = round(28 * gui.scale)
		preset_h = round(16 * gui.scale)
		right_inner_w = right_w - round(36 * gui.scale)
		theme_count = max(len(self.themes), 1)
		preset_columns = max(1, min(theme_count, (right_inner_w + preset_gap) // max(preset_w + preset_gap, 1)))
		preset_rows = max(1, math.ceil(theme_count / preset_columns))
		preset_grid_h = preset_rows * preset_h + max(0, preset_rows - 1) * preset_gap
		left_min_h = round(80 * gui.scale) + row_h * 5 + row_gap * 4
		right_min_h = round(132 * gui.scale) + preset_grid_h + row_gap * 3 + action_h + row_h
		card_h = max(left_min_h, right_min_h)
		left_rect = (x, y, left_w, card_h)
		right_rect = (x + left_w + column_gap, y, right_w, card_h)
		if not draw:
			return max(left_rect[3], right_rect[3])

		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			left_rect,
			_("Background"),
			_("Artwork and automatic theme changes."),
			accent,
		)
		self.settings_switch_row((inner_x, inner_y, inner_w, row_h), self.tauon.toggle_auto_bg, _("Use album art as background"), accent=accent)
		inner_y += row_h + row_gap
		old_fanart = prefs.enable_fanart_bg
		prefs.enable_fanart_bg = self.settings_switch_row((inner_x, inner_y, inner_w, row_h), prefs.enable_fanart_bg, _("Prefer artist backgrounds"), accent=accent)
		if prefs.enable_fanart_bg and prefs.enable_fanart_bg != old_fanart and not prefs.auto_dl_artist_data:
			prefs.auto_dl_artist_data = True
			self.show_message(
				_("Also enabling 'auto-fetch artist data' to scrape last.fm."),
				_("You can toggle this back off under Settings > General"),
			)
		inner_y += row_h + row_gap
		self.settings_switch_row((inner_x, inner_y, inner_w, row_h), self.tauon.toggle_auto_bg_strong, _("Stronger background"), accent=accent)
		inner_y += row_h + row_gap
		self.settings_switch_row((inner_x, inner_y, inner_w, row_h), self.tauon.toggle_auto_bg_blur, _("Blur background"), accent=accent)
		inner_y += row_h + row_gap
		self.settings_switch_row((inner_x, inner_y, inner_w, row_h), self.tauon.toggle_auto_theme, _("Auto-theme from album art"), accent=accent)

		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			right_rect,
			_("Theme preset"),
				gui.theme_name,
			accent,
		)
		preset_columns = max(1, min(theme_count, (inner_w + preset_gap) // max(preset_w + preset_gap, 1)))
		preset_rows = max(1, math.ceil(theme_count / preset_columns))
		grid_w = preset_columns * preset_w + max(0, preset_columns - 1) * preset_gap
		grid_x = inner_x + max(0, (inner_w - grid_w) // 2)
		grid_y = inner_y + round(4 * gui.scale)
		for index, (theme_colours, theme_name, theme_number) in enumerate(self.themes):
			col = index % preset_columns
			row = index // preset_columns
			cell_x = grid_x + col * (preset_w + preset_gap)
			cell_y = grid_y + row * (preset_h + preset_gap)
			rect = (cell_x, cell_y, preset_w, preset_h)
			hit_rect = grow_rect(rect, round(4 * gui.scale))
			hover = self.coll(hit_rect)
			active = theme_name == gui.theme_name
			if active:
				self.ddt.bordered_rect(
					(
						cell_x - round(2 * gui.scale),
						cell_y - round(2 * gui.scale),
						preset_w + round(4 * gui.scale),
						preset_h + round(4 * gui.scale),
					),
					alpha_blend(alpha_mod(accent, 20), self.colours.box_background),
					alpha_blend(alpha_mod(accent, 120), self.colours.box_text_border),
					round(1 * gui.scale),
				)

			base_fill = alpha_blend(ColourRGBA(255, 255, 255, 6), self.colours.box_background)
			if hover:
				base_fill = alpha_blend(ColourRGBA(255, 255, 255, 10), base_fill)
			border = alpha_blend(ColourRGBA(255, 255, 255, 18), self.colours.box_text_border)
			if active:
				border = alpha_blend(alpha_mod(accent, 90), border)
			self.ddt.bordered_rect(rect, base_fill, border, round(1 * gui.scale))
			self.fields.add(hit_rect)
			if hover and self.click:
				prefs.theme = theme_number
				gui.reload_theme = True

			c1 = theme_colours.playlist_panel_background
			c2 = theme_colours.artist_playing
			c3 = theme_colours.title_playing
			c4 = theme_colours.bottom_panel_colour
			if theme_name == "Carbon":
				c1 = theme_colours.title_playing
				c2 = theme_colours.playlist_panel_background
				c3 = theme_colours.top_panel_background
			if theme_name == "Lavender Light":
				c1 = theme_colours.tab_background_active
			if theme_name == "Neon Love":
				c2 = theme_colours.artist_text
				c4 = ColourRGBA(118, 85, 194, 255)
				c1 = c4
			if theme_name == "Sky":
				c2 = theme_colours.artist_text
			if theme_name == "Sunken":
				c2 = theme_colours.title_text
				c3 = theme_colours.artist_text
				c4 = ColourRGBA(59, 115, 109, 255)
				c1 = c4

			strip_x = cell_x + round(2 * gui.scale)
			strip_y = cell_y + round(2 * gui.scale)
			strip_w = preset_w - round(4 * gui.scale)
			strip_h = preset_h - round(4 * gui.scale)
			colours = (c1, c2, c3, c4)
			segment_w = max(1, strip_w // len(colours))
			for colour_index, colour_value in enumerate(colours):
				segment_x = strip_x + colour_index * segment_w
				if colour_index == len(colours) - 1:
					segment_width = strip_x + strip_w - segment_x
				else:
					segment_width = segment_w
				self.ddt.rect((segment_x, strip_y, segment_width, strip_h), colour_value)

		toggle_y = right_rect[1] + right_rect[3] - round(14 * gui.scale) - row_h
		action_y = toggle_y - row_gap - action_h
		icon_button_w = round(26 * gui.scale)
		icon_gap = round(4 * gui.scale)
		action_total_w = icon_button_w * 3 + icon_gap * 2
		action_x = inner_x + inner_w - action_total_w
		self.settings_icon_button(
			(action_x, action_y, icon_button_w, action_h),
			self.create_user_theme,
			accent=accent,
			draw_icon=self.draw_duplicate_theme_icon,
			tooltip=_("Duplicate"),
		)
		self.settings_icon_button(
			(action_x + icon_button_w + icon_gap, action_y, icon_button_w, action_h),
			self.open_theme_editor,
			accent=accent,
			icon=self.gui.rename_tracks_icon,
			tooltip=_("Edit"),
		)
		self.settings_icon_button(
			(action_x + (icon_button_w + icon_gap) * 2, action_y, icon_button_w, action_h),
			self.delete_active_user_theme,
			accent=accent,
			icon=self.gui.delete_icon,
			tooltip=_("Delete"),
		)
		self.settings_switch_row((inner_x, toggle_y, inner_w, row_h), self.tauon.toggle_transparent_accent, _("Transparent accent"), accent=accent)

		return max(left_rect[3], right_rect[3])

	def render_settings_window_category(self, x: int, y: int, w: int, accent: ColourRGBA, draw: bool = True) -> int:
		gui = self.gui
		prefs = self.prefs
		column_gap = round(12 * gui.scale)
		left_w = max(round(270 * gui.scale), min(round(w * 0.5), w - round(220 * gui.scale)))
		right_w = w - left_w - column_gap
		left_rect = (x, y, left_w, round(500 * gui.scale))
		right_rect = (x + left_w + column_gap, y, right_w, round(360 * gui.scale))
		if not draw:
			return max(left_rect[3], right_rect[3])

		row_h = round(30 * gui.scale)
		row_gap = round(6 * gui.scale)
		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			left_rect,
			_("Window"),
			_("Notifications, on-screen controls, scrolling and scale."),
			accent,
		)
		self.settings_switch_row((inner_x, inner_y, inner_w, row_h), self.tauon.toggle_notifications, _("Emit track change notifications"), accent=accent)
		inner_y += row_h + row_gap
		self.settings_switch_row((inner_x, inner_y, inner_w, row_h), self.tauon.toggle_borderless, _("Draw own window decorations"), accent=accent)
		inner_y += row_h + row_gap
		self.settings_switch_row(
			(inner_x, inner_y, inner_w, row_h), self.tauon.toggle_titlebar_line, _("Show playing in titlebar"),
			accent=accent, disabled=self.tauon.draw_border)
		inner_y += row_h + row_gap
		self.settings_switch_row(
			(inner_x, inner_y, inner_w, row_h), self.tauon.toggle_rounded_corners, _("Rounded window corners"),
			accent=accent, disabled=not self.tauon.draw_border)
		inner_y += row_h + row_gap
		new_radius = self.draw_settings_range_slider(
			(inner_x, inner_y, inner_w, round(46 * gui.scale)),
			_("Corner radius"),
			float(prefs.corner_radius),
			2,
			30,
			1,
			accent=accent,
			formatter=lambda value: f"{round(value)} px",
			disabled=not (self.tauon.draw_border and prefs.rounded_corners),
		)
		if round(new_radius) != prefs.corner_radius:
			prefs.corner_radius = round(new_radius)
			gui.update += 1
		inner_y += round(52 * gui.scale)
		old_on_top = prefs.mini_mode_on_top
		prefs.mini_mode_on_top = self.settings_switch_row((inner_x, inner_y, inner_w, row_h), prefs.mini_mode_on_top, _("Mini-mode always on top"), accent=accent)
		if self.wayland and prefs.mini_mode_on_top and prefs.mini_mode_on_top != old_on_top:
			self.show_message(_("Always-on-top feature not yet implemented for Wayland mode"))
		# ("Top-panel visualiser" switch moved to the main menu under
		# Top Panel Layout…)

		inner_y += row_h + row_gap
		self.settings_switch_row(
			(inner_x, inner_y, inner_w, round(42 * gui.scale)),
			self.tauon.toggle_smooth_scroll,
			_("Smooth scrolling"),
			_("Use inertial scrolling"),
			accent=accent,
			disabled=prefs.macos,
		)
		inner_y += round(42 * gui.scale) + row_gap
		prefs.smooth_scroll_speed = self.draw_settings_range_slider(
			(inner_x, inner_y, inner_w, round(46 * gui.scale)),
			_("Smooth scroll speed"),
			prefs.smooth_scroll_speed,
			0.25,
			10.0,
			0.05,
			accent=accent,
			formatter=lambda number: f"{number:.2f}x",
		)
		inner_y += round(52 * gui.scale)

		def normalize_scale_value(value: float) -> float:
			scale_value = max(min(round(round(value / 0.05) * 0.05, 2), 3.5), 0.5)
			if scale_value in (0.95, 1.05):
				scale_value = 1.0
			if scale_value in (1.95, 2.05):
				scale_value = 2.0
			if scale_value in (2.95, 3.05):
				scale_value = 3.0
			return scale_value

		def set_scale(value: float) -> None:
			prefs.scale_want = normalize_scale_value(value)
			prefs.x_scale = False
			gui.update += 1
			gui.update_layout = True

		preview_scale = float(
			prefs.scale_want
			if self.settings_scale_preview_value is None else
			self.settings_scale_preview_value
		)
		scale_slider_rect = (inner_x, inner_y, inner_w, round(46 * gui.scale))
		holding_scale_slider = self.inp.mouse_down and self.coll(scale_slider_rect)
		new_preview_scale = self.draw_settings_range_slider(
			scale_slider_rect,
			_("Interface scale"),
			preview_scale,
			0.5,
			3.5,
			0.05,
			accent=accent,
			formatter=lambda value: (
				_("auto")
				if prefs.x_scale and not holding_scale_slider and self.settings_scale_preview_value is None else
				f"{normalize_scale_value(value):.2f}x"
			),
		)
		new_preview_scale = normalize_scale_value(new_preview_scale)
		if abs(new_preview_scale - preview_scale) > 0.0001:
			self.settings_scale_preview_value = new_preview_scale
		if self.inp.mouse_up and self.settings_scale_preview_value is not None:
			set_scale(self.settings_scale_preview_value)
			self.settings_scale_preview_value = None
		inner_y += round(52 * gui.scale)
		self.settings_switch_row((inner_x, inner_y, inner_w, row_h), self.toggle_x_scale, _("Auto scale"), accent=accent)

		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			right_rect,
			_("Tray"),
			_("System tray options."),
			accent,
		)
		self.settings_switch_row((inner_x, inner_y, inner_w, row_h), self.tauon.toggle_use_tray, _("Show icon in system tray"), accent=accent)
		inner_y += row_h + row_gap
		self.settings_switch_row((inner_x, inner_y, inner_w, row_h), self.tauon.toggle_min_tray, _("Close to tray"), accent=accent)
		inner_y += row_h + row_gap
		self.settings_switch_row((inner_x, inner_y, inner_w, row_h), self.tauon.toggle_start_in_tray, _("Start in tray"), accent=accent, disabled=not prefs.use_tray)
		inner_y += row_h + row_gap
		self.settings_switch_row((inner_x, inner_y, inner_w, row_h), self.tauon.toggle_text_tray, _("Show title text"), accent=accent)
		inner_y += row_h + row_gap
		old_theme = prefs.tray_theme
		mono = self.settings_switch_row((inner_x, inner_y, inner_w, row_h), prefs.tray_theme == "gray", _("Monochrome tray icon"), accent=accent)
		prefs.tray_theme = "gray" if mono else "pink"
		if prefs.tray_theme != old_theme:
			self.tauon.set_tray_icons(force=True)
			self.show_message(_("Restart Tauon for change to take effect"))

		return max(left_rect[3], right_rect[3])

	def render_settings_audio_category(self, x: int, y: int, w: int, accent: ColourRGBA, draw: bool = True) -> int:
		gui = self.gui
		prefs = self.prefs
		if not self.phazor_found or prefs.backend != Backend.PHAZOR:
			body_h = round(120 * gui.scale)
			if draw:
				self.draw_settings_note(
					(x, y, w, body_h),
					_("Audio controls are only available with the PHAzOR backend."),
					accent,
					_("Playback backend"),
				)
			return body_h

		column_gap = round(12 * gui.scale)
		left_w = max(round(270 * gui.scale), min(round(w * 0.48), w - round(240 * gui.scale)))
		right_w = w - left_w - column_gap
		row1_h = round(416 * gui.scale)
		row2_h = round(291 * gui.scale)
		if not draw:
			return row1_h + row2_h + column_gap

		row_h = round(30 * gui.scale)
		row_gap = round(6 * gui.scale)
		left_rect = (x, y, left_w, row1_h)
		right_rect = (x + left_w + column_gap, y, right_w, row1_h)
		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			left_rect,
			_("Playback and cache"),
			_("Playback behaviour and local file caching."),
			accent,
		)
		self.settings_switch_row((inner_x, inner_y, inner_w, row_h), self.tauon.toggle_pause_fade, _("Fade on pause and stop"), accent=accent)
		inner_y += row_h + row_gap
		self.settings_switch_row((inner_x, inner_y, inner_w, row_h), self.tauon.toggle_jump_crossfade, _("Fade on track jump"), accent=accent)
		inner_y += row_h + row_gap
		prefs.back_restarts = self.settings_switch_row((inner_x, inner_y, inner_w, row_h), prefs.back_restarts, _("Back restarts to beginning"), accent=accent)
		inner_y += row_h + row_gap
		prefs.precache = self.settings_switch_row((inner_x, inner_y, inner_w, row_h), prefs.precache, _("Cache local files"), accent=accent)
		inner_y += row_h + row_gap
		old_tmp_cache = prefs.tmp_cache
		prefs.tmp_cache = self.settings_switch_row((inner_x, inner_y, inner_w, row_h), prefs.tmp_cache ^ True, _("Use persistent network cache"), accent=accent) ^ True
		if old_tmp_cache != prefs.tmp_cache and self.tauon.cachement:
			self.tauon.cachement.__init__(self.tauon)
		inner_y += row_h + row_gap
		# Applies from the next track load. Shown inverted: when enabled,
		# network tracks are fully downloaded to the cache before playing
		# (network_stream off); when disabled, they are streamed directly.
		prefs.network_stream = self.settings_switch_row((inner_x, inner_y, inner_w, row_h), prefs.network_stream ^ True, _("Wait for entire file"), accent=accent) ^ True
		inner_y += row_h + row_gap
		cache_size_gb = round(self.draw_settings_range_slider(
			(inner_x, inner_y, inner_w, round(46 * gui.scale)),
			_("Cache size"),
			float(self.prefs.cache_limit / 1000),
			0.5,
			1000,
			0,
			accent=accent,
			formatter=lambda number: f"{round(number, 1):.1f} GB",
			log_scale=True,
		), 1)
		prefs.cache_limit = int(cache_size_gb * 1000)
		inner_y += round(52 * gui.scale)
		old_resample = prefs.avoid_resampling
		prefs.avoid_resampling = self.settings_switch_row((inner_x, inner_y, inner_w, row_h), prefs.avoid_resampling, _("Avoid resampling"), accent=accent)
		if prefs.avoid_resampling != old_resample:
			self.pctl.playerCommand = "reload"
			self.pctl.playerCommandReady = True

		self.draw_audio_device_selector(right_rect, accent)

		row2_y = y + row1_h + column_gap
		left_rect = (x, row2_y, left_w, row2_h)
		right_rect = (x + left_w + column_gap, row2_y, right_w, row2_h)

		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			left_rect,
			_("ReplayGain"),
			_("Playback loudness matching."),
			accent,
		)
		bar_h = self.settings_segmented_bar(
			(inner_x, inner_y),
			(
				(_("Off"), self.tauon.switch_rg_off(1), self.tauon.switch_rg_off),
				(_("Auto"), self.tauon.switch_rg_auto(1), self.tauon.switch_rg_auto),
				(_("Album"), self.tauon.switch_rg_album(1), self.tauon.switch_rg_album),
				(_("Tracks"), self.tauon.switch_rg_track(1), self.tauon.switch_rg_track),
			),
			accent,
			width=inner_w,
		)
		inner_y += bar_h + round(12 * gui.scale)
		prefs.replay_preamp = int(self.settings_stepper_row(
			(inner_x, inner_y, inner_w, round(30 * gui.scale)),
			_("Pre-amp"),
			prefs.replay_preamp,
			-15,
			15,
			accent=accent,
			formatter=lambda number: f"{number:+d} dB" if number else "0 dB",
		))

		inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
			right_rect,
			_("Equalizer"),
			_("Ten-band playback EQ."),
			accent,
		)
		if not isinstance(prefs.eq, list):
			try:
				prefs.eq = list(prefs.eq)
			except Exception:
				prefs.eq = []
		if len(prefs.eq) < 10:
			prefs.eq.extend([0.0] * (10 - len(prefs.eq)))
		elif len(prefs.eq) > 10:
			prefs.eq = prefs.eq[:10]

		eq_enabled = self.tauon.toggle_eq(1)
		switch_w = round(150 * gui.scale)
		self.settings_switch_row((inner_x, inner_y, switch_w, round(30 * gui.scale)), self.tauon.toggle_eq, _("Enable EQ"), accent=accent)
		reset_w = round(96 * gui.scale)
		reset_x = inner_x + inner_w - reset_w
		self.settings_action_tile((reset_x, inner_y, reset_w, round(30 * gui.scale)), _("Reset"), plug=self.reset_eq, accent=accent)
		bar_y = inner_y + round(44 * gui.scale)
		bar_h = min(round(150 * gui.scale), inner_h - round(52 * gui.scale))
		center = bar_h // 2
		bar_w = max(round(18 * gui.scale), (inner_w - round(18 * gui.scale)) // 10)
		bar_gap = max(round(3 * gui.scale), (inner_w - bar_w * 10) // 9)
		db_range = 12
		labels = ("31", "62", "125", "250", "500", "1k", "2k", "4k", "8k", "16k")
		self.ddt.rect((inner_x, bar_y + center, inner_w, round(1 * gui.scale)), alpha_blend(ColourRGBA(255, 255, 255, 20), self.colours.box_text_border))
		bar_fill_colour = accent if eq_enabled else alpha_blend(ColourRGBA(255, 255, 255, 80), self.colours.box_text_border)
		bar_x = inner_x
		for i, q in enumerate(prefs.eq):
			track_rect = (bar_x, bar_y, bar_w, bar_h)
			self.ddt.rect(track_rect, ColourRGBA(255, 255, 255, 18))
			hit_rect = grow_rect(track_rect, round(3 * gui.scale))
			self.fields.add(hit_rect)
			if self.coll(hit_rect):
				if self.inp.mouse_down:
					target = self.inp.mouse_position[1] - bar_y - center
					target = (target / max(center, 1)) * db_range * -1
					target = min(target, db_range)
					target = max(target, db_range * -1)
					if -0.1 < target < 0.1:
						target = 0
					prefs.eq[i] = target
					self.gui.update += 1
					self.pctl.playerCommand = "seteq"
					self.pctl.playerCommandReady = True
				if self.right_click:
					prefs.eq[i] = 0
					self.gui.update += 1
					self.pctl.playerCommand = "seteq"
					self.pctl.playerCommandReady = True

			start = (q / db_range) * center * -1
			fill_rect = (bar_x, bar_y + center, bar_w, start)
			self.ddt.rect(fill_rect, bar_fill_colour)
			self.ddt.text((bar_x + bar_w // 2, bar_y + bar_h + round(6 * gui.scale), 2), labels[i], self.colours.box_text_label, 10)
			bar_x += bar_w + bar_gap

		return row1_h + row2_h + column_gap

	def reset_eq(self) -> None:
		if not isinstance(self.prefs.eq, list):
			self.prefs.eq = [0.0] * 10
		for i in range(min(len(self.prefs.eq), 10)):
			self.prefs.eq[i] = 0.0
		self.gui.update += 1
		self.pctl.playerCommand = "seteq"
		self.pctl.playerCommandReady = True

	def render_settings_transcode_category(self, x: int, y: int, w: int, accent: ColourRGBA, draw: bool = True) -> int:
		gui = self.gui
		prefs = self.prefs
		column_gap = round(12 * gui.scale)
		section_header_h = round(66 * gui.scale)
		section_bottom_pad = round(14 * gui.scale)
		row1_h = section_header_h + round(150 * gui.scale) + section_bottom_pad
		if prefs.transcode_codec == "opus":
			row1_h += round(36 * gui.scale)
		if prefs.transcode_codec != "flac":
			row1_h += round(30 * gui.scale)
		row2_h = section_header_h + round(322 * gui.scale) + section_bottom_pad
		if not draw:
			return row1_h + row2_h + column_gap

		row1_rect = (x, y, w, row1_h)
		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			row1_rect,
			"",
			"",
			accent,
		)
		inner_column_gap = round(18 * gui.scale)
		left_w = max(round(270 * gui.scale), min(round(inner_w * 0.53), inner_w - round(220 * gui.scale)))
		right_w = inner_w - left_w - inner_column_gap
		left_x = inner_x
		right_x = left_x + left_w + inner_column_gap
		column_label_y = inner_y
		column_subtitle_y = column_label_y + round(16 * gui.scale) + round(2 * gui.scale)
		column_body_y = column_label_y + round(34 * gui.scale)
		divider_x = left_x + left_w + inner_column_gap // 2
		divider_h = row1_rect[1] + row1_rect[3] - round(14 * gui.scale) - column_label_y
		self.ddt.text((left_x, column_label_y), _("Encoding"), self.colours.box_text, 212)
		self.ddt.text((left_x, column_subtitle_y), _("Select default codec and bitrate."), self.colours.box_text_label, 10, max_w=left_w)
		self.ddt.text((right_x, column_label_y), _("Files"), self.colours.box_text, 212)
		self.ddt.text((right_x, column_subtitle_y), _("Output location and overwrite rules."), self.colours.box_text_label, 10, max_w=right_w)
		self.ddt.rect(
			(divider_x, column_label_y, round(1 * gui.scale), divider_h),
			alpha_blend(alpha_mod(accent, 70), self.colours.box_text_border),
		)

		inner_x = left_x
		inner_y = column_body_y + round(2 * gui.scale)
		inner_w = left_w
		bar_h = self.settings_segmented_bar(
			(inner_x, inner_y),
			(
				("FLAC", prefs.transcode_codec == "flac", self.tauon.switch_flac),
				("OPUS", prefs.transcode_codec == "opus", self.tauon.switch_opus),
				("OGG", prefs.transcode_codec == "ogg", self.tauon.switch_ogg),
				("MP3", prefs.transcode_codec == "mp3", self.tauon.switch_mp3),
			),
			accent,
			width=inner_w,
		)
		inner_y += bar_h + round(12 * gui.scale)
		if prefs.transcode_codec == "opus":
			self.settings_switch_row((inner_x, inner_y, inner_w, round(30 * gui.scale)), self.tauon.switch_opus_ogg, _("Save opus as .ogg"), accent=accent)
			inner_y += round(36 * gui.scale)
		if prefs.transcode_codec != "flac":
			prefs.transcode_bitrate = int(self.settings_stepper_row(
				(inner_x, inner_y, inner_w, round(30 * gui.scale)),
				_("Bitrate"),
				prefs.transcode_bitrate,
				32,
				320,
				accent=accent,
				step=8,
				formatter=lambda number: f"{int(number)} kbps",
			))

		inner_x = right_x
		inner_y = column_body_y + round(2 * gui.scale)
		inner_w = right_w
		self.settings_action_tile((inner_x, inner_y, inner_w, round(36 * gui.scale)), _("Open output folder"), self.tauon.open_encode_out, accent)
		inner_y += round(42 * gui.scale)
		self.settings_switch_row((inner_x, inner_y, inner_w, round(30 * gui.scale)), self.tauon.toggle_transcode_output, _("Save to output folder"), accent=accent)
		inner_y += round(36 * gui.scale)
		self.settings_switch_row((inner_x, inner_y, inner_w, round(30 * gui.scale)), self.tauon.toggle_transcode_inplace, _("Save and overwrite files in place"), accent=accent)

		row2_y = y + row1_h + column_gap
		sync_rect = (x, row2_y, w, row2_h)
		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			sync_rect,
			_("Sync to device"),
			_("Transcode and copy a selected playlist."),
			accent,
		)
		pl = None
		if prefs.sync_playlist:
			pl = self.pctl.id_to_pl(prefs.sync_playlist)
		if pl is None:
			prefs.sync_playlist = None
		selected_playlist = _("No sync playlist selected")
		if prefs.sync_playlist and pl is not None:
			selected_playlist = self.pctl.multi_playlist[pl].title
		self.draw_settings_note((inner_x, inner_y, inner_w, round(42 * gui.scale)), selected_playlist, accent, _("Source Playlist"))
		inner_y += round(50 * gui.scale)

		self.ddt.text((inner_x, inner_y), _("Target folder"), self.colours.box_text_label, 11, bg=self.ddt.text_background_colour)
		inner_y += round(18 * gui.scale)
		field_h = round(24 * gui.scale)
		rect1 = (inner_x, inner_y, inner_w - round(42 * gui.scale), field_h)
		self.draw_settings_text_field(
			rect1,
			self.tauon.sync_target,
			accent,
			stored_value=self.tauon.sync_target.text,
			editable=not gui.sync_progress,
		)
		icon_rect = (rect1[0] + rect1[2] + round(8 * gui.scale), inner_y + round(1 * gui.scale), round(20 * gui.scale), round(20 * gui.scale))
		self.fields.add(icon_rect)
		icon_colour = self.colours.box_text_label
		if self.coll(icon_rect):
			icon_colour = accent
			if self.click:
				paths = auto_get_sync_targets()
				if paths:
					self.tauon.sync_target.text = paths[0]
					self.show_message(_("A mounted music folder was found!"), mode="done")
				else:
					self.show_message(_("Could not auto-detect mounted device path."), _("Make sure the device is mounted and path is accessible."))
		gui.power_bar_icon.render(icon_rect[0], icon_rect[1], icon_colour)
		inner_y += round(34 * gui.scale)
		prefs.sync_deletes = self.settings_switch_row((inner_x, inner_y, inner_w, round(30 * gui.scale)), prefs.sync_deletes, _("Delete other folders in target"), accent=accent)
		inner_y += round(36 * gui.scale)
		prefs.bypass_transcode = self.settings_switch_row((inner_x, inner_y, inner_w, round(30 * gui.scale)), prefs.bypass_transcode ^ True, _("Transcode files"), accent=accent) ^ True
		inner_y += round(36 * gui.scale)
		prefs.smart_bypass = self.settings_switch_row((inner_x, inner_y, inner_w, round(30 * gui.scale)), prefs.smart_bypass ^ True, _("Bypass low bitrate"), accent=accent) ^ True
		inner_y += round(40 * gui.scale)
		start_label = _("Start Sync") if prefs.bypass_transcode else _("Start Transcode and Sync")
		if gui.stop_sync:
			self.settings_action_tile((inner_x, inner_y, inner_w, round(36 * gui.scale)), _("Stopping..."), accent=accent, emphasis=True)
		elif gui.sync_progress:
			if self.settings_action_tile((inner_x, inner_y, inner_w, round(36 * gui.scale)), _("Stop"), accent=accent, emphasis=True):
				gui.stop_sync = True
				gui.sync_progress = _("Aborting Sync")
		else:
			def start_sync() -> None:
				if pl is not None:
					self.tauon.auto_sync(pl)
				else:
					self.show_message(_("Select a source playlist"), _("Right click tab > Misc... > Set as sync playlist"))
			self.settings_action_tile((inner_x, inner_y, inner_w, round(36 * gui.scale)), start_label, start_sync, accent, emphasis=True)
		return row1_h + row2_h + column_gap

	def settings_account_services(self) -> tuple[list[tuple[int, str, str]], list[tuple[int, str, str]]]:
		prefs = self.prefs
		scrobbling = [
			(1, "Last.fm" if not prefs.use_libre_fm else "Libre.fm", _("Scrobbles and loves.")),
			(2, "ListenBrainz", _("Token-based scrobbling.")),
			(9, "Maloja", _("Self-hosted scrobbling.")),
		]
		streaming = [
			(10, "Jellyfin", _("Network library.")),
			(12, "TIDAL", _("Albums and tracks.")),
			(7, "Airsonic", _("Subsonic library.")),
			(5, "PLEX", _("Network library.")),
			(11, "Tauon", _("Tauon-to-Tauon sync.")),
		]

		if self.inp.key_shift_down:
			scrobbling.append((4, "fanart.tv", _("Artwork sources.")))

		return scrobbling, streaming

	def render_settings_service_detail(
		self,
		view: int,
		x: int,
		y: int,
		w: int,
		accent: ColourRGBA,
		draw: bool = True,
	) -> int:
		gui = self.gui
		tauon = self.tauon
		prefs = self.prefs
		card_gap = round(12 * gui.scale)
		row_h = round(30 * gui.scale)
		info_row_h = round(42 * gui.scale)
		row_gap = round(6 * gui.scale)
		action_h = round(36 * gui.scale)
		field_h = round(42 * gui.scale)
		note_h = round(46 * gui.scale)

		if view == 1:
			service_name = "Last.fm" if not prefs.use_libre_fm else "Libre.fm"
			account_subtitle = prefs.last_fm_username or _("Account not connected.")
			card1_h = round(252 * gui.scale) if prefs.last_fm_token is None else round(216 * gui.scale)
			card2_h = round(200 * gui.scale)
			card3_h = round(146 * gui.scale)
			total_h = card1_h + card_gap + card2_h + card_gap + card3_h
			if not draw:
				return total_h

			card_y = y
			rect = (x, card_y, w, card1_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				service_name,
				_("Scrobble playback and sync loves."),
				accent,
			)
			self.settings_switch_row(
				(inner_x, inner_y, inner_w, info_row_h),
				self.tauon.toggle_lfm_auto,
				_("Enable scrobbling"),
				account_subtitle,
				accent,
			)
			inner_y += info_row_h + row_gap
			if prefs.last_fm_token is None:
				self.draw_settings_action_row(
					(inner_x, inner_y, inner_w, action_h),
					[
						(_("Login"), self.lastfm.auth1, True),
						(_("Done"), self.lastfm.auth2, False),
					],
					accent,
				)
				inner_y += action_h + row_gap
				if self.lastfm.url is None:
					prefs.use_libre_fm = self.settings_switch_row(
						(inner_x, inner_y, inner_w, row_h),
						prefs.use_libre_fm,
						_("Use LibreFM"),
						accent=accent,
					)
					inner_y += row_h + row_gap
				note_text = _("Use Login, finish the browser step, then click Done.")
				if self.lastfm.url is not None:
					note_text = _("Finish authorisation in the browser, then click Done.")
				self.draw_settings_note((inner_x, inner_y, inner_w, note_h), note_text, accent, _("Authorisation"))
			else:
				self.draw_settings_action_row(
					(inner_x, inner_y, inner_w, action_h),
					[
						(_("Forget account"), self.lastfm.auth3, True),
					],
					accent,
				)
				inner_y += action_h + row_gap
				self.draw_settings_note((inner_x, inner_y, inner_w, note_h), prefs.last_fm_username, accent, _("Signed in as"))

			card_y += card1_h + card_gap
			rect = (x, card_y, w, card2_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				_("Library sync"),
				_("Pull loves and scrobble counts into Tauon."),
				accent,
			)
			self.draw_settings_action_row(
				(inner_x, inner_y, inner_w, action_h),
				[
					(_("Get user loves"), self.get_user_love, True),
					(_("Clear local loves"), self.clear_local_loves, False),
				],
				accent,
			)
			inner_y += action_h + row_gap
			self.draw_settings_action_row(
				(inner_x, inner_y, inner_w, action_h),
				[
					(_("Get friend loves"), self.get_friend_love, True),
					(_("Clear friend loves"), self.lastfm.clear_friends_love, False),
				],
				accent,
			)
			inner_y += action_h + row_gap
			self.draw_settings_action_row(
				(inner_x, inner_y, inner_w, action_h),
				[
					(_("Get scrobble counts"), self.get_scrobble_counts, True),
					(_("Clear counts"), self.clear_scrobble_counts, False),
				],
				accent,
			)

			card_y += card2_h + card_gap
			rect = (x, card_y, w, card3_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				_("Options"),
				_("Controls for import and playback display."),
				accent,
			)
			old_pull = prefs.lastfm_pull_love
			prefs.lastfm_pull_love = self.settings_switch_row(
				(inner_x, inner_y, inner_w, row_h),
				prefs.lastfm_pull_love,
				_("Pull love on scrobble/rescan"),
				accent=accent,
			)
			if old_pull != prefs.lastfm_pull_love and prefs.lastfm_pull_love:
				self.show_message(_("This will overwrite local love state when it differs from last.fm."))
			inner_y += row_h + row_gap
			self.settings_switch_row(
				(inner_x, inner_y, inner_w, row_h),
				self.tauon.toggle_scrobble_mark,
				_("Show threshold marker"),
				accent=accent,
			)
			return total_h

		if view == 2:
			card1_h = round(210 * gui.scale)
			card2_h = round(152 * gui.scale)
			total_h = card1_h + card_gap + card2_h
			if not draw:
				return total_h

			rect = (x, y, w, card1_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				"ListenBrainz",
				_("Token-based scrobbling."),
				accent,
			)
			token_status = _("Token saved.") if prefs.lb_token else _("Paste a token to enable ListenBrainz.")
			self.settings_switch_row(
				(inner_x, inner_y, inner_w, info_row_h),
				self.tauon.toggle_lb,
				_("Enable scrobbling"),
				token_status,
				accent,
			)
			inner_y += info_row_h + row_gap
			self.draw_settings_action_row(
				(inner_x, inner_y, inner_w, action_h),
				[
					(_("Paste token"), self.tauon.lb.paste_key, True),
					(_("Clear"), self.tauon.lb.clear_key, False),
				],
				accent,
			)
			inner_y += action_h + row_gap
			endpoint_note = prefs.listenbrainz_url or _("Using the default ListenBrainz endpoint.")
			self.draw_settings_note((inner_x, inner_y, inner_w, note_h), endpoint_note, accent, _("Endpoint"))

			rect = (x, y + card1_h + card_gap, w, card2_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				_("Options"),
				_("Extra controls for ListenBrainz."),
				accent,
			)
			self.draw_settings_action_row(
				(inner_x, inner_y, inner_w, action_h),
				[
					(_("Open profile"), lambda: webbrowser.open("https://listenbrainz.org/profile/", new=2, autoraise=True), True),
				],
				accent,
			)
			inner_y += action_h + row_gap
			self.settings_switch_row(
				(inner_x, inner_y, inner_w, row_h),
				self.tauon.toggle_scrobble_mark,
				_("Show threshold marker"),
				accent=accent,
			)
			return total_h

		if view == 4:
			card_h = round(240 * gui.scale)
			if not draw:
				return card_h

			def flip_current_artist() -> None:
				if self.inp.key_shift_down:
					prefs.bg_flips.clear()
					self.show_message(_("Reset flips"), mode="done")
					return
				track = self.pctl.playing_object()
				artist = get_artist_safe(track)
				if artist:
					if artist not in prefs.bg_flips:
						prefs.bg_flips.add(artist)
					else:
						prefs.bg_flips.remove(artist)
					tauon.style_overlay.flush()
				self.show_message(_("OK"), mode="done")

			rect = (x, y, w, card_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				"fanart.tv",
				_("Artwork sources for artist images and covers."),
				accent,
			)
			self.draw_settings_note(
				(inner_x, inner_y, inner_w, note_h),
				_("Use fanart.tv for artist images and manual cover art lookup."),
				accent,
				_("About"),
			)
			inner_y += note_h + row_gap
			prefs.enable_fanart_cover = self.settings_switch_row(
				(inner_x, inner_y, inner_w, row_h),
				prefs.enable_fanart_cover,
				_("Cover art (manual only)"),
				accent=accent,
			)
			inner_y += row_h + row_gap
			prefs.enable_fanart_artist = self.settings_switch_row(
				(inner_x, inner_y, inner_w, row_h),
				prefs.enable_fanart_artist,
				_("Artist images (automatic)"),
				accent=accent,
			)
			inner_y += row_h + row_gap
			self.draw_settings_action_row(
				(inner_x, inner_y, inner_w, action_h),
				[
					(_("Flip current"), flip_current_artist, True),
				],
				accent,
			)
			return card_h

		if view == 5:
			two_factor = tauon.plex.two_factor_required
			card1_h = round(122 * gui.scale) if two_factor else round(266 * gui.scale)
			card2_h = round(206 * gui.scale)
			total_h = card1_h + card_gap + card2_h
			if not draw:
				return total_h

			rect = (x, y, w, card1_h)
			subtitle = _("Finish the sign-in step to continue.") if two_factor else _("Connect to a Plex server.")
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(rect, "PLEX", subtitle, accent)
			if two_factor:
				two_factor_text = self.settings_text_input(
					(inner_x, inner_y, inner_w, field_h),
					_("Two-factor code"),
					tauon.text_plex_2fa,
					tauon.text_plex_2fa.text,
					accent,
				)
				tauon.text_plex_2fa.text = two_factor_text
			else:
				prefs.plex_username = self.settings_text_input(
					(inner_x, inner_y, inner_w, field_h),
					_("Username / Email"),
					tauon.text_plex_usr,
					prefs.plex_username,
					accent,
				)
				inner_y += field_h + row_gap
				prefs.plex_password = self.settings_text_input(
					(inner_x, inner_y, inner_w, field_h),
					_("Password"),
					tauon.text_plex_pas,
					prefs.plex_password,
					accent,
					secret=True,
				)
				inner_y += field_h + row_gap
				prefs.plex_servername = self.settings_text_input(
					(inner_x, inner_y, inner_w, field_h),
					_("Server name"),
					tauon.text_plex_lib,
					prefs.plex_servername,
					accent,
				)

				inner_y += field_h + row_gap
				prefs.plex_library = self.settings_text_input(
					(inner_x, inner_y, inner_w, field_h),
					_("Library name"),
					tauon.text_plex_ser,
					prefs.plex_library,
					accent,
				)

			rect = (x, y + card1_h + card_gap, w, card2_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				_("Actions"),
				_("Import from Plex."),
				accent,
			)
			if two_factor:
				self.draw_settings_action_row(
					(inner_x, inner_y, inner_w, action_h),
					[
						(_("Continue"), tauon.plex_get_album_thread, True),
						(_("Cancel"), tauon.plex_cancel_two_factor, False),
					],
					accent,
				)
			else:
				self.draw_settings_action_row(
					(inner_x, inner_y, inner_w, action_h),
					[
						(_("Import music to playlist"), tauon.plex_get_album_thread, True),
					],
					accent,
				)
			inner_y += action_h + row_gap
			prefs.scrobble_plex = self.settings_switch_row(
				(inner_x, inner_y, inner_w, info_row_h),
				prefs.scrobble_plex,
				_("Allow local scrobbling"),
				_("Disable if Plex scrobbles on its own."),
				accent,
			)
			return total_h

		if view == 7:
			card1_h = round(218 * gui.scale)
			card2_h = round(212 * gui.scale)
			total_h = card1_h + card_gap + card2_h
			if not draw:
				return total_h

			rect = (x, y, w, card1_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				_("Airsonic / Subsonic"),
				_("Connect to a Subsonic-compatible server."),
				accent,
			)
			prefs.subsonic_user = self.settings_text_input(
				(inner_x, inner_y, inner_w, field_h),
				_("Username / Email"),
				tauon.text_air_usr,
				prefs.subsonic_user,
				accent,
			)
			inner_y += field_h + row_gap
			prefs.subsonic_password = self.settings_text_input(
				(inner_x, inner_y, inner_w, field_h),
				_("Password"),
				tauon.text_air_pas,
				prefs.subsonic_password,
				accent,
				secret=True,
			)
			inner_y += field_h + row_gap
			prefs.subsonic_server = self.settings_text_input(
				(inner_x, inner_y, inner_w, field_h),
				_("Server URL"),
				tauon.text_air_ser,
				prefs.subsonic_server,
				accent,
			)

			rect = (x, y + card1_h + card_gap, w, card2_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				_("Options"),
				_("Import and authentication settings."),
				accent,
			)
			prefs.subsonic_password_plain = self.settings_switch_row(
				(inner_x, inner_y, inner_w, info_row_h),
				prefs.subsonic_password_plain,
				_("Use plain text authentication"),
				_("Needed for Nextcloud Music."),
				accent,
			)
			inner_y += info_row_h + row_gap
			prefs.scrobble_subsonic = self.settings_switch_row(
				(inner_x, inner_y, inner_w, info_row_h),
				prefs.scrobble_subsonic,
				_("Allow local scrobbling"),
				_("Disable if your server scrobbles on its own."),
				accent,
			)
			inner_y += info_row_h + row_gap
			self.draw_settings_action_row(
				(inner_x, inner_y, inner_w, action_h),
				[
					(_("Import music to playlist"), tauon.sub_get_album_thread, True),
				],
				accent,
			)
			return total_h

		if view == 9:
			card1_h = round(158 * gui.scale)
			card2_h = round(170 * gui.scale)
			card3_h = round(158 * gui.scale)
			total_h = card1_h + card_gap + card2_h + card_gap + card3_h
			if not draw:
				return total_h

			def test_maloja() -> None:
				if not prefs.maloja_url or not prefs.maloja_key:
					self.show_message(_("One or more fields are missing."))
					return
				url = prefs.maloja_url
				if not url.endswith("/mlj_1"):
					if not url.endswith("/"):
						url += "/"
					url += "apis/mlj_1"
				url += "/test"
				try:
					result = requests.get(url, params={"key": prefs.maloja_key}, timeout=10)
					if result.status_code == 403:
						self.show_message(_("Connection looked successful but the API key was invalid."), mode="warning")
					elif result.status_code == 200:
						self.show_message(_("Connection to Maloja server was successful."), mode="done")
					else:
						self.show_message(_("The Maloja server returned an error."), result.text, mode="warning")
				except Exception:
					logging.exception("Could not communicate with the Maloja server")
					self.show_message(_("Could not communicate with the Maloja server."), mode="warning")

			rect = (x, y, w, card1_h)
			status_text = prefs.maloja_url or _("No server configured.")
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				"Maloja",
				_("Self-hosted scrobble server."),
				accent,
			)
			self.settings_switch_row(
				(inner_x, inner_y, inner_w, info_row_h),
				self.tauon.toggle_maloja,
				_("Enable scrobbling"),
				status_text,
				accent,
			)
			inner_y += info_row_h + row_gap
			self.settings_switch_row(
				(inner_x, inner_y, inner_w, row_h),
				self.tauon.toggle_scrobble_mark,
				_("Show threshold marker"),
				accent=accent,
			)

			card_y = y + card1_h + card_gap
			rect = (x, card_y, w, card2_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				_("Credentials"),
				_("Server address and API key."),
				accent,
			)
			prefs.maloja_url = self.settings_text_input(
				(inner_x, inner_y, inner_w, field_h),
				_("Server URL"),
				tauon.text_maloja_url,
				prefs.maloja_url,
				accent,
			).strip()
			inner_y += field_h + row_gap
			prefs.maloja_key = self.settings_text_input(
				(inner_x, inner_y, inner_w, field_h),
				_("API key"),
				tauon.text_maloja_key,
				prefs.maloja_key,
				accent,
				secret=True,
			).strip()

			card_y += card2_h + card_gap
			rect = (x, card_y, w, card3_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				_("Actions"),
				_("Connectivity and library import."),
				accent,
			)
			self.draw_settings_action_row(
				(inner_x, inner_y, inner_w, action_h),
				[
					(_("About Maloja"), lambda: webbrowser.open("https://github.com/krateng/maloja", new=2, autoraise=True), False),
					(_("Test connectivity"), test_maloja, True),
				],
				accent,
			)
			inner_y += action_h + row_gap
			self.draw_settings_action_row(
				(inner_x, inner_y, inner_w, action_h),
				[
					(_("Get scrobble counts"), lambda: shooter(tauon.maloja_get_scrobble_counts), True),
					(_("Clear counts"), self.clear_scrobble_counts, False),
				],
				accent,
			)
			return total_h

		if view == 10:
			card1_h = round(266 * gui.scale)
			card2_h = round(158 * gui.scale)
			total_h = card1_h + card_gap + card2_h
			if not draw:
				return total_h

			def import_jelly_playlists() -> None:
				found = False
				for value in self.pctl.gen_codes.values():
					if value.startswith("jelly"):
						found = True
						break
				if not found:
					self.show_message(_("Run music import first"))
				else:
					tauon.jellyfin_get_playlists_thread()

			rect = (x, y, w, card1_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				"Jellyfin",
				_("Connect to a Jellyfin server."),
				accent,
			)
			prefs.jelly_username = self.settings_text_input(
				(inner_x, inner_y, inner_w, field_h),
				_("Username"),
				tauon.text_jelly_usr,
				prefs.jelly_username,
				accent,
			)
			inner_y += field_h + row_gap
			prefs.jelly_password = self.settings_text_input(
				(inner_x, inner_y, inner_w, field_h),
				_("Password"),
				tauon.text_jelly_pas,
				prefs.jelly_password,
				accent,
				secret=True,
			)
			inner_y += field_h + row_gap
			prefs.jelly_server_url = self.settings_text_input(
				(inner_x, inner_y, inner_w, field_h),
				_("Server URL"),
				tauon.text_jelly_ser,
				prefs.jelly_server_url,
				accent,
			)
			inner_y += field_h + row_gap
			prefs.scrobble_jellyfin = self.settings_switch_row(
				(inner_x, inner_y, inner_w, info_row_h),
				prefs.scrobble_jellyfin,
				_("Allow local scrobbling"),
				_("Disable if Jellyfin scrobbles on its own."),
				accent,
			)

			rect = (x, y + card1_h + card_gap, w, card2_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				_("Actions"),
				_("Import music and playlists."),
				accent,
			)
			self.draw_settings_action_row(
				(inner_x, inner_y, inner_w, action_h),
				[
					(_("Import music"), tauon.jellyfin_get_library_thread, True),
					(_("Import playlists"), import_jelly_playlists, False),
				],
				accent,
			)
			inner_y += action_h + row_gap
			self.draw_settings_action_row(
				(inner_x, inner_y, inner_w, action_h),
				[
					(_("Test connectivity"), tauon.jellyfin.test, False),
				],
				accent,
			)
			return total_h

		if view == 11:
			card1_h = round(170 * gui.scale)
			card2_h = round(116 * gui.scale)
			total_h = card1_h + card_gap + card2_h
			if not draw:
				return total_h

			rect = (x, y, w, card1_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				"Tauon",
				_("Fetch a playlist from another Tauon instance."),
				accent,
			)
			prefs.sat_url = self.settings_text_input(
				(inner_x, inner_y, inner_w, field_h),
				_("IP"),
				tauon.text_sat_url,
				prefs.sat_url,
				accent,
			).strip()
			inner_y += field_h + row_gap
			playlist_name = self.settings_text_input(
				(inner_x, inner_y, inner_w, field_h),
				_("Playlist name"),
				tauon.text_sat_playlist,
				tauon.text_sat_playlist.text,
				accent,
			)
			tauon.text_sat_playlist.text = playlist_name

			def load_remote_playlist() -> None:
				if tauon.tau.processing:
					self.show_message(_("An operation is already running"))
				else:
					shooter(tauon.tau.get_playlist)

			rect = (x, y + card1_h + card_gap, w, card2_h)
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				_("Actions"),
				_("Fetch the named playlist."),
				accent,
			)
			self.draw_settings_action_row(
				(inner_x, inner_y, inner_w, action_h),
				[
					(_("Get playlist"), load_remote_playlist, True),
				],
				accent,
			)
			return total_h

		if view == 12:
			logged_in = os.path.isfile(tauon.tidal.save_path)
			waiting_for_redirect = tauon.tidal.login_stage != 0 and not logged_in
			card1_h = round(224 * gui.scale) if waiting_for_redirect else round(216 * gui.scale)
			card2_h = round(116 * gui.scale) if logged_in else 0
			total_h = card1_h + (card_gap + card2_h if card2_h else 0)
			if not draw:
				return total_h

			def paste_tidal_redirect() -> None:
				text = copy_from_clipboard()
				if text:
					tauon.tidal.login2(text)

			rect = (x, y, w, card1_h)
			subtitle = _("Signed in.") if logged_in else (_("Waiting for redirect.") if waiting_for_redirect else _("Not connected."))
			inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
				rect,
				"TIDAL",
				_("Authorise TIDAL and import favourites."),
				accent,
			)
			self.draw_settings_note((inner_x, inner_y, inner_w, note_h), subtitle, accent, _("Status"))
			inner_y += note_h + row_gap
			if logged_in:
				self.draw_settings_action_row(
					(inner_x, inner_y, inner_w, action_h),
					[
						(_("Logout"), tauon.tidal.logout, True),
					],
					accent,
				)
				inner_y += action_h + row_gap
				prefs.scrobble_tidal = self.settings_switch_row(
					(inner_x, inner_y, inner_w, info_row_h),
					prefs.scrobble_tidal,
					_("Allow local scrobbling"),
					_("Disable if TIDAL scrobbles on its own."),
					accent,
				)
			elif waiting_for_redirect:
				self.draw_settings_note(
					(inner_x, inner_y, inner_w, note_h),
					_("Copy the full URL of the resulting page, then paste it here."),
					accent,
					_("Redirect"),
				)
				inner_y += note_h + row_gap
				self.draw_settings_action_row(
					(inner_x, inner_y, inner_w, action_h),
					[
						(_("Paste redirect URL"), paste_tidal_redirect, True),
					],
					accent,
				)
			else:
				self.draw_settings_action_row(
					(inner_x, inner_y, inner_w, action_h),
					[
						(_("Login"), tauon.tidal.login1, True),
					],
					accent,
				)

			if logged_in:
				rect = (x, y + card1_h + card_gap, w, card2_h)
				inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
					rect,
					_("Imports"),
					_("Pull favourites into Tauon."),
					accent,
				)
				self.draw_settings_action_row(
					(inner_x, inner_y, inner_w, action_h),
					[
						(_("Import albums"), lambda: shooter(tauon.tidal.fav_albums), True),
						(_("Import tracks"), lambda: shooter(tauon.tidal.fav_tracks), False),
					],
					accent,
				)
			return total_h

		card1_h = round(164 * gui.scale)
		card2_h = round(104 * gui.scale)
		total_h = card1_h + card_gap + card2_h
		if not draw:
			return total_h

		rect = (x, y, w, card1_h)
		inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
			rect,
			_("Service"),
			_("No layout available."),
			accent,
		)
		self.draw_settings_note((inner_x, inner_y, inner_w, note_h), _("This service view is not available right now."), accent)
		rect = (x, y + card1_h + card_gap, w, card2_h)
		inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(rect, _("Actions"), "", accent)
		self.draw_settings_action_row((inner_x, inner_y, inner_w, action_h), [(_("Back to Last.fm"), lambda: self.select_account_view(1), True)], accent)
		return total_h

	def render_settings_services_category(self, x: int, y: int, w: int, accent: ColourRGBA, draw: bool = True) -> int:
		gui = self.gui
		column_gap = round(12 * gui.scale)
		left_w = max(round(250 * gui.scale), min(round(w * 0.34), w - round(320 * gui.scale)))
		right_w = w - left_w - column_gap
		scrobbling, streaming = self.settings_account_services()
		valid_views = {view for view, title, subtitle in scrobbling + streaming}
		if self.account_view not in valid_views:
			self.account_view = 1

		tile_h = round(34 * gui.scale)
		tile_gap = round(8 * gui.scale)
		group_gap = round(12 * gui.scale)
		scrobble_rows = max(1, len(scrobbling))
		stream_rows = max(1, len(streaming))
		nav_h = (
			round(122 * gui.scale)
			+ scrobble_rows * tile_h
			+ max(0, scrobble_rows - 1) * tile_gap
			+ group_gap
			+ round(18 * gui.scale)
			+ stream_rows * tile_h
			+ max(0, stream_rows - 1) * tile_gap
		)
		detail_h = self.render_settings_service_detail(self.account_view, x + left_w + column_gap, y, right_w, accent, draw=False)
		body_h = max(nav_h, detail_h)
		if not draw:
			return body_h

		left_rect = (x, y, left_w, body_h)
		inner_x, inner_y, inner_w, inner_h = self.draw_settings_section(
			left_rect,
			_("Accounts"),
			_("Pick a service to configure."),
			accent,
		)
		tile_w = inner_w

		for heading, items in (
			(_("Scrobbling"), scrobbling),
			(_("Streaming"), streaming),
		):
			self.ddt.text((inner_x, inner_y), heading, self.colours.box_text_label, 11)
			inner_y += round(16 * gui.scale)
			for view, title, subtitle_text in items:
				self.settings_switcher_tile(
					(inner_x, inner_y, tile_w, tile_h),
					title,
					self.account_view == view,
					callback=lambda view=view: self.select_account_view(view),
					accent=accent,
				)
				inner_y += tile_h + tile_gap
			inner_y += group_gap - tile_gap

		self.render_settings_service_detail(self.account_view, x + left_w + column_gap, y, right_w, accent, draw=True)
		return body_h

	def render_settings_stats_category(self, x: int, y: int, w: int, accent: ColourRGBA, draw: bool = True) -> int:
		gui = self.gui
		pctl = self.pctl
		colours = self.colours
		strings = self.tauon.strings
		column_gap = round(12 * gui.scale)
		row1_h = round(154 * gui.scale)
		row2_h = round(109 * gui.scale)
		row3_h = round(364 * gui.scale) if self.prefs.chart_cascade else round(334 * gui.scale)
		if self.stats_pl != pctl.multi_playlist[pctl.active_playlist_viewing].uuid_int or self.stats_pl_timer.get() > 5:
			self.stats_pl = pctl.multi_playlist[pctl.active_playlist_viewing].uuid_int
			self.stats_pl_timer.set()

			album_names = set()
			folder_names = set()
			count = 0

			for track_id in pctl.default_playlist:
				tr = pctl.get_track(track_id)
				if not tr.album:
					if tr.parent_folder_path not in folder_names:
						count += 1
					folder_names.add(tr.parent_folder_path)
				else:
					if tr.parent_folder_path not in folder_names and tr.album not in album_names:
						count += 1
					folder_names.add(tr.parent_folder_path)
					album_names.add(tr.album)

			self.stats_pl_albums = count
			self.stats_pl_length = 0
			for item in pctl.default_playlist:
				self.stats_pl_length += pctl.master_library[item].length

		if self.stats_timer.get() > 5:
			album_names = set()
			folder_names = set()
			count = 0
			for pl in pctl.multi_playlist:
				for track_id in pl.playlist_ids:
					tr = pctl.get_track(track_id)
					if not tr.album:
						if tr.parent_folder_path not in folder_names:
							count += 1
						folder_names.add(tr.parent_folder_path)
					else:
						if tr.parent_folder_path not in folder_names and tr.album not in album_names:
							count += 1
						folder_names.add(tr.parent_folder_path)
						album_names.add(tr.album)
			self.total_albums = count
			self.stats_timer.set()

		if not draw:
			return row1_h + row2_h + row3_h + column_gap * 2

		left_w = max(round(260 * gui.scale), min(round(w * 0.48), w - round(220 * gui.scale)))
		right_w = w - left_w - column_gap
		left_rect = (x, y, left_w, row1_h)
		right_rect = (x + left_w + column_gap, y, right_w, row1_h)
		line_gap = round(20 * gui.scale)

		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			left_rect,
			_("Current playlist"),
			self.pctl.multi_playlist[self.pctl.active_playlist_viewing].title,
			accent,
		)
		line = seconds_to_day_hms(self.stats_pl_length, strings.day, strings.days)
		for label, value in (
			(_("Tracks"), py_locale.format_string("%d", len(pctl.default_playlist), True)),
			(_("Albums"), str(self.stats_pl_albums)),
			(_("Duration"), line),
		):
			self.ddt.text((inner_x, inner_y), label, colours.box_text_label, 12)
			self.ddt.text((inner_x + inner_w, inner_y, 1), value, colours.box_sub_text, 12)
			inner_y += line_gap

		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			right_rect,
			_("Library"),
			_("Totals across the database."),
			accent,
		)
		for label, value in (
			(_("Tracks"), py_locale.format_string("%d", len(pctl.master_library), True)),
			(_("Albums"), str(self.total_albums)),
			(_("Playtime"), seconds_to_day_hms(pctl.total_playtime, strings.day, strings.days)),
		):
			self.ddt.text((inner_x, inner_y), label, colours.box_text_label, 12)
			self.ddt.text((inner_x + inner_w, inner_y, 1), value, colours.box_sub_text, 12)
			inner_y += line_gap

		row2_y = y + row1_h + column_gap
		format_rect = (x, row2_y, w, row2_h)
		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			format_rect,
			_("File formats"),
			_("Click a segment to build a playlist for that format."),
			accent,
		)
		if pctl.master_library:
			try:
				if self.last_db_size != len(pctl.master_library):
					self.last_db_size = len(pctl.master_library)
					self.ext_ratio = {}
					for key, value in pctl.master_library.items():
						if value.file_ext in self.ext_ratio:
							self.ext_ratio[value.file_ext] += 1
						else:
							self.ext_ratio[value.file_ext] = 1

				bar_rect = (inner_x, inner_y + round(8 * gui.scale), inner_w, round(14 * gui.scale))
				self.ddt.bordered_rect(
					bar_rect,
					alpha_blend(ColourRGBA(255, 255, 255, 10), colours.box_background),
					alpha_blend(ColourRGBA(255, 255, 255, 18), colours.box_text_border),
					round(1 * gui.scale),
				)
				d = 0
				for key, value in self.ext_ratio.items():
					colour = ColourRGBA(200, 200, 200, 255)
					if key in self.formats.colours:
						colour = self.formats.colours[key]
					colour_hls = colorsys.rgb_to_hls(colour.r / 255, colour.g / 255, colour.b / 255)
					colour_rgb = colorsys.hls_to_rgb(1 - colour_hls[0], colour_hls[1] * 0.8, colour_hls[2] * 0.8)
					colour = ColourRGBA(int(colour_rgb[0] * 255), int(colour_rgb[1] * 255), int(colour_rgb[2] * 255), 255)
					block_w = round(value / len(pctl.master_library) * bar_rect[2])
					if block_w <= 0:
						continue
					block_rect = (bar_rect[0] + d, bar_rect[1], max(block_w - 1, 1), bar_rect[3])
					self.ddt.rect(block_rect, colour)
					self.fields.add(block_rect)
					if self.coll(block_rect):
						self.ddt.text((block_rect[0] + block_rect[2] // 2, bar_rect[1] - round(18 * gui.scale), 2), key, colours.grey_blend_bg(220), 13)
						if self.click:
							self.tauon.gen_codec_pl(key)
					d += block_w
			except Exception:
				logging.exception("Error draw ext bar")

		row3_y = row2_y + row2_h + column_gap
		self.topchart(x, row3_y, w, row3_h, show_return=False, accent=accent)
		return row1_h + row2_h + row3_h + column_gap * 2

	def render_settings_about_category(self, x: int, y: int, w: int, accent: ColourRGBA, draw: bool = True) -> int:
		gui = self.gui
		column_gap = round(12 * gui.scale)
		left_h = round(224 * gui.scale)
		right_h = left_h
		row_h = max(left_h, right_h)
		if not draw:
			return row_h

		left_w = max(round(260 * gui.scale), min(round(w * 0.46), w - round(220 * gui.scale)))
		right_w = w - left_w - column_gap
		left_rect = (x, y, left_w, left_h)
		right_rect = (x + left_w + column_gap, y, right_w, right_h)
		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			left_rect,
			_("Tauon"),
			self.t_version,
			accent,
		)
		action_h = round(34 * gui.scale)
		action_gap = round(8 * gui.scale)
		icon_x = left_rect[0] + left_rect[2] - round(16 * gui.scale) - round(self.app_icon.w)
		icon_y = left_rect[1] + round(10 * gui.scale)
		self.app_icon.render(icon_x, icon_y)
		copyright_h = self.ddt.text(
			(inner_x, inner_y, 4, inner_w, round(40 * gui.scale)),
			"Copyright © 2015-2026 Taiko2k",
			self.colours.box_sub_text,
			13,
		) or round(18 * gui.scale)
		inner_y += max(copyright_h + round(6 * gui.scale), round(24 * gui.scale))
		self.ddt.text((inner_x, inner_y, 4, inner_w, round(44 * gui.scale)), _("This program comes with absolutely no warranty."), self.colours.box_text_label, 11)
		button_y = left_rect[1] + left_rect[3] - round(14 * gui.scale) - action_h * 2 - action_gap
		self.settings_action_tile((inner_x, button_y, inner_w, action_h), _("Open website"), lambda: webbrowser.open("https://tauonmusicbox.rocks", new=2, autoraise=True), accent)
		button_y += action_h + action_gap
		self.settings_action_tile((inner_x, button_y, inner_w, action_h), _("Open GPL license"), lambda: webbrowser.open("https://www.gnu.org/licenses/gpl-3.0.html", new=2, autoraise=True), accent)

		inner_x, inner_y, inner_w, section_h = self.draw_settings_section(
			right_rect,
			_("Credits"),
			_("People and project links."),
			accent,
		)
		self.ddt.text((inner_x, inner_y), _("Created by"), self.colours.box_text_label, 12)
		self.ddt.text((inner_x + round(75 * gui.scale), inner_y), "Taiko2k", self.colours.box_sub_text, 13)
		inner_y += round(28 * gui.scale)
		self.settings_action_tile((inner_x, inner_y, inner_w, action_h), _("Contributors"), lambda: webbrowser.open("https://github.com/Taiko2k/Tauon/graphs/contributors", new=2, autoraise=True), accent)
		inner_y += round(40 * gui.scale)
		self.settings_action_tile((inner_x, inner_y, inner_w, action_h), _("Source code"), lambda: webbrowser.open("https://github.com/Taiko2k/Tauon", new=2, autoraise=True), accent)
		return row_h

	def draw_settings_category_heading(self, title: str, x: int, y: int, w: int) -> int:
		heading_h = round(34 * self.gui.scale)
		text_y = y + round(4 * self.gui.scale)
		line_y = y + round(14 * self.gui.scale)
		gap = round(14 * self.gui.scale)
		min_line_w = round(28 * self.gui.scale)
		text_w = min(self.ddt.get_text_w(title, 213), max(0, w - (gap + min_line_w) * 2))
		line_colour = self.colours.box_text_border
		center_x = x + w // 2
		left_line_w = max(0, center_x - x - text_w // 2 - gap)
		right_line_x = center_x + text_w // 2 + gap
		right_line_w = max(0, x + w - right_line_x)
		line_h = max(1, round(1 * self.gui.scale))
		if left_line_w:
			self.ddt.rect((x, line_y, left_line_w, line_h), line_colour)
		if right_line_w:
			self.ddt.rect((right_line_x, line_y, right_line_w, line_h), line_colour)
		self.ddt.text((center_x, text_y, 2), title, self.colours.box_text, 213, bg=self.colours.box_background, max_w=text_w)
		return heading_h

	def render_settings_category(self, index: int, x: int, y: int, w: int, draw: bool = True) -> int:
		self.settings_accent_bar_pending = True
		accent = self.settings_tab_accent(index)
		heading_h = round(34 * self.gui.scale)
		if draw and index < len(self.tabs):
			heading_h = self.draw_settings_category_heading(self.tabs[index], x, y, w)
		body_y = y + heading_h

		if index == 0:
			body_h = self.render_settings_general_category(x, body_y, w, draw)
		elif index == 1:
			body_h = self.render_settings_connections_category(x, body_y, w, accent, draw)
		elif index == 2:
			body_h = self.render_settings_audio_category(x, body_y, w, accent, draw)
		elif index == 3:
			body_h = self.render_settings_theme_category(x, body_y, w, accent, draw)
		elif index == 4:
			body_h = self.render_settings_view_category(x, body_y, w, accent, draw)
		elif index == 5:
			body_h = self.render_settings_transcode_category(x, body_y, w, accent, draw)
		elif index == 6:
			body_h = self.render_settings_services_category(x, body_y, w, accent, draw)
		elif index == 7:
			body_h = self.render_settings_func_category(4, x, body_y, w, draw)
		elif index == 8:
			body_h = self.render_settings_stats_category(x, body_y, w, accent, draw)
		else:
			body_h = self.render_settings_about_category(x, body_y, w, accent, draw)

		return heading_h + body_h

	# def style_up(self) -> None:
	# 	self.prefs.line_style += 1
	# 	if self.prefs.line_style > 5:
	# 		self.prefs.line_style = 1

	def inside(self) -> bool:
		return self.coll((self.box_x, self.box_y, self.w, self.h))

	def init2(self) -> None:
		self.init2done = True

	def close(self) -> None:
		self.enabled = False
		self.clear_theme_editor_state()
		self.tauon.smooth_scroll.reset_motion("settings nav")
		self.tauon.smooth_scroll.reset_motion("settings content")
		self.settings_scale_preview_value = None
		self.settings_text_focus = None
		self.settings_text_order = []
		self.settings_text_seen = []
		self.settings_text_hit = False
		self.destroy_settings_texture()
		self.tauon.fader.fall()
		if self.gui.opened_config_file:
			self.tauon.reload_config_file()

	def render_theme_editor_window(self) -> None:
		gui = self.gui
		ddt = self.ddt
		colours = self.colours
		current_colour = self.theme_editor_current_colour()
		hue = self.theme_editor_hue_value
		sat = self.theme_editor_sat_value
		val = self.theme_editor_val_value

		full_width = round(510 * gui.scale)
		full_height = round(375 * gui.scale)
		if self.theme_editor_window_position is None:
			x = int(self.window_size[0] / 2) - int(full_width / 2)
			y = int(self.window_size[1] / 2) - int(full_height / 2)
		else:
			x, y = self.theme_editor_window_position
		if self.theme_editor_drag_target == "window" and self.inp.mouse_down:
			x = (
				self.theme_editor_window_drag_start_position[0]
				+ self.inp.mouse_position[0]
				- self.theme_editor_window_drag_start_mouse[0]
			)
			y = (
				self.theme_editor_window_drag_start_position[1]
				+ self.inp.mouse_position[1]
				- self.theme_editor_window_drag_start_mouse[1]
			)
			self.tauon.input_sdl.mouse_capture_want = True
			gui.update += 1
		margin = round(8 * gui.scale)
		x = min(max(x, margin), max(margin, self.window_size[0] - round(full_width * 0.5)))
		y = min(max(y, margin), max(margin, self.window_size[1] - round(full_height * 0.5)))
		self.theme_editor_window_position = (x, y)
		self.box_x = x
		self.box_y = y
		self.w = full_width
		self.h = full_height

		ddt.rect(
			(x - 5 * gui.scale, y - 5 * gui.scale, full_width + 10 * gui.scale, full_height + 10 * gui.scale),
			colours.box_border,
		)
		ddt.rect_a((x, y), (full_width, full_height), colours.box_background)
		ddt.text_background_colour = colours.box_background

		self.begin_settings_text_inputs()

		pad = round(12 * gui.scale)
		column_gap = round(10 * gui.scale)
		row_gap = round(6 * gui.scale)
		header_h = round(72 * gui.scale)
		left_w = round(full_width * 0.42)
		right_w = full_width - left_w - column_gap - pad * 2
		left_x = x + pad
		right_x = left_x + left_w + column_gap
		header_y = y + pad

		grip_w = round(23 * gui.scale)
		grip_gap = round(7 * gui.scale)
		grip_rect = (left_x, header_y + round(17 * gui.scale), grip_w, round(22 * gui.scale))
		title_field_x = left_x + grip_w + grip_gap
		title_field_w = full_width - pad * 2 - round(190 * gui.scale) - grip_w - grip_gap
		self.ddt.text((title_field_x, header_y), _("Theme title"), colours.box_text_label, 11, bg=colours.box_background)
		grip_hover = self.coll(grip_rect)
		self.fields.add(grip_rect)
		grip_dot = max(2, round(2 * gui.scale))
		grip_colour = colours.box_text if self.theme_editor_drag_target == "window" else colours.box_text_label
		for offset_x in (round(4 * gui.scale), round(10 * gui.scale), round(16 * gui.scale)):
			for offset_y in (round(5 * gui.scale), round(10 * gui.scale), round(15 * gui.scale)):
				ddt.rect((grip_rect[0] + offset_x, grip_rect[1] + offset_y, grip_dot, grip_dot), grip_colour)
		if self.click and grip_hover:
			self.theme_editor_drag_target = "window"
			self.theme_editor_window_drag_start_mouse = tuple(self.inp.mouse_position)
			self.theme_editor_window_drag_start_position = (x, y)
			self.tauon.input_sdl.mouse_capture_want = True
		self.draw_settings_text_field(
			(title_field_x, header_y + round(17 * gui.scale), title_field_w, round(22 * gui.scale)),
			self.theme_editor_title_box,
			self.settings_page_accent(4),
			stored_value=self.theme_editor_title_box.text,
			placeholder=_("Theme title"),
		)
		if self.theme_editor_dirty:
			self.ddt.text(
				(title_field_x, header_y + round(42 * gui.scale)),
				_("Unsaved changes!"),
				colours.box_text_label,
				11,
				bg=colours.box_background,
			)

		button_w = round(78 * gui.scale)
		button_h = round(30 * gui.scale)
		close_x = x + full_width - pad - button_w
		save_x = close_x - column_gap - button_w
		self.settings_action_tile((save_x, header_y + round(12 * gui.scale), button_w, button_h), _("Save"), self.save_active_user_theme, self.settings_page_accent(4), emphasis=True)
		self.settings_action_tile((close_x, header_y + round(12 * gui.scale), button_w, button_h), _("Close"), self.close_theme_editor, self.settings_page_accent(4))

		panel_y = y + header_h
		panel_h = full_height - header_h - pad
		left_rect = (left_x, panel_y, left_w, panel_h)
		right_rect = (right_x, panel_y, right_w, panel_h)
		panel_fill = alpha_blend(ColourRGBA(255, 255, 255, 6), colours.box_background)
		panel_border = alpha_blend(ColourRGBA(255, 255, 255, 18), colours.box_text_border)
		ddt.bordered_rect(left_rect, panel_fill, panel_border, round(1 * gui.scale))
		ddt.bordered_rect(right_rect, panel_fill, panel_border, round(1 * gui.scale))

		left_inner_x = left_rect[0] + pad
		left_inner_y = left_rect[1] + pad
		left_inner_w = left_rect[2] - pad * 2
		left_inner_h = left_rect[3] - pad * 2
		ddt.text((left_inner_x, left_inner_y - round(3 * gui.scale)), _("Components"), colours.box_text, 12, bg=panel_fill)
		list_y = left_inner_y + round(16 * gui.scale)
		row_h = round(26 * gui.scale)
		row_step = row_h + row_gap
		list_rect = (left_inner_x, list_y, left_inner_w, left_inner_h - round(10 * gui.scale))
		visible_rows = max(1, int((list_rect[3] + row_gap) // max(row_step, 1)))
		max_scroll = max(len(THEME_EDITOR_COMPONENTS) - visible_rows, 0)
		if self.coll(left_rect) and self.scroll:
			self.theme_editor_list_scroll -= self.scroll
		self.theme_editor_list_scroll = min(max(self.theme_editor_list_scroll, 0), max_scroll)
		scroll_index = int(self.theme_editor_list_scroll)
		scrollbar_w = round(10 * gui.scale)
		scrollbar_gap = round(8 * gui.scale)
		row_w = list_rect[2]
		if max_scroll > 0:
			row_w -= scrollbar_w + scrollbar_gap
			self.theme_editor_list_scroll = self.theme_editor_list_scroll_bar.draw(
				list_rect[0] + list_rect[2] - scrollbar_w,
				list_rect[1],
				scrollbar_w,
				list_rect[3],
				self.theme_editor_list_scroll,
				max_scroll,
				click=self.click,
				extend_field=round(4 * gui.scale),
			)
			scroll_index = int(self.theme_editor_list_scroll)

		rows_count = 0
		row_y = list_rect[1]
		for label, attr in THEME_EDITOR_COMPONENTS[scroll_index : scroll_index + visible_rows]:
			rows_count += 1
			if rows_count > visible_rows:
				break
			row_rect = (list_rect[0], row_y, row_w, row_h)
			hover = self.coll(row_rect)
			active = self.theme_editor_selected_attrs == attr
			row_fill = alpha_blend(ColourRGBA(255, 255, 255, 4), panel_fill)
			if active:
				row_fill = alpha_blend(alpha_mod(self.settings_page_accent(4), 24), row_fill)
			elif hover:
				row_fill = alpha_blend(ColourRGBA(255, 255, 255, 8), row_fill)
			row_border = alpha_blend(ColourRGBA(255, 255, 255, 18), panel_border)
			if active:
				row_border = alpha_blend(alpha_mod(self.settings_page_accent(4), 90), row_border)
			ddt.bordered_rect(row_rect, row_fill, row_border, round(1 * gui.scale))
			self.fields.add(row_rect)

			subcolors_are_identical = True
			if len(attr) > 1: # THEME_EDITOR_COMPONENTS is currently set up for a STATIC visible_rows value. if this value ever changes, get smarter.
				compare_color = getattr(self.theme_editor_draft_colours, attr[0], current_colour) if self.theme_editor_draft_colours is not None else current_colour
				for color in attr:
					component_colour = getattr(self.theme_editor_draft_colours, color, current_colour) if self.theme_editor_draft_colours is not None else current_colour
					subcolors_are_identical = subcolors_are_identical and component_colour == compare_color
					compare_color = component_colour

				dropdown_rect = (list_rect[0] + row_w - row_h, row_y, row_h, row_h)
				# ddt.bordered_rect(dropdown_rect, row_fill, row_border, round(1 * gui.scale))
				ddt.text(
					(dropdown_rect[0]+ round(10*gui.scale),dropdown_rect[1]+round(4*gui.scale)),
					"🞃" if self.theme_editor_dropdown_expansions[THEME_EDITOR_COMPONENTS.index((label,attr))] else "🞂",
					colours.box_button_text, 12, bg=row_fill
					)
				if self.coll(dropdown_rect) and self.click:
					self.theme_editor_dropdown_expansions[THEME_EDITOR_COMPONENTS.index((label,attr))] = not self.theme_editor_dropdown_expansions[THEME_EDITOR_COMPONENTS.index((label,attr))]
				dropped_down = self.theme_editor_dropdown_expansions[THEME_EDITOR_COMPONENTS.index((label,attr))]
				if dropped_down:

					for color in attr:
						row_y += row_step
						rows_count += 1
						if rows_count > visible_rows:
							break

						sub_rect = (list_rect[0] + round(10*gui.scale), row_y, row_w - round(10*gui.scale), row_h)
						subhover = self.coll(sub_rect)
						subactive = self.theme_editor_selected_attrs == color
						subrow_fill = alpha_blend(ColourRGBA(255, 255, 255, 4), panel_fill)
						if subactive:
							subrow_fill = alpha_blend(alpha_mod(self.settings_page_accent(4), 24), row_fill)
						elif subhover:
							subrow_fill = alpha_blend(ColourRGBA(255, 255, 255, 8), row_fill)
						subrow_border = alpha_blend(ColourRGBA(255, 255, 255, 18), panel_border)
						if subactive:
							subrow_border = alpha_blend(alpha_mod(self.settings_page_accent(4), 90), row_border)
						ddt.bordered_rect(sub_rect, subrow_fill, subrow_border, round(1 * gui.scale))
						self.fields.add(sub_rect)

						if subhover and self.click:
							self.theme_editor_selected_attr = color
							self.theme_editor_selected_attrs = (color,)
							self.theme_editor_drag_target = None
							self.sync_theme_editor_controls_from_current_colour()


						component_colour = getattr(self.theme_editor_draft_colours, color, current_colour) if self.theme_editor_draft_colours is not None else current_colour
						swatch_rect = (sub_rect[0] + round(8 * gui.scale), sub_rect[1] + round(5 * gui.scale), round(14 * gui.scale), round(14 * gui.scale))
						ddt.rect(swatch_rect, component_colour)
						ddt.rect_s(swatch_rect, alpha_blend(ColourRGBA(255, 255, 255, 40), subrow_border), round(1 * gui.scale))
						ddt.text((swatch_rect[0] + swatch_rect[2] + round(8 * gui.scale), sub_rect[1] + round(4 * gui.scale)), color, colours.box_text if subactive else colours.box_button_text_highlight if subhover else colours.box_button_text, 12, bg=subrow_fill, max_w=sub_rect[2] - round(44 * gui.scale))



			if hover and self.click and ("dropdown_rect" in locals() and not self.coll(dropdown_rect)):
				self.theme_editor_selected_attr = attr[0]
				self.theme_editor_selected_attrs = attr
				self.theme_editor_drag_target = None
				self.sync_theme_editor_controls_from_current_colour()

			swatch_rect = (row_rect[0] + round(8 * gui.scale), row_rect[1] + round(5 * gui.scale), round(14 * gui.scale), round(14 * gui.scale))
			if subcolors_are_identical:
				component_colour = getattr(self.theme_editor_draft_colours, attr[0], current_colour) if self.theme_editor_draft_colours is not None else current_colour
				ddt.rect(swatch_rect, component_colour)
				ddt.rect_s(swatch_rect, alpha_blend(ColourRGBA(255, 255, 255, 40), row_border), round(1 * gui.scale))
			else:
				ddt.text((swatch_rect[0] + round(1*gui.scale), row_rect[1] + round(7 * gui.scale)), "~", colours.box_text if active else colours.box_button_text_highlight if hover else colours.box_button_text, 20, bg=row_fill, max_w=row_rect[2] - round(44 * gui.scale))
			ddt.text((swatch_rect[0] + swatch_rect[2] + round(8 * gui.scale), row_rect[1] + round(4 * gui.scale)), _(label), colours.box_text if active else colours.box_button_text_highlight if hover else colours.box_button_text, 12, bg=row_fill, max_w=row_rect[2] - round(44 * gui.scale))
			row_y += row_step

		right_inner_x = right_rect[0] + pad
		right_inner_y = right_rect[1] + pad
		right_inner_w = right_rect[2] - pad * 2

		preview_y = right_inner_y
		button_w = round(62 * gui.scale)
		action_button_h = round(34 * gui.scale)
		preview_gap = row_gap
		preview_rect = (
			right_inner_x,
			preview_y,
			right_inner_w - button_w * 2 - preview_gap * 2,
			action_button_h,
		)
		ddt.bordered_rect(preview_rect, current_colour, panel_border, round(1 * gui.scale))
		preview_text = ColourRGBA(25, 25, 25, 255) if is_light(current_colour) else ColourRGBA(245, 245, 245, 255)
		ddt.text((preview_rect[0] + preview_rect[2] // 2, preview_rect[1] + round(8 * gui.scale), 2), self.theme_colour_to_hex(current_colour), preview_text, 212, bg=current_colour)

		button_y = preview_rect[1]
		copy_x = preview_rect[0] + preview_rect[2] + preview_gap
		paste_x = copy_x + button_w + preview_gap
		self.settings_action_tile((copy_x, button_y, button_w, action_button_h), _("Copy"), self.theme_editor_copy_colour, self.settings_page_accent(4), show_arrow=False)
		self.settings_action_tile((paste_x, button_y, button_w, action_button_h), _("Paste"), self.theme_editor_paste_colour, self.settings_page_accent(4), show_arrow=False)

		picker_top = preview_rect[1] + preview_rect[3] + round(20 * gui.scale)
		alpha_gap = round(8 * gui.scale)
		alpha_w = round(18 * gui.scale)
		hue_h = round(14 * gui.scale)
		hue_gap = round(12 * gui.scale)
		picker_area_h = right_rect[1] + right_rect[3] - pad - picker_top - round(10 * gui.scale)
		sv_available_w = right_inner_w - alpha_w - alpha_gap - round(20 * gui.scale)
		sv_available_h = picker_area_h - hue_gap - hue_h
		sv_side = max(round(150 * gui.scale), min(sv_available_w, sv_available_h))
		selector_w = sv_side + alpha_gap + alpha_w
		selector_h = sv_side + hue_gap + hue_h
		selector_x = right_inner_x + max(0, (right_inner_w - selector_w) // 2)
		selector_y = picker_top + max(0, (picker_area_h - selector_h) // 2)
		sv_rect = (selector_x, selector_y, sv_side, sv_side)
		hue_y = sv_rect[1] + sv_rect[3] + hue_gap
		hue_rect = (selector_x, hue_y, sv_rect[2], hue_h)
		display_hue = hue
		if self.theme_editor_drag_target == "hue" and self.inp.mouse_down:
			display_hue = min(max((self.inp.mouse_position[0] - hue_rect[0]) / max(hue_rect[2], 1), 0.0), 1.0)
		applied_hue = min(display_hue, 1.0 - (1.0 / max(hue_rect[2], 1)))
		display_sat = sat
		display_val = val
		if self.theme_editor_drag_target == "sv" and self.inp.mouse_down:
			display_sat = min(max((self.inp.mouse_position[0] - sv_rect[0]) / max(sv_rect[2], 1), 0.0), 1.0)
			display_val = min(max(1 - ((self.inp.mouse_position[1] - sv_rect[1]) / max(sv_rect[3], 1)), 0.0), 1.0)
		sv_texture = self.ensure_theme_editor_sv_texture(sv_rect[2], sv_rect[3], applied_hue)
		self.render_theme_editor_texture(sv_texture, sv_rect)
		ddt.rect_s(sv_rect, panel_border, round(1 * gui.scale))
		marker_x = sv_rect[0] + round(display_sat * sv_rect[2])
		marker_y = sv_rect[1] + round((1 - display_val) * sv_rect[3])
		ddt.rect((marker_x - round(4 * gui.scale), marker_y - round(4 * gui.scale), round(8 * gui.scale), round(8 * gui.scale)), preview_text)
		self.fields.add(sv_rect)
		if self.click and self.coll(sv_rect):
			self.theme_editor_drag_target = "sv"
		if self.theme_editor_drag_target == "sv" and self.inp.mouse_down:
			self.apply_theme_editor_hsv(applied_hue, display_sat, display_val)

		alpha_rect = (sv_rect[0] + sv_rect[2] + alpha_gap, sv_rect[1], alpha_w, sv_rect[3])
		checker_size = max(2, round(6 * gui.scale))
		checker_light = alpha_blend(ColourRGBA(255, 255, 255, 48), panel_fill)
		checker_dark = alpha_blend(ColourRGBA(0, 0, 0, 36), panel_fill)
		for check_y in range(alpha_rect[1], alpha_rect[1] + alpha_rect[3], checker_size):
			cell_h = min(checker_size, alpha_rect[1] + alpha_rect[3] - check_y)
			for check_x in range(alpha_rect[0], alpha_rect[0] + alpha_rect[2], checker_size):
				cell_w = min(checker_size, alpha_rect[0] + alpha_rect[2] - check_x)
				colour = checker_light if ((check_x - alpha_rect[0]) // checker_size + (check_y - alpha_rect[1]) // checker_size) % 2 == 0 else checker_dark
				ddt.rect((check_x, check_y, cell_w, cell_h), colour)
		alpha_steps = 32
		for index in range(alpha_steps):
			start_y = alpha_rect[1] + round(index * alpha_rect[3] / alpha_steps)
			end_y = alpha_rect[1] + round((index + 1) * alpha_rect[3] / alpha_steps)
			alpha_progress = index / max(alpha_steps - 1, 1)
			alpha_value = 255 - round((alpha_progress ** 2.0) * 255)
			ddt.rect((alpha_rect[0], start_y, alpha_rect[2], max(end_y - start_y, 1)), ColourRGBA(current_colour.r, current_colour.g, current_colour.b, alpha_value))
		ddt.rect_s(alpha_rect, panel_border, round(1 * gui.scale))
		alpha_marker_y = alpha_rect[1] + round((1 - self.theme_editor_alpha_value) * alpha_rect[3])
		ddt.rect((alpha_rect[0] - round(3 * gui.scale), alpha_marker_y - round(2 * gui.scale), alpha_rect[2] + round(6 * gui.scale), round(4 * gui.scale)), preview_text)
		self.fields.add(alpha_rect)
		if self.click and self.coll(alpha_rect):
			self.theme_editor_drag_target = "alpha"
		if self.theme_editor_drag_target == "alpha" and self.inp.mouse_down:
			alpha_portion = min(max((self.inp.mouse_position[1] - alpha_rect[1]) / max(alpha_rect[3], 1), 0.0), 1.0)
			self.apply_theme_editor_alpha(1 - alpha_portion)

		hue_texture = self.ensure_theme_editor_hue_texture(hue_rect[2], hue_rect[3])
		self.render_theme_editor_texture(hue_texture, hue_rect)
		ddt.rect_s(hue_rect, panel_border, round(1 * gui.scale))
		hue_marker_x = hue_rect[0] + round(display_hue * hue_rect[2])
		ddt.rect((hue_marker_x - round(2 * gui.scale), hue_rect[1] - round(3 * gui.scale), round(4 * gui.scale), hue_rect[3] + round(6 * gui.scale)), preview_text)
		self.fields.add(hue_rect)
		if self.click and self.coll(hue_rect):
			self.theme_editor_drag_target = "hue"
		if self.theme_editor_drag_target == "hue" and self.inp.mouse_down:
			self.apply_theme_editor_hsv(applied_hue, sat, val)
		if self.inp.mouse_up or not self.inp.mouse_down:
			self.theme_editor_drag_target = None
		self.finish_settings_text_inputs()
		self.click = False
		self.right_click = False

	def render(self) -> None:
		tauon   = self.tauon
		inp     = self.inp
		gui     = self.gui
		ddt     = self.ddt
		colours = self.colours
		if self.init2done is False:
			self.init2()

		if inp.key_esc_press and not self.theme_editor_enabled:
			self.close()

		if self.theme_editor_enabled:
			self.render_theme_editor_window()
			return

		full_width = round(875 * gui.scale)
		header_height = round(58 * gui.scale)
		full_height = round(440 * gui.scale) - header_height // 2
		side_width = round(150 * gui.scale)
		content_width = full_width - side_width
		content_height = full_height

		x = int(self.window_size[0] / 2) - int(full_width / 2)
		y = int(self.window_size[1] / 2) - int(full_height / 2)

		self.box_x = x
		self.box_y = y
		self.w = full_width
		self.h = full_height

		border_colour = colours.box_border
		ddt.rect(
			(x - 5 * gui.scale, y - 5 * gui.scale, full_width + 10 * gui.scale, full_height + 10 * gui.scale), border_colour)
		ddt.rect_a((x, y), (full_width, full_height), colours.box_background)

		tab_bg = colours.sys_tab_bg
		tab_hl = colours.sys_tab_hl
		tab_text = rgb_add_hls(tab_bg, 0, 0.3, -0.15)
		if is_light(tab_bg):
			h, l, s = rgb_to_hls(tab_bg.r, tab_bg.g, tab_bg.b)
			l = 0.1
			tab_text = hls_to_rgb(h, l, s)

		if self.click and gui.message_box:
			if not self.coll(tauon.message_box.get_rect()):
				gui.message_box = False
			else:
				inp.mouse_click = True
				self.click = False

		self.begin_settings_text_inputs()

		ddt.rect_a((x, y), (side_width, full_height), tab_bg)
		ddt.rect_a(
			(x + side_width - round(1 * gui.scale), y),
			(round(1 * gui.scale), full_height),
			alpha_mod(colours.box_text_border, 170))

		nav_x = x + round(10 * gui.scale)
		nav_y = y + round(14 * gui.scale)
		nav_h = full_height - round(28 * gui.scale)
		row_step = round(28 * gui.scale)
		row_height = round(26 * gui.scale)
		visible_rows = max(1, nav_h // max(row_step, 1))
		max_nav_scroll = self.sync_settings_nav_scroll((x, y, side_width, full_height), row_step, visible_rows)
		nav_w = side_width - round(12 * gui.scale)
		if max_nav_scroll > 0:
			scrollbar_w = round(10 * gui.scale)
			scrollbar_gap = round(8 * gui.scale)
			scrollbar_x = x + side_width - scrollbar_w - round(6 * gui.scale)
			nav_w -= scrollbar_w + scrollbar_gap
			self.settings_nav_scroll = self.settings_nav_scroll_bar.draw(
				scrollbar_x,
				y + round(8 * gui.scale),
				scrollbar_w,
				full_height - round(16 * gui.scale),
				self.settings_nav_scroll,
				max_nav_scroll,
				click=self.click,
				extend_field=round(4 * gui.scale))

		content_x = x + side_width
		content_y = y
		inner_pad_x = round(12 * gui.scale)
		content_top_pad = round(12 * gui.scale)
		content_bottom_pad = 0
		scrollbar_w = round(16 * gui.scale)
		scrollbar_gap = round(8 * gui.scale)
		scrollbar_right_inset = 0
		view_rect = (
			content_x + inner_pad_x,
			content_y,
			content_width - inner_pad_x * 2,
			content_height - content_bottom_pad,
		)
		scrollbar_x = content_x + content_width - scrollbar_right_inset - scrollbar_w
		doc_w = scrollbar_x - scrollbar_gap - view_rect[0]
		if doc_w < round(260 * gui.scale):
			doc_w = view_rect[2]

		self.settings_category_offsets = []
		category_heights: list[int] = []
		doc_height = content_top_pad
		category_gap = round(14 * gui.scale)
		for index in range(len(self.tabs)):
			self.settings_category_offsets.append(doc_height - content_top_pad)
			category_h = self.render_settings_category(index, view_rect[0], view_rect[1], doc_w, draw=False)
			category_heights.append(category_h)
			doc_height += category_h + category_gap
		if category_heights:
			doc_height -= category_gap
		doc_bottom_pad = round(16 * gui.scale)
		if category_heights:
			# Add enough trailing space for the final category to reach the top inset.
			doc_bottom_pad += max(0, view_rect[3] - (content_top_pad + category_heights[-1] + doc_bottom_pad))
			doc_height += doc_bottom_pad

			max_content_scroll = self.sync_settings_content_scroll((content_x, content_y, content_width, content_height), doc_height)
			active_anchor = self.settings_content_scroll + round(24 * gui.scale)
			for index, offset in enumerate(self.settings_category_offsets):
				if active_anchor >= offset:
					self.tab_active = index

			scroll_start = int(self.settings_nav_scroll)
			scroll_offset = (self.settings_nav_scroll - scroll_start) * max(row_step, 1)
			if is_light(tab_bg):
				active_bg = rgb_add_hls(tab_bg, l=-0.09, s=0.03)
				hover_bg = rgb_add_hls(tab_bg, l=-0.05, s=0.02)
			else:
				active_bg = rgb_add_hls(tab_bg, l=0.08, s=0.03)
				hover_bg = rgb_add_hls(tab_bg, l=0.04, s=0.02)
			yy = nav_y - scroll_offset
			for index, label in enumerate(self.tabs):
				if index < scroll_start:
					continue
				if yy > nav_y + nav_h - row_step:
					break

				rect = (nav_x, round(yy), nav_w, row_height)
				self.fields.add(rect)
				hovered = self.coll(rect)
				row_bg = tab_bg
				if self.tab_active == index:
					row_bg = active_bg
					ddt.rect_a((rect[0], rect[1]), (rect[2], rect[3]), row_bg)
				elif hovered:
					row_bg = hover_bg
					ddt.rect_a((rect[0], rect[1]), (rect[2], rect[3]), row_bg)
				ddt.text(
					(rect[0] + round(11 * gui.scale), rect[1] + round(4 * gui.scale)),
					label,
					colours.box_text if self.tab_active == index else tab_text,
					212,
					bg=row_bg,
					max_w=rect[2] - round(18 * gui.scale),
				)
				if hovered and self.click:
					self.tab_active = index
					if index < len(self.settings_category_offsets):
						self.settings_content_scroll = min(max(self.settings_category_offsets[index], 0), max_content_scroll)
						self.tauon.smooth_scroll.reset_motion("settings content")
						self.lyrics_panel = False
				yy += row_step

		content_scrollbar_extend = round(4 * gui.scale)
		content_scrollbar_click = (
			max_content_scroll > 0
			and (self.click or self.inp.mouse_down)
			and self.coll((scrollbar_x - content_scrollbar_extend, view_rect[1], scrollbar_w + content_scrollbar_extend, view_rect[3]))
		)
		if content_scrollbar_click:
			self.click = False
			bar_height = round(90 * gui.scale)
			if view_rect[3] > 400 * gui.scale and max_content_scroll < 20:
				bar_height = round(180 * gui.scale)
			half = bar_height // 2
			distance = max(view_rect[3] - bar_height, 1)
			position = min(max(self.inp.mouse_position[1] - view_rect[1] - half, 0), distance)
			self.settings_content_scroll_bar.held = True
			self.settings_content_scroll_bar.source_click_y = self.inp.mouse_position[1]
			self.settings_content_scroll_bar.source_bar_y = position
			self.settings_content_scroll_bar.input_sdl.mouse_capture_want = True
			self.settings_content_scroll = round(max_content_scroll * (position / distance))
		if max_content_scroll > 0:
			self.settings_content_scroll = self.settings_content_scroll_bar.draw(
				scrollbar_x,
				view_rect[1],
				scrollbar_w,
				view_rect[3],
				self.settings_content_scroll,
				max_content_scroll,
				click=content_scrollbar_click,
				extend_field=content_scrollbar_extend,
			)

		texture = self.ensure_settings_texture((max(1, int(self.window_size[0])), max(1, int(self.window_size[1]))))
		current_target = sdl3.SDL_GetRenderTarget(self.renderer)
		sdl3.SDL_SetRenderTarget(self.renderer, texture)
		sdl3.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 0)
		sdl3.SDL_RenderClear(self.renderer)

		doc_x = view_rect[0]
		doc_y = view_rect[1] + content_top_pad - self.settings_content_scroll
		visible_top = view_rect[1] - round(80 * gui.scale)
		visible_bottom = view_rect[1] + view_rect[3] + round(80 * gui.scale)
		for index, category_h in enumerate(category_heights):
			category_y = round(doc_y + self.settings_category_offsets[index])
			if category_y > visible_bottom or category_y + category_h < visible_top:
				continue
			self.render_settings_category(index, doc_x, category_y, doc_w, draw=True)

		sdl3.SDL_SetRenderTarget(self.renderer, current_target)
		src_rect = sdl3.SDL_FRect(view_rect[0], view_rect[1], view_rect[2], view_rect[3])
		dst_rect = sdl3.SDL_FRect(view_rect[0], view_rect[1], view_rect[2], view_rect[3])
		sdl3.SDL_RenderTexture(self.renderer, texture, src_rect, dst_rect)

		self.finish_settings_text_inputs()
		self.click = False
		self.right_click = False

		ddt.text_background_colour = colours.box_background

class Fields:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon = tauon
		self.coll  = tauon.coll
		self.id = []
		self.last_id = []

		self.field_array = []
		self.force = False

	def add(self, rect, callback=None) -> None:
		# Honour the active view transform so a widget rendering in a local view
		# space registers its hover fields in real screen coordinates.
		ox, oy = self.tauon.inp.view_offset
		if ox or oy:
			rect = (rect[0] + ox, rect[1] + oy, rect[2], rect[3])
		self.field_array.append((rect, callback))

	def test(self) -> bool:
		if self.force:
			self.force = False
			return True

		self.last_id = self.id
		#logging.info(len(self.id))
		self.id = []

		for f in self.field_array:
			if self.coll(f[0]):
				self.id.append(1)  # += "1"
				if f[1] is not None:  # Call callback if present
					f[1]()
			else:
				self.id.append(0)  # += "0"

		return self.last_id != self.id

	def clear(self) -> None:
		self.field_array = []

class TopPanel:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon:          Tauon = tauon
		self.ddt:            TDraw = tauon.ddt
		self.gui:           GuiVar = tauon.gui
		self.inp:            Input = tauon.inp
		self.coll            = tauon.coll
		self.pctl:       PlayerCtl = tauon.pctl
		self.prefs:          Prefs = tauon.prefs
		self.fonts:          Fonts = tauon.fonts
		self.fields:        Fields = tauon.fields
		self.colours: ColoursClass = tauon.colours
		self.renderer        = tauon.renderer
		self.window_size: list[int] = tauon.window_size
		self.overflow_menu:    Menu = tauon.overflow_menu
		self.draw_min_button:  bool = tauon.draw_min_button
		self.draw_max_button:  bool = tauon.draw_max_button
		self.height:            int = self.gui.panelY
		self.ty:                int = 0

		self.start_space_left = round(46 * self.gui.scale)
		self.start_space_compact_left = 46 * self.gui.scale

		self.tab_text_font = self.fonts.tabs
		self.tab_extra_width = round(17 * self.gui.scale)
		self.tab_text_start_space = 8 * self.gui.scale
		self.tab_text_y_offset = 7 * self.gui.scale
		self.tab_spacing = 0

		self.ini_menu_space = 17 * self.gui.scale  # 17
		self.menu_space = 17 * self.gui.scale
		self.click_buffer = 4 * self.gui.scale

		self.tabs_right_x = 0  # computed for drag and drop code elsewhere (hacky)
		self.tabs_left_x = 1

		self.prime_tab = self.gui.saved_prime_tab
		self.prime_side = self.gui.saved_prime_direction  # 0=left, 1=right
		self.shown_tabs = []

		# ---
		self.space_left = 0
		self.tab_text_spaces: list[int] = []
		self.index_playing = -1
		self.drag_zone_start_x = 300 * self.gui.scale

		bag                   = tauon.bag
		self.exit_button      = asset_loader(bag, bag.loaded_asset_dc, "ex.png", True)
		self.maximize_button  = asset_loader(bag, bag.loaded_asset_dc, "max.png", True)
		self.restore_button   = asset_loader(bag, bag.loaded_asset_dc, "restore.png", True)
		self.restore_button   = asset_loader(bag, bag.loaded_asset_dc, "restore.png", True)
		self.playlist_icon    = asset_loader(bag, bag.loaded_asset_dc, "playlist.png", True)
		self.return_icon      = asset_loader(bag, bag.loaded_asset_dc, "return.png", True)
		self.artist_list_icon = asset_loader(bag, bag.loaded_asset_dc, "artist-list.png", True)
		self.folder_list_icon = asset_loader(bag, bag.loaded_asset_dc, "folder-list.png", True)
		self.dl_button        = asset_loader(bag, bag.loaded_asset_dc, "dl.png", True)
		self.overflow_icon    = asset_loader(bag, bag.loaded_asset_dc, "overflow.png", True)

		self.drag_slide_timer = Timer(100)
		self.tab_d_click_timer = Timer(10)
		self.tab_d_click_ref = None

		self.adds: list[list[int | Timer]] = []

	def left_overflow_switch_playlist(self, pl: int) -> None:
		self.prime_side = 0
		self.prime_tab = pl
		self.pctl.switch_playlist(pl)

	def right_overflow_switch_playlist(self, pl: int) -> None:
		self.prime_side = 1
		self.prime_tab = pl
		self.pctl.switch_playlist(pl)

	def render(self) -> None:
		tauon       = self.tauon
		pctl        = self.pctl
		gui         = self.gui
		ddt         = self.ddt
		inp         = self.inp
		colours     = self.colours
		prefs       = self.prefs
		window_size = self.window_size

		# C-TD
		hh = gui.panelY2
		yy = gui.panelY - hh
		self.height = hh

		if inp.quick_drag is True:
			# gui.pl_update = 1
			gui.update_on_drag = True

		# Draw the background
		ddt.clear_rect((0, 0, window_size[0], gui.panelY))
		ddt.rect((0, 0, window_size[0], gui.panelY), colours.top_panel_background)

		if prefs.shuffle_lock and not gui.compact_bar:
			colour = ColourRGBA(250, 250, 250, 255)
			if colours.lm:
				colour = ColourRGBA(10, 10, 10, 255)
			text = _("Tauon SHUFFLE!")
			if prefs.album_shuffle_lock_mode:
				text = _("ALBUM SHUFFLE")
			ddt.text((window_size[0] // 2, 8 * gui.scale, 2), text, colour, 212, bg=colours.top_panel_background)
		if gui.top_bar_mode2:
			tr = pctl.playing_object()
			if tr:
				tauon.album_art_gen.display(tr, (window_size[0] - gui.panelY - 1, 0), (gui.panelY, gui.panelY))
				if pctl.loading_in_progress or \
						tauon.to_scan or \
						tauon.cm_clean_db or \
						tauon.lastfm.scanning_friends or \
						tauon.after_scan or \
						tauon.move_in_progress or \
						tauon.plex.scanning or \
						tauon.transcode_list or tauon.subsonic.scanning or \
						gui.sync_progress or tauon.lastfm.scanning_scrobbles:
					ddt.rect(
						(window_size[0] - (gui.panelY + 20), gui.panelY - gui.panelY2, gui.panelY + 25, gui.panelY2),
						colours.top_panel_background)

				maxx = window_size[0] - (gui.panelY + 30 * gui.scale)
				title_colour = colours.grey(249)
				if colours.lm:
					title_colour = colours.grey(30)
				title = tr.title
				if not title:
					title = tr.filename
				artist = tr.artist

				if pctl.playing_state == PlayingState.URL_STREAM and not tauon.radiobox.dummy_track.title:
					title = pctl.tag_meta
					artist = tauon.radiobox.loaded_url  # pctl.url

				ddt.text_background_colour = colours.top_panel_background

				ddt.text((round(14 * gui.scale), round(15 * gui.scale)), title, title_colour, 215, max_w=maxx)
				ddt.text((round(14 * gui.scale), round(40 * gui.scale)), artist, colours.grey(120), 315, max_w=maxx)

		wwx = 0
		if prefs.left_window_control and not gui.compact_bar:
			if gui.macstyle:
				wwx = 24
				# wwx = round(64 * gui.scale)
				if self.draw_min_button:
					wwx += 20
				if self.draw_max_button:
					wwx += 20
				wwx = round(wwx * gui.scale)
			else:
				wwx = 26
				# wwx = round(90 * gui.scale)
				if self.draw_min_button:
					wwx += 35
				if self.draw_max_button:
					wwx += 33
				wwx = round(wwx * gui.scale)

		# The panel button sits in the second corner slot; the layout/edit-menu
		# button (below) takes the first, matching its position in custom mode.
		rect = (wwx + 44 * gui.scale, yy + 4 * gui.scale, 34 * gui.scale, 25 * gui.scale)
		self.fields.add(rect)

		if self.coll(rect) and not prefs.shuffle_lock and not gui.custom_mode:
			if inp.mouse_click:

				if gui.combo_mode:
					gui.switch_showcase_off = True
				else:
					gui.lsp ^= True

				gui.update_layout = True
				gui.update += 1
			if self.inp.mouse_down and self.inp.quick_drag:
				gui.lsp = True
				gui.update_layout = True
				gui.update += 1

			if self.inp.middle_click:
				self.tauon.toggle_left_last()
				gui.update_layout = True
				gui.update += 1

			if inp.right_click:
				# prefs.artist_list ^= True
				self.tauon.lsp_menu.activate(position=(5 * gui.scale, gui.panelY))
				self.tauon.update_layout_do()

		colour = colours.corner_button  # [230, 230, 230, 255]

		if gui.lsp:
			colour = colours.corner_button_active
		if gui.combo_mode:
			colour = colours.corner_button
			if self.coll(rect):
				colour = colours.corner_button_active

		if not prefs.shuffle_lock and not gui.custom_mode:
			# The panel button hides in custom mode (the layout/edit button below
			# keeps the first slot there, drawn by the layout engine).
			if gui.combo_mode:
				self.return_icon.render(wwx + 49 * gui.scale, yy + 8 * gui.scale, colour)
			elif prefs.left_panel_mode == "artist list":
				self.artist_list_icon.render(wwx + 48 * gui.scale, yy + 8 * gui.scale, colour)
			elif prefs.left_panel_mode == "folder view":
				self.folder_list_icon.render(wwx + 49 * gui.scale, yy + 8 * gui.scale, colour)
			else:
				self.playlist_icon.render(wwx + 48 * gui.scale, yy + 8 * gui.scale, colour)

		if not prefs.shuffle_lock and not gui.custom_mode:
			# Corner layout/edit-menu button, in the first slot (before the panel
			# button). Same dim styling; active colour while its menu is open;
			# opens the layout menu.
			lrect = (wwx + 9 * gui.scale, yy + 3 * gui.scale, 34 * gui.scale, 25 * gui.scale)
			self.fields.add(lrect)
			if self.coll(lrect) and inp.mouse_click and not self.tauon.layout_menu.click_dismissed:
				inp.mouse_click = False
				self.tauon.layout_menu.activate(position=(lrect[0], lrect[1] + lrect[3]))
			lcol = colours.corner_button_active if self.tauon.layout_menu.active else colours.corner_button
			gw = round(18 * gui.scale)
			gh = round(13 * gui.scale)
			draw_layout_glyph(
				ddt, gui.scale,
				lrect[0] + round((lrect[2] - gw) / 2), lrect[1] + round((lrect[3] - gh) / 2),
				gw, gh, lcol)

		# if prefs.artist_list:
		#     self.artist_list_icon.render(13 * gui.scale, yy + 8 * gui.scale, colour)
		# else:
		#     self.playlist_icon.render(13 * gui.scale, yy + 8 * gui.scale, colour)

		if tauon.playlist_box.drag:
			self.inp.drag_mode = False

		# Need to test length
		self.tab_text_spaces = []

		if gui.radio_view:
			for item in pctl.radio_playlists:
				le = ddt.get_text_w(item.name, self.tab_text_font)
				self.tab_text_spaces.append(le)
		else:
			for i, item in enumerate(pctl.multi_playlist):
				le = ddt.get_text_w(pctl.multi_playlist[i].title, self.tab_text_font)
				self.tab_text_spaces.append(le)

		x = self.start_space_left + wwx
		if not prefs.shuffle_lock and not gui.custom_mode:
			# The corner holds two buttons (layout/edit menu, then the panel
			# button); start the tab strip after the second slot.
			x += round(35 * gui.scale)
		y = yy  # self.ty

		# Calculate position for playing text and text
		offset = 15 * gui.scale
		if tauon.draw_border and not prefs.left_window_control:
			offset += 61 * gui.scale
			if self.draw_max_button:
				offset += 61 * gui.scale
		if gui.turbo:
			offset += 90 * gui.scale
			if gui.vis == 3:
				offset += 57 * gui.scale
		if gui.top_bar_mode2:
			offset = 0

		p_text_len = 180 * gui.scale
		right_space_es = p_text_len + offset

		x_start = x

		if tauon.playlist_box.drag and not gui.radio_view:
			if self.inp.mouse_up:
				if self.inp.mouse_up_position[0] > gui.playlist_left and self.inp.mouse_up_position[1] > gui.panelY:
					tauon.playlist_box.drag = False
					if prefs.drag_to_unpin:
						if tauon.playlist_box.drag_source == 0:
							pass
							# Disabled drag to unpin feature
							#pctl.multi_playlist[tauon.playlist_box.drag_on].hidden = True
						else:
							pctl.multi_playlist[tauon.playlist_box.drag_on].hidden = False
					gui.update += 1
			gui.update_on_drag = True

		# List all tabs eligible to be shown
		#logging.info("-------------")
		ready_tabs: list[int] = []
		show_tabs: list[int] = []

		if prefs.tabs_on_top or gui.radio_view:
			if gui.radio_view:
				for i, tab in enumerate(pctl.radio_playlists):
					ready_tabs.append(i)
				self.prime_tab = min(self.prime_tab, len(pctl.radio_playlists) - 1)
			else:
				for i, tab in enumerate(pctl.multi_playlist):
					# Skip if hide flag is set
					if tab.hidden:
						continue
					ready_tabs.append(i)
				self.prime_tab = min(self.prime_tab, len(pctl.multi_playlist) - 1)
			max_w = window_size[0] - (x + right_space_es + round(34 * gui.scale))

			left_tabs: list[int] = []
			right_tabs: list[int] = []
			if prefs.shuffle_lock:
				for p in ready_tabs:
					left_tabs.append(p)

			else:
				for p in ready_tabs:
					if p < self.prime_tab:
						left_tabs.append(p)

				for p in ready_tabs:
					if p > self.prime_tab:
						right_tabs.append(p)
				left_tabs.reverse()

			run = max_w

			if self.prime_tab in ready_tabs:
				size = self.tab_text_spaces[self.prime_tab] + self.tab_extra_width
				if size < run:
					show_tabs.append(self.prime_tab)
					run -= size

			if self.prime_side == 0:
				for tab in right_tabs:
					size = self.tab_text_spaces[tab] + self.tab_extra_width
					if size < run:
						show_tabs.append(tab)
						run -= size
					else:
						break
				for tab in left_tabs:
					size = self.tab_text_spaces[tab] + self.tab_extra_width
					if size < run:
						show_tabs.insert(0, tab)
						run -= size
					else:
						break
			else:
				for tab in left_tabs:
					size = self.tab_text_spaces[tab] + self.tab_extra_width
					if size < run:
						show_tabs.insert(0, tab)
						run -= size
					else:
						break
				for tab in right_tabs:
					size = self.tab_text_spaces[tab] + self.tab_extra_width
					if size < run:
						show_tabs.append(tab)
						run -= size
					else:
						break

			# for tab in show_tabs:
			#     logging.info(pctl.multi_playlist[tab].title)
			#logging.info("---")
			left_overflow = [x for x in left_tabs if x not in show_tabs]
			right_overflow = [x for x in right_tabs if x not in show_tabs]
			self.shown_tabs = show_tabs

			if left_overflow:
				hh = round(20 * gui.scale)
				rect = [x, y + (self.height - hh), 17 * gui.scale, hh]
				ddt.rect(rect, colours.tab_background)
				self.overflow_icon.render(rect[0] + round(3 * gui.scale), rect[1] + round(4 * gui.scale), colours.tab_text)

				x += 17 * gui.scale
				x_start = x

				if inp.mouse_click and self.coll(rect):
					self.overflow_menu.items.clear()
					for tab in reversed(left_overflow):
						if gui.radio_view:
							self.overflow_menu.add(
								MenuItem(pctl.radio_playlists[tab].name, self.left_overflow_switch_playlist,
								pass_ref=True, set_ref=tab))
						else:
							self.overflow_menu.add(
								MenuItem(pctl.multi_playlist[tab].title, self.left_overflow_switch_playlist,
								pass_ref=True, set_ref=tab))
					self.overflow_menu.activate(0, (rect[0], rect[1] + rect[3]))

			xx = x + (max_w - run)  # + round(6 * gui.scale)
			self.tabs_left_x = x_start

			if right_overflow:
				hh = round(20 * gui.scale)
				rect = [xx, y + (self.height - hh), 17 * gui.scale, hh]
				ddt.rect(rect, colours.tab_background)
				self.overflow_icon.render(
					rect[0] + round(3 * gui.scale), rect[1] + round(4 * gui.scale),
					colours.tab_text)
				if inp.mouse_click and self.coll(rect):
					self.overflow_menu.items.clear()
					for tab in right_overflow:
						if gui.radio_view:
							self.overflow_menu.add(
								MenuItem(
									pctl.radio_playlists[tab].name, self.left_overflow_switch_playlist, pass_ref=True, set_ref=tab))
						else:
							self.overflow_menu.add(
								MenuItem(
									pctl.multi_playlist[tab].title, self.left_overflow_switch_playlist, pass_ref=True, set_ref=tab))
					self.overflow_menu.activate(0, (rect[0], rect[1] + rect[3]))

			if gui.radio_view:
				if not self.inp.mouse_down and pctl.radio_playlist_viewing not in show_tabs and pctl.radio_playlist_viewing in ready_tabs:
					if pctl.radio_playlist_viewing < self.prime_tab:
						self.prime_side = 0
					elif pctl.radio_playlist_viewing > self.prime_tab:
						self.prime_side = 1
					self.prime_tab = pctl.radio_playlist_viewing
					gui.update += 1
			elif not self.inp.mouse_down and pctl.active_playlist_viewing not in show_tabs and pctl.active_playlist_viewing in ready_tabs:
				if pctl.active_playlist_viewing < self.prime_tab:
					self.prime_side = 0
				elif pctl.active_playlist_viewing > self.prime_tab:
					self.prime_side = 1
				self.prime_tab = pctl.active_playlist_viewing
				gui.update += 1

			if tauon.playlist_box.drag and self.inp.mouse_position[0] > xx and inp.mouse_position[1] < gui.panelY:
				gui.update += 1
				if 0.5 < self.drag_slide_timer.get() < 1 and show_tabs and right_overflow:
					self.drag_slide_timer.set()
					self.prime_side = 1
					self.prime_tab = right_overflow[0]
				if self.drag_slide_timer.get() > 1:
					self.drag_slide_timer.set()
			if tauon.playlist_box.drag and self.inp.mouse_position[0] < x and inp.mouse_position[1] < gui.panelY:
				gui.update += 1
				if 0.5 < self.drag_slide_timer.get() < 1 and show_tabs and left_overflow:
					self.drag_slide_timer.set()
					self.prime_side = 0
					self.prime_tab = left_overflow[0]
				if self.drag_slide_timer.get() > 1:
					self.drag_slide_timer.set()

		# TAB INPUT PROCESSING
		target = pctl.multi_playlist
		if gui.radio_view:
			target = pctl.radio_playlists
		for i, tab in enumerate(target):
			if not gui.radio_view:
				if not prefs.tabs_on_top or prefs.shuffle_lock:
					break

				if len(pctl.multi_playlist) != len(self.tab_text_spaces):
					break

			if i not in show_tabs:
				continue

			# Determine the tab width
			tab_width = self.tab_text_spaces[i] + self.tab_extra_width

			# Save the far right boundary of the tabs (hacky)
			self.tabs_right_x = x + tab_width

			# Detect mouse over and add tab to mouse over detection
			f_rect = [x, y + 1, tab_width - 1, self.height - 1]
			tab_hit = self.coll(f_rect)

			# Tab functions
			if tab_hit:
				if not gui.radio_view:
					# Double click to play
					if self.inp.mouse_up and pctl.pl_to_id(i) == self.tab_d_click_ref == pctl.pl_to_id(pctl.active_playlist_viewing) and \
							self.tab_d_click_timer.get() < 0.25 and point_distance(
								self.inp.last_click_location, self.inp.mouse_up_position) < 5 * gui.scale:

						if pctl.playing_state == PlayingState.PAUSED and pctl.active_playlist_playing == i:
							pctl.play()
						elif pctl.selected_ready() and (pctl.playing_state != PlayingState.PLAYING or pctl.active_playlist_playing != i):
							pctl.jump(pctl.default_playlist[pctl.selected_in_playlist], pl_position=pctl.selected_in_playlist)
					if self.inp.mouse_up:
						self.tab_d_click_timer.set()
						self.tab_d_click_ref = pctl.pl_to_id(i)

				# Click to change playlist
				if inp.mouse_click:
					gui.pl_update = 1
					tauon.playlist_box.drag = True
					tauon.playlist_box.drag_source = 0
					tauon.playlist_box.drag_on = i
					if gui.radio_view:
						pctl.radio_playlist_viewing = i
					else:
						pctl.switch_playlist(i)
					gui.set_drag_source()

				# Drag to move playlist
				if self.inp.mouse_up and tauon.playlist_box.drag and coll_point(self.inp.mouse_up_position, f_rect):
					if gui.radio_view:
						pctl.move_radio_playlist(tauon.playlist_box.drag_on, i)
					else:
						if tauon.playlist_box.drag_source == 1:
							pctl.multi_playlist[tauon.playlist_box.drag_on].hidden = False

						if i != tauon.playlist_box.drag_on:

							# # Reveal the tab in case it has been hidden
							# pctl.multi_playlist[tauon.playlist_box.drag_on].hidden = False

							if self.inp.key_shift_down:
								pctl.multi_playlist[i].playlist_ids += pctl.multi_playlist[tauon.playlist_box.drag_on].playlist_ids
								pctl.delete_playlist(tauon.playlist_box.drag_on, check_lock=True, force=True)
							else:
								pctl.move_playlist(tauon.playlist_box.drag_on, i)

					tauon.playlist_box.drag = False
					gui.update += 1

				# Delete playlist on wheel click
				elif tauon.tab_menu.active is False and self.inp.middle_click:
					# delete_playlist(i)
					self.pctl.delete_playlist_ask(i)
					break

				# Activate menu on right click
				elif inp.right_click:
					if gui.radio_view:
						tauon.radio_tab_menu.activate(copy.deepcopy(i))
					else:
						tauon.tab_menu.activate(copy.deepcopy(i))
					gui.tab_menu_pl = i

				# Quick drop tracks
				elif not gui.radio_view and self.inp.quick_drag is True and self.inp.mouse_up:
					self.tab_d_click_ref = -1
					self.tab_d_click_timer.force_set(100)
					if (pctl.gen_codes.get(pctl.pl_to_id(i)) and "self" not in pctl.gen_codes[pctl.pl_to_id(i)]):
						tauon.clear_gen_ask(pctl.pl_to_id(i))
					self.inp.quick_drag = False
					modified = False
					gui.pl_update += 1

					for item in gui.shift_selection:
						pctl.multi_playlist[i].playlist_ids.append(pctl.default_playlist[item])
						modified = True
					if len(gui.shift_selection) > 0:
						modified = True
						self.adds.append(
							[pctl.multi_playlist[i].uuid_int, len(gui.shift_selection), Timer()])  # ID, num, timer

					if modified:
						pctl.after_import_flag = True
						tauon.dropped_playlist = i
						pctl.notify_database_changed()
						pctl.update_shuffle_pool(pctl.multi_playlist[i].uuid_int)
						tauon.tree_view_box.clear_target_pl(i)
						tauon.thread_manager.ready("worker")

				if self.inp.mouse_up and tauon.radio_view.drag:
					pctl.radio_playlists[i].stations.append(tauon.radio_view.drag)
					self.tauon.toast(_("Added station to: ") + pctl.radio_playlists[i].name)

					tauon.radio_view.drag = None

			x += tab_width + self.tab_spacing

		# Test dupelicate tab function
		if tauon.playlist_box.drag:
			rect = (0, x, self.height, window_size[0])
			self.fields.add(rect)

		if self.inp.mouse_up and tauon.playlist_box.drag and self.inp.mouse_position[0] > x and self.inp.mouse_position[1] < self.height:
			if gui.radio_view:
				pass
			elif self.inp.key_ctrl_down:
				tauon.gen_dupe(tauon.playlist_box.drag_on)

			else:
				if tauon.playlist_box.drag_source == 1:
					pctl.multi_playlist[tauon.playlist_box.drag_on].hidden = False

				pctl.move_playlist(tauon.playlist_box.drag_on, i)
			tauon.playlist_box.drag = False

		# Need to test length again
		# Need to test length
		self.tab_text_spaces = []

		if gui.radio_view:
			for item in pctl.radio_playlists:
				le = ddt.get_text_w(item.name, self.tab_text_font)
				self.tab_text_spaces.append(le)
		else:
			for i, item in enumerate(pctl.multi_playlist):
				le = ddt.get_text_w(pctl.multi_playlist[i].title, self.tab_text_font)
				self.tab_text_spaces.append(le)

		# Reset X draw position
		x = x_start
		bar_highlight_size = round(2 * gui.scale)

		# TAB DRAWING
		shown = []
		for i, tab in enumerate(target):

			if not gui.radio_view:
				if not prefs.tabs_on_top or prefs.shuffle_lock:
					break

				if len(pctl.multi_playlist) != len(self.tab_text_spaces):
					break

			# if tab.hidden is True:
			#     continue

			if i not in show_tabs:
				continue

			# if window_size[0] - x - (self.tab_text_spaces[i] + self.tab_extra_width) < right_space_es:
			#     break

			shown.append(i)

			tab_width = self.tab_text_spaces[i] + self.tab_extra_width
			rect = [x, y, tab_width, self.height]

			# Detect mouse over and add tab to mouse over detection
			f_rect = [x, y + 1, tab_width - 1, self.height - 1]
			self.fields.add(f_rect)
			tab_hit = self.coll(f_rect)
			playing_hint = False
			active = False

			# Determine tab background colour
			if not gui.radio_view:
				if i == pctl.active_playlist_viewing:
					bg = colours.tab_background_active
					active = True
				elif (
						tauon.tab_menu.active is True and tauon.tab_menu.reference == i) or (tauon.tab_menu.active is False and tab_hit and not tauon.playlist_box.drag):
					bg = colours.tab_highlight
				elif i == pctl.active_playlist_playing:
					bg = colours.tab_background
					playing_hint = True
				else:
					bg = colours.tab_background
			elif pctl.radio_playlist_viewing == i:
				bg = colours.tab_background_active
				active = True
			else:
				bg = colours.tab_background

			# Draw tab background
			ddt.rect(rect, bg)
			if playing_hint:
				ddt.rect(rect, ColourRGBA(255, 255, 255, 7))

			# Determine text colour
			fg = colours.tab_text_active if active else colours.tab_text

			# Draw tab text
			text = tab.name if gui.radio_view else tab.title
			ddt.text((x + self.tab_text_start_space, y + self.tab_text_y_offset), text, fg, self.tab_text_font, bg=bg)

			# Drop pulse

			if gui.pl_pulse and gui.drop_playlist_target == i and self.tauon.tab_pulse.render(
			x, y + self.height - bar_highlight_size, tab_width, bar_highlight_size, r=200,g=130) is False:
				gui.pl_pulse = False

			# Drag to move playlist
			if tab_hit:
				if self.inp.mouse_down and i != tauon.playlist_box.drag_on and tauon.playlist_box.drag is True:
					if self.inp.key_shift_down:
						ddt.rect((x, y + self.height - bar_highlight_size, tab_width, bar_highlight_size), ColourRGBA(80, 160, 200, 255))
					elif tauon.playlist_box.drag_on < i:
						ddt.rect((x + tab_width - bar_highlight_size, y, bar_highlight_size, gui.panelY2), ColourRGBA(80, 160, 200, 255))
					else:
						ddt.rect((x, y, bar_highlight_size, gui.panelY2), ColourRGBA(80, 160, 200, 255))
				elif not gui.radio_view and (self.inp.quick_drag or gui.ext_drop_mode) is True and tauon.pl_is_mut(i):
					ddt.rect((x, y + self.height - bar_highlight_size, tab_width, bar_highlight_size), ColourRGBA(80, 200, 180, 255))
			# Drag yellow line highlight if single track already in playlist
			elif not gui.radio_view and self.inp.quick_drag and not point_proximity_test(gui.drag_source_position, self.inp.mouse_position, 15 * gui.scale):
				for item in gui.shift_selection:
					if item < len(pctl.default_playlist) and pctl.default_playlist[item] in tab.playlist_ids:
						ddt.rect((x, y + self.height - bar_highlight_size, tab_width, bar_highlight_size), ColourRGBA(190, 160, 20, 255))
						break
			# Drag red line highlight if playlist is generator playlist
			if not gui.radio_view and self.inp.quick_drag and not point_proximity_test(gui.drag_source_position, self.inp.mouse_position, 15 * gui.scale):
				if not self.tauon.pl_is_mut(i):
					ddt.rect((x, y + self.height - bar_highlight_size, tab_width, bar_highlight_size), ColourRGBA(200, 70, 50, 255))

			if not gui.radio_view:
				if len(self.adds) > 0:
					for k in reversed(range(len(self.adds))):
						if pctl.multi_playlist[i].uuid_int == self.adds[k][0]:
							if self.adds[k][2].get() > 0.3:
								del self.adds[k]
							else:
								ay = y + 4
								ay -= 6 * self.adds[k][2].get() / 0.3

								ddt.text(
									(x + tab_width - 3, round(ay), 1), "+" + str(self.adds[k][1]), colours.pulse_colour, 212, bg=bg)
								gui.update += 1

			x += tab_width + self.tab_spacing

		# Quick drag single track onto bar to create new playlist function and indicator
		if prefs.tabs_on_top:
			if (self.inp.quick_drag or gui.ext_drop_mode) and self.inp.mouse_position[0] > x and self.inp.mouse_position[1] < gui.panelY and tauon.quick_d_timer.get() > 1:
				ddt.rect((x, y, 2 * gui.scale, gui.panelY2), ColourRGBA(80, 200, 180, 255))

				if self.inp.mouse_up:
					tauon.drop_tracks_to_new_playlist(gui.shift_selection)

			# Draw end drag tab indicator
			if tauon.playlist_box.drag and self.inp.mouse_position[0] > x and self.inp.mouse_position[1] < gui.panelY:
				if self.inp.key_ctrl_down:
					ddt.rect((x, y, 2 * gui.scale, gui.panelY2), ColourRGBA(255, 190, 0, 255))
				else:
					ddt.rect((x, y, 2 * gui.scale, gui.panelY2), ColourRGBA(80, 160, 200, 255))

		if prefs.tabs_on_top and right_overflow:
			x += 24 * gui.scale
			self.tabs_right_x += 24 * gui.scale

		# -------------
		# Other input
		if self.inp.mouse_up:
			# In the Custom Layout the Header Bar widget renders in tree order,
			# usually before drop targets like the Queue widget; ending the drag
			# here would eat their drop, so the engine ends it after all widgets
			# have drawn (CustomMode.render) instead.
			if not gui.custom_mode:
				self.inp.quick_drag = False
			tauon.playlist_box.drag = False
			tauon.radio_view.drag = None

		# Scroll anywhere on panel to cycle playlist
		# (This is a bit complicated because we need to skip over hidden playlists)
		if self.inp.mouse_wheel != 0 and 1 < self.inp.mouse_position[1] < gui.panelY + 1 and len(pctl.multi_playlist) > 1 and self.inp.mouse_position[0] > 5:

			pctl.cycle_playlist_pinned(self.inp.mouse_wheel)
			# TODO (Flynn): does this one need a smooth scrolling update?

			gui.pl_update = 1
			if not prefs.tabs_on_top:
				if pctl.active_playlist_viewing not in shown:  # and not gui.lsp:
					gui.mode_toast_text = pctl.multi_playlist[pctl.active_playlist_viewing].title
					tauon.toast_mode_timer.set()
					gui.frame_callback_list.append(TestTimer(1))
				else:
					tauon.toast_mode_timer.force_set(10)
					gui.mode_toast_text = ""
		# ---------
		# Menu Bar

		x += self.ini_menu_space
		y += 7 * gui.scale
		ddt.text_background_colour = colours.top_panel_background

		# MENU -----------------------------

		word = _("MENU")
		word_length = ddt.get_text_w(word, 212)
		rect = [x - self.click_buffer, yy + self.ty + 1, word_length + self.click_buffer * 2, self.height - 1]
		hit = self.coll(rect)
		self.fields.add(rect)

		if (tauon.x_menu.active or hit) and not tauon.tab_menu.active:
			bg = colours.status_text_over
		else:
			bg = colours.status_text_normal
		ddt.text((x, y), word, bg, 212)

		if hit and inp.mouse_click:
			if tauon.x_menu.active:
				tauon.x_menu.active = False
			elif not tauon.x_menu.click_dismissed:
				# click_dismissed: this same click already closed the menu's
				# popup window (event loop) — don't instantly reopen it.
				xx = x
				if x > window_size[0] - (210 * gui.scale):
					xx = window_size[0] - round(210 * gui.scale)
				# View Switcher no longer pops out here (layouts live in the
				# corner layout menu now); menu sits 7px further left.
				tauon.x_menu.activate(position=(xx + round(5 * gui.scale), gui.panelY))

		# if True:
		#     border = round(3 * gui.scale)
		#     border_colour = colours.grey(30)
		#     rect = (5 * gui.scale, gui.panelY, round(90 * gui.scale), round(25 * gui.scale))
		#

		dl = len(tauon.dl_mon.ready)
		watching = len(tauon.dl_mon.watching)

		if (dl > 0 or watching > 0) and tauon.core_timer.get() > 2 and prefs.auto_extract and prefs.monitor_downloads:
			x += 52 * gui.scale
			rect = (x - 5 * gui.scale, y - 2 * gui.scale, 30 * gui.scale, 23 * gui.scale)
			self.fields.add(rect)

			if self.coll(rect):
				colour = colours.corner_button_active
				# if colours.lm:
				# colour = ColourRGBA(40, 40, 40, 255)
				if (dl > 0 or watching > 0) and inp.right_click:
					tauon.dl_menu.activate(position=(inp.mouse_position[0], gui.panelY))
				if dl > 0:
					if inp.mouse_click:
						pln = 0
						for item in tauon.dl_mon.ready:
							load_order = LoadClass()
							load_order.target = item
							pln = pctl.active_playlist_viewing
							load_order.playlist = pctl.multi_playlist[pln].uuid_int

							for i, pl in enumerate(pctl.multi_playlist):
								if prefs.download_playlist is not None:
									if pl.uuid_int == prefs.download_playlist:
										load_order.playlist = pl.uuid_int
										pln = i
										break
							else:
								for i, pl in enumerate(pctl.multi_playlist):
									if pl.title.lower() == "downloads":
										load_order.playlist = pl.uuid_int
										pln = i
										break

							tauon.load_orders.append(copy.deepcopy(load_order))

						if len(tauon.dl_mon.ready) > 0:
							tauon.dl_mon.ready.clear()
							pctl.switch_playlist(pln)

							pctl.playlist_view_position = len(pctl.default_playlist)
							logging.debug("Position changed by track import")
							gui.update += 1
				else:
					colour = colours.corner_button  # ColourRGBA(60, 60, 60, 255)
					# if colours.lm:
					# 	colour = ColourRGBA(180, 180, 180, 255)
					if inp.mouse_click:
						inp.mouse_click = False
						self.show_message(
							_("It looks like something is being downloaded..."), _("Let's check back later..."), mode="info")


			else:
				colour = colours.corner_button  # ColourRGBA(60, 60, 60, 255)
				if colours.lm:
					# colour = ColourRGBA(180, 180, 180, 255)
					if tauon.dl_mon.ready:
						colour = colours.corner_button_active  # ColourRGBA(60, 60, 60, 255)

			self.dl_button.render(x, y + 1 * gui.scale, colour)
			if dl > 0:
				ddt.text((x + 18 * gui.scale, y - 4 * gui.scale), str(dl), colours.pulse_colour, 209)  # ColourRGBA(244, 223, 66, 255)
				# ColourRGBA(166, 244, 179, 255)

		# LAYOUT --------------------------------
		x += self.menu_space + word_length

		self.drag_zone_start_x = x - 5 * gui.scale
		status = True

		if pctl.loading_in_progress:
			bg = colours.status_info_text
			if gui.to_got == "xspf":
				text = _("Importing XSPF playlist")
			elif gui.to_got == "xspfl":
				text = _("Importing XSPF playlist...")
			elif gui.to_got == "ex":
				text = _("Extracting Archive...")
			else:
				text = _("Importing...  ") + str(gui.to_got)  # + "/" + str(gui.to_get)
				if inp.right_click and self.coll([x, y, 180 * gui.scale, 18 * gui.scale]):
					tauon.cancel_menu.activate(position=(x + 20 * gui.scale, y + 23 * gui.scale))
		elif tauon.after_scan:
			# bg = colours.status_info_text
			bg = ColourRGBA(100, 200, 100, 255)
			text = _("Scanning Tags...  {N} remaining").format(N=str(len(tauon.after_scan)))
		elif tauon.playlist_autoscan:
			# bg = colours.status_info_text
			bg = ColourRGBA(100, 200, 100, 255)
			text = _("Auto-importing playlists...")
		elif tauon.move_in_progress:
			text = _("File copy in progress...")
			bg = colours.status_info_text
		elif tauon.cm_clean_db and gui.to_get > 0:
			per = str(int(gui.to_got / gui.to_get * 100))
			text = _("Cleaning db...  ") + per + "%"
			bg = ColourRGBA(100, 200, 100, 255)
		elif tauon.to_scan:
			text = _("Rescanning Tags...  {N} remaining").format(N=str(len(tauon.to_scan)))
			bg = ColourRGBA(100, 200, 100, 255)
		elif tauon.plex.scanning:
			text = _("Accessing PLEX library...")
			if gui.to_got:
				text += f" {gui.to_got}"
			bg = ColourRGBA(229, 160, 13, 255)
		elif tauon.subsonic.scanning:
			text = _("Accessing AIRSONIC library...")
			if gui.to_got:
				text += f" {gui.to_got}"
			bg = ColourRGBA(58, 194, 224, 255)
		elif tauon.jellyfin.scanning:
			text = _("Accessing JELLYFIN library...")
			bg = ColourRGBA(90, 170, 240, 255)
		elif tauon.chrome_mode:
			text = _("Chromecast Mode")
			bg = ColourRGBA(207, 94, 219, 255)
		elif gui.sync_progress and not tauon.transcode_list:
			text = gui.sync_progress
			bg = ColourRGBA(100, 200, 100, 255)
			if inp.right_click and self.coll([x, y, 280 * gui.scale, 18 * gui.scale]):
				tauon.cancel_menu.activate(position=(x + 20 * gui.scale, y + 23 * gui.scale))
		elif tauon.transcode_list and gui.tc_cancel:
			bg = ColourRGBA(150, 150, 150, 255)
			text = _("Stopping transcode...")
		elif tauon.lrclib_uploads:
			bg = ColourRGBA(100, 200, 100, 255)
			text = _("Uploading lyrics to LRCLIB...")
		elif tauon.lastfm.scanning_friends or tauon.lastfm.scanning_loves:
			text = _("Scanning: ") + tauon.lastfm.scanning_username
			bg = ColourRGBA(200, 150, 240, 255)
		elif tauon.lastfm.scanning_scrobbles:
			text = _("Scanning Scrobbles...")
			bg = ColourRGBA(219, 88, 18, 255)
		elif gui.buffering:
			text = _("Buffering... ")
			text += gui.buffering_text
			bg = ColourRGBA(18, 180, 180, 255)
		elif tauon.lfm_scrobbler.queue and tauon.scrobble_warning_timer.get() < 260:
			text = _("Network error. Will try again later.")
			bg = ColourRGBA(250, 250, 250, 255)
			gui.last_fm_icon.render(x - 4 * gui.scale, y + 4 * gui.scale, ColourRGBA(250, 40, 40, 255))
			x += 21 * gui.scale
		elif tauon.listen_alongers:
			new = {}
			for ip, timer in tauon.listen_alongers.items():
				if timer.get() < 6:
					new[ip] = timer
			tauon.listen_alongers = new

			text = _("{N} listening along").format(N=len(tauon.listen_alongers))
			bg = ColourRGBA(40, 190, 235, 255)
		else:
			status = False

		if status:
			x += ddt.text((x, y), text, bg, 311)
			# x += ddt.get_text_w(text, 11)
		# TODO(Taiko): list listening clients
		elif tauon.transcode_list:
			bg = colours.status_info_text
			# if inp.key_ctrl_down and inp.key_c_press:
			# 	del tauon.transcode_list[1:]
			# 	gui.tc_cancel = True
			if inp.right_click and self.coll([x, y, 280 * gui.scale, 18 * gui.scale]):
				tauon.cancel_menu.activate(position=(x + 20 * gui.scale, y + 23 * gui.scale))

			w = 100 * gui.scale
			x += ddt.text((x, y), _("Transcoding"), bg, 311) + 8 * gui.scale

			if gui.transcoding_batch_total:

				# c1 = ColourRGBA(40, 40, 40, 255)
				# c2 = ColourRGBA(60, 60, 60, 255)
				# c3 = ColourRGBA(130, 130, 130, 255)
				#
				# if colours.lm:
				# 	c1 = ColourRGBA(100, 100, 100, 255)
				# 	c2 = ColourRGBA(130, 130, 130, 255)
				# 	c3 = ColourRGBA(180, 180, 180, 255)

				c1 = ColourRGBA(40, 40, 40, 255)
				c2 = ColourRGBA(100, 59, 200, 200)
				c3 = ColourRGBA(150, 70, 200, 255)

				if colours.lm:
					c1 = ColourRGBA(100, 100, 100, 255)
					c2 = ColourRGBA(170, 140, 255, 255)
					c3 = ColourRGBA(230, 170, 255, 255)

				yy = y + 4 * gui.scale
				h = 9 * gui.scale
				box = [x, yy, w, h]
				# ddt.rect_r(box, ColourRGBA(100, 100, 100, 255))
				ddt.rect(box, c1)

				done = round(gui.transcoding_batch_done / gui.transcoding_batch_total * 100)
				doing = round(self.tauon.core_use / gui.transcoding_batch_total * 100)

				ddt.rect([x, yy, done, h], c3)
				ddt.rect([x + done, yy, doing, h], c2)

			x += w + 8 * gui.scale

			if gui.sync_progress:
				text = gui.sync_progress
			else:
				text = _("{N} Folder Remaining {T}").format(N=str(len(tauon.transcode_list)), T=tauon.transcode_state)
				if len(tauon.transcode_list) > 1:
					text = _("{N} Folders Remaining {T}").format(N=str(len(tauon.transcode_list)), T=tauon.transcode_state)

			x += ddt.text((x, y), text, bg, 311) + 8 * gui.scale


		if colours.lm:
			colours.tb_line = colours.grey(200)
			ddt.rect((0, int(gui.panelY - 1 * gui.scale), window_size[0], int(1 * gui.scale)), colours.tb_line)

class BottomBarType1:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon         = tauon
		self.gui           = tauon.gui
		self.inp           = tauon.inp
		self.ddt           = tauon.ddt
		self.coll          = tauon.coll
		self.pctl          = tauon.pctl
		self.prefs         = tauon.prefs
		self.fields        = tauon.fields
		self.colours       = tauon.colours
		self.renderer      = tauon.renderer
		self.window_size   = tauon.window_size
		self.smooth_scroll = tauon.smooth_scroll
		self.mode          = 0

		self.seek_time = 0

		self.seek_down = False
		self.seek_hit = False
		self.volume_hit = False
		self.volume_bar_being_dragged = False
		self.control_line_bottom = 35 * self.gui.scale
		self.repeat_click_off = False
		self.random_click_off = False

		self.seek_bar_position = [300 * self.gui.scale, self.window_size[1] - self.gui.panelBY]
		self.seek_bar_size = [self.window_size[0] - (300 * self.gui.scale), 15 * self.gui.scale]
		self.volume_bar_size = [135 * self.gui.scale, 14 * self.gui.scale]
		self.volume_bar_position = [0, 45 * self.gui.scale]

		self.play_button        = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "play.png", True)
		self.forward_button     = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "ff.png", True)
		self.back_button        = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "bb.png", True)
		self.repeat_button      = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "tauon_repeat.png", True)
		self.repeat_button_off  = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "tauon_repeat_off.png", True)
		self.shuffle_button_off = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "tauon_shuffle_off.png", True)
		self.shuffle_button     = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "tauon_shuffle.png", True)
		self.repeat_button_a    = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "tauon_repeat_a.png", True)
		self.shuffle_button_a   = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "tauon_shuffle_a.png", True)

		self.buffer_shard       = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "shard.png", True)

		self.scrob_stick = 0

	def update(self) -> None:
		if self.mode == 0:
			self.volume_bar_position[0] = self.window_size[0] - (210 * self.gui.scale)
			self.volume_bar_position[1] = self.window_size[1] - (27 * self.gui.scale)
			self.seek_bar_position[1]   = self.window_size[1] - self.gui.panelBY

			seek_bar_x = 300 * self.gui.scale
			if self.window_size[0] < 600 * self.gui.scale:
				seek_bar_x = 250 * self.gui.scale

			self.seek_bar_size[0] = self.window_size[0] - seek_bar_x
			self.seek_bar_position[0] = seek_bar_x

			# if gui.bb_show_art:
			#     self.seek_bar_position[0] = 300 + gui.panelBY
			#     self.seek_bar_size[0] = window_size[0] - 300 - gui.panelBY

			# self.seek_bar_position[0] = 0
			# self.seek_bar_size[0] = window_size[0]

	def render(self) -> None:
		window_size = self.window_size
		tauon       = self.tauon
		ddt         = self.ddt
		gui         = self.gui
		prefs       = self.prefs
		pctl        = self.pctl
		inp         = self.inp
		colours     = self.colours
		fonts       = self.tauon.fonts

		sdl3.SDL_SetRenderDrawBlendMode(self.renderer, sdl3.SDL_BLENDMODE_NONE)
		ddt.rect_a((0, self.window_size[1] - self.gui.panelBY), (self.window_size[0], self.gui.panelBY), colours.bottom_panel_colour)
		sdl3.SDL_SetRenderDrawBlendMode(self.renderer, sdl3.SDL_BLENDMODE_BLEND)

		ddt.rect_a(self.seek_bar_position, self.seek_bar_size, colours.seek_bar_background)

		right_offset = 0
		if gui.display_time_mode >= 2:
			right_offset = 22 * self.gui.scale

		if self.window_size[0] < 670 * self.gui.scale:
			right_offset -= 90 * self.gui.scale
		# Scrobble marker

		if prefs.scrobble_mark \
		and (prefs.auto_lfm or self.tauon.lb.enable or prefs.maloja_enable) and not prefs.scrobble_hold \
		and pctl.playing_length > 0 and (self.pctl.playing_state in (PlayingState.PLAYING, PlayingState.PAUSED)):
			if pctl.master_library[pctl.track_queue[pctl.queue_step]].length > 240 * 2:
				l_target = 240
			else:
				l_target = int(pctl.master_library[pctl.track_queue[pctl.queue_step]].length * 0.50)
			l_lead = l_target - pctl.a_time

			if l_lead > 0 and pctl.master_library[pctl.track_queue[pctl.queue_step]].length > 30:
				l_x = self.seek_bar_position[0] + math.ceil(
					pctl.playing_time * self.seek_bar_size[0] / int(pctl.playing_length))
				l_x += math.ceil(self.seek_bar_size[0] / int(pctl.playing_length) * l_lead)

				if abs(self.scrob_stick - l_x) < 2:
					l_x = self.scrob_stick
				else:
					self.scrob_stick = l_x
				ddt.rect((self.scrob_stick, self.seek_bar_position[1], 2 * self.gui.scale, self.seek_bar_size[1]), ColourRGBA(240, 10, 10, 80))

		# # MINI ALBUM ART
		# if gui.bb_show_art:
		# 	rect = [self.seek_bar_position[0] - gui.panelBY, self.seek_bar_position[1], gui.panelBY, gui.panelBY]
		# 	ddt.rect_r(rect, [255, 255, 255, 8], True)
		# 	if self.pctl.playing_state in (PlayingState.PLAYING, PlayingState.PAUSED:
		# 		tauon.album_art_gen.display(pctl.track_queue[pctl.queue_step], (rect[0], rect[1]), (rect[2], rect[3]))

		# ddt.rect_r(rect, ColourRGBA(255, 255, 255, 20))

		# SEEK BAR------------------
		if pctl.playing_time < 1:
			self.seek_time = 0

		if inp.mouse_click and coll_point(
			self.inp.mouse_position,
			self.seek_bar_position + [self.seek_bar_size[0]] + [
			self.seek_bar_size[1] + 2]):
			self.seek_down = True
			self.volume_hit = True
		if inp.right_click and coll_point(
			inp.mouse_position, self.seek_bar_position + [self.seek_bar_size[0]] + [self.seek_bar_size[1] + 2]):
			pctl.pause()
			if pctl.playing_state == PlayingState.STOPPED:
				pctl.play()

		self.fields.add(self.seek_bar_position + self.seek_bar_size)
		if self.coll(self.seek_bar_position + self.seek_bar_size):

			if self.inp.middle_click and pctl.playing_state != PlayingState.STOPPED:
				gui.seek_cur_show = True

			inp.global_clicked = True
			if self.inp.mouse_wheel != 0:
				pctl.seek_time(pctl.playing_time + (self.inp.mouse_wheel * 3))

		if gui.seek_cur_show:
			gui.update += 1

			# self.fields.add([inp.mouse_position[0] - 1, inp.mouse_position[1] - 1, 1, 1])
			# ddt.rect_r([inp.mouse_position[0] - 1, inp.mouse_position[1] - 1, 1, 1], [255,0,0,180], True)

			bargetX = self.inp.mouse_position[0]
			bargetX = min(bargetX, self.seek_bar_position[0] + self.seek_bar_size[0])
			bargetX = max(bargetX, self.seek_bar_position[0])
			bargetX -= self.seek_bar_position[0]
			seek = bargetX / self.seek_bar_size[0]
			gui.cur_time = get_display_time(pctl.playing_object().length * seek)

		if self.seek_down is True and self.inp.mouse_position[0] == 0:
			self.seek_down = False
			self.seek_hit = True

		if (self.inp.mouse_up and self.coll(self.seek_bar_position + self.seek_bar_size) \
		and coll_point(self.inp.last_click_location, self.seek_bar_position + self.seek_bar_size) \
		and coll_point(self.inp.click_location, self.seek_bar_position + self.seek_bar_size)) \
		or (self.inp.mouse_up and self.volume_hit) or self.seek_hit:
			self.volume_hit = False
			self.seek_down = False
			self.seek_hit = False

			bargetX = self.inp.mouse_position[0]
			bargetX = min(bargetX, self.seek_bar_position[0] + self.seek_bar_size[0])
			bargetX = max(bargetX, self.seek_bar_position[0])
			bargetX -= self.seek_bar_position[0]
			seek = bargetX / self.seek_bar_size[0]

			pctl.seek_decimal(seek)
			#logging.info(seek)

			self.seek_time = pctl.playing_time

		if tauon.radiobox.load_connecting or gui.buffering:
			x = self.seek_bar_position[0] - round(26 - gui.scale)
			y = self.seek_bar_position[1]
			while x < self.seek_bar_position[0] + self.seek_bar_size[0]:
				offset = (math.floor(((tauon.core_timer.get() * 1) % 1) * 13) / 13) * self.buffer_shard.w
				gui.delay_frame(0.01)

				# colour = colours.seek_bar_fill
				h, l, s = rgb_to_hls(
					colours.seek_bar_background.r, colours.seek_bar_background.g, colours.seek_bar_background.b)
				l = min(1, l + 0.05)
				colour = hls_to_rgb(h, l, s)
				colour.a = colours.seek_bar_background.a

				self.buffer_shard.render(x + offset, y, colour)
				x += self.buffer_shard.w

			ddt.rect(
				(self.seek_bar_position[0] - self.buffer_shard.w, y, self.buffer_shard.w, self.buffer_shard.h),
				colours.bottom_panel_colour)

		if pctl.playing_length > 0:
			if pctl.download_time != 0:
				if pctl.download_time == -1:
					pctl.download_time = pctl.playing_length

				colour = ColourRGBA(255, 255, 255, 10)
				if gui.theme_name == "Lavender Light" or gui.theme_name == "Carbon":
					colour = ColourRGBA(255, 255, 255, 40)

				gui.seek_bar_rect = (
					self.seek_bar_position[0], self.seek_bar_position[1],
					int(pctl.download_time * self.seek_bar_size[0] / pctl.playing_length),
					self.seek_bar_size[1])
				ddt.rect(gui.seek_bar_rect, colour)

			gui.seek_bar_rect = (
				self.seek_bar_position[0], self.seek_bar_position[1],
				int(self.seek_time * self.seek_bar_size[0] / pctl.playing_length),
				self.seek_bar_size[1])
			ddt.rect(gui.seek_bar_rect, colours.seek_bar_fill)
			tauon.draw_ab_repeat_markers(
				self.seek_bar_position[0], self.seek_bar_position[1],
				self.seek_bar_size[0], self.seek_bar_size[1])

		if gui.seek_cur_show:
			if self.coll(
				[self.seek_bar_position[0] - 50, self.seek_bar_position[1] - 50, self.seek_bar_size[0] + 50, self.seek_bar_size[1] + 100]):
				if self.inp.mouse_position[0] > self.seek_bar_position[0] - 1:
					cur = [self.inp.mouse_position[0] - 40, self.seek_bar_position[1] - 25, 42, 19]
					ddt.rect(cur, colours.grey(15))
					# ddt.rect_r(cur, colours.grey(80))
					ddt.text(
						(self.inp.mouse_position[0] - 40 + 3, self.seek_bar_position[1] - 24), gui.cur_time,
						colours.grey(180), 213,
						bg=colours.grey(15))

					ddt.rect(
						[self.inp.mouse_position[0], self.seek_bar_position[1], 2, self.seek_bar_size[1]],
						ColourRGBA(100, 100, 20, 255))
			else:
				gui.seek_cur_show = False

		if gui.buffering and pctl.buffering_percent:
			ddt.rect_a((self.seek_bar_position[0], self.seek_bar_position[1] + self.seek_bar_size[1] - round(3 * gui.scale)), (self.seek_bar_size[0] * pctl.buffering_percent / 100, round(3 * gui.scale)), ColourRGBA(255, 255, 255, 50))
		# Volume mouse wheel control -----------------------------------------
		if self.inp.mouse_wheel != 0 and self.inp.mouse_position[1] > self.seek_bar_position[1] + 4 \
		and not coll_point(self.inp.mouse_position, self.seek_bar_position + self.seek_bar_size):
			scroll_distance = self.smooth_scroll.scroll("volume bar")
			pctl.player_volume += scroll_distance * prefs.volume_wheel_increment
			if pctl.player_volume < 1:
				pctl.player_volume = 0
			elif pctl.player_volume > 100:
				pctl.player_volume = 100

			pctl.player_volume = int(pctl.player_volume)
			pctl.set_volume()

		# Volume Bar 2 ------------------------------------------------
		if window_size[0] < 670 * gui.scale:
			x = window_size[0] - right_offset - 207 * gui.scale
			y = window_size[1] - round(14 * gui.scale)

			rect = (x - 8 * gui.scale, y - 17 * gui.scale, 55 * gui.scale, 23 * gui.scale)
			# ddt.rect(rect, [255,255,255,25])
			if self.coll(rect) and self.inp.mouse_down:
				gui.update_on_drag = True

			h_rect = (x - 6 * gui.scale, y - 17 * gui.scale, 4 * gui.scale, 23 * gui.scale)
			if self.coll(h_rect) and self.inp.mouse_down:
				pctl.player_volume = 0

			step = round(1 * gui.scale)
			min_h = round(4 * gui.scale)
			spacing = round(5 * gui.scale)

			if inp.right_click and self.coll((h_rect[0], h_rect[1], h_rect[2] + 50 * gui.scale, h_rect[3])):
				if inp.right_click:
					pctl.toggle_mute()

			for bar in range(8):
				h = min_h + bar * step
				rect = (x, y - h, 3 * gui.scale, h)
				h_rect = (x - 1 * gui.scale, y - 17 * gui.scale, 4 * gui.scale, 23 * gui.scale)

				if self.coll(h_rect):
					if self.inp.mouse_down or self.inp.mouse_up:
						gui.update_on_drag = True

						if bar == 0:
							pctl.player_volume = 5
						if bar == 1:
							pctl.player_volume = 10
						if bar == 2:
							pctl.player_volume = 20
						if bar == 3:
							pctl.player_volume = 30
						if bar == 4:
							pctl.player_volume = 45
						if bar == 5:
							pctl.player_volume = 55
						if bar == 6:
							pctl.player_volume = 70
						if bar == 7:
							pctl.player_volume = 100

						pctl.set_volume()

				colour = colours.mode_button_off

				if bar == 0 and pctl.player_volume > 0:
					colour = colours.mode_button_active
				elif bar == 1 and pctl.player_volume >= 10:
					colour = colours.mode_button_active
				elif bar == 2 and pctl.player_volume >= 20:
					colour = colours.mode_button_active
				elif bar == 3 and pctl.player_volume >= 30:
					colour = colours.mode_button_active
				elif bar == 4 and pctl.player_volume >= 45:
					colour = colours.mode_button_active
				elif bar == 5 and pctl.player_volume >= 55:
					colour = colours.mode_button_active
				elif bar == 6 and pctl.player_volume >= 70:
					colour = colours.mode_button_active
				elif bar == 7 and pctl.player_volume >= 95:
					colour = colours.mode_button_active

				ddt.rect(rect, colour)
				x += spacing

		# Volume Bar --------------------------------------------------------
		else:
			if (inp.mouse_click and self.coll((
					self.volume_bar_position[0] - right_offset, self.volume_bar_position[1], self.volume_bar_size[0],
					self.volume_bar_size[1] + 4))) or \
					self.volume_bar_being_dragged is True:
				inp.global_clicked = True

				if inp.mouse_click is True or self.volume_bar_being_dragged is True:
					gui.update = 2

					self.volume_bar_being_dragged = True
					volgetX = self.inp.mouse_position[0]
					volgetX = min(volgetX, self.volume_bar_position[0] + self.volume_bar_size[0] - right_offset)
					volgetX = max(volgetX, self.volume_bar_position[0] - right_offset)
					volgetX -= self.volume_bar_position[0] - right_offset
					pctl.player_volume = volgetX / self.volume_bar_size[0] * 100

					time.sleep(0.005)

					if self.inp.mouse_down is False:
						self.volume_bar_being_dragged = False
						pctl.player_volume = int(pctl.player_volume)
						pctl.set_volume(True)

				if self.inp.mouse_down:
					pctl.player_volume = int(pctl.player_volume)
					pctl.set_volume(False)

			if inp.right_click and self.coll((
					self.volume_bar_position[0] - 15 * gui.scale, self.volume_bar_position[1] - 10 * gui.scale,
					self.volume_bar_size[0] + 30 * gui.scale,
					self.volume_bar_size[1] + 20 * gui.scale)):

				if pctl.player_volume > 0:
					pctl.volume_store = pctl.player_volume
					pctl.player_volume = 0
				else:
					pctl.player_volume = pctl.volume_store

				pctl.set_volume()

			ddt.rect_a(
				(self.volume_bar_position[0] - right_offset, self.volume_bar_position[1]),
				self.volume_bar_size, colours.volume_bar_background)  # 22

			gui.volume_bar_rect = (
				self.volume_bar_position[0] - right_offset, self.volume_bar_position[1],
				int(pctl.player_volume * self.volume_bar_size[0] / 100), self.volume_bar_size[1])

			ddt.rect(gui.volume_bar_rect, colours.volume_bar_fill)

			self.fields.add(self.volume_bar_position + self.volume_bar_size)
			if pctl.active_replaygain != 0 and (self.coll((
				self.volume_bar_position[0], self.volume_bar_position[1], self.volume_bar_size[0],
				self.volume_bar_size[1])) or self.volume_bar_being_dragged):

				if pctl.player_volume > 50:
					ddt.text(
						(self.volume_bar_position[0] - right_offset + 8 * gui.scale,
						self.volume_bar_position[1] - 1 * gui.scale), str(pctl.active_replaygain) + " dB",
						colours.volume_bar_background,
						11, bg=colours.volume_bar_fill)
				else:
					ddt.text(
						(self.volume_bar_position[0] - right_offset + 85 * gui.scale,
						self.volume_bar_position[1] - 1 * gui.scale), str(pctl.active_replaygain) + " dB",
						colours.volume_bar_fill,
						11, bg=colours.volume_bar_background)

		gui.show_bottom_title = gui.showed_title ^ True
		if not prefs.hide_bottom_title:
			gui.show_bottom_title = True

		if gui.show_bottom_title and pctl.playing_state != PlayingState.STOPPED and window_size[0] > 820 * gui.scale:
			line = pctl.title_text()

			x = self.seek_bar_position[0] + 1
			mx = window_size[0] - 710 * gui.scale
			# if self.gui.bb_show_art:
			#  x += 10 * self.gui.scale
			#  mx -= self.gui.panelBY - 10

			# line = self.tauon.trunc_line(line, 213, mx)
			ddt.text(
				(x, self.seek_bar_position[1] + 24 * gui.scale), line, colours.bar_title_text,
				fonts.panel_title, max_w=mx)

		if (inp.mouse_click or inp.right_click) and self.coll((
				self.seek_bar_position[0] - 10 * gui.scale, self.seek_bar_position[1] + 20 * gui.scale,
				window_size[0] - 710 * gui.scale, 30 * gui.scale)):
			# if pctl.playing_state == PlayingState.URL_STREAM:
			# 	copy_to_clipboard(pctl.tag_meta)
			# 	self.show_message("Copied text to clipboard")
			# 	if input.mouse_click or inp.right_click:
			# 		input.mouse_click = False
			# 		inp.right_click = False
			# else:
			if inp.mouse_click and pctl.playing_state != PlayingState.URL_STREAM:
				pctl.show_current()

			if pctl.playing_ready() and not gui.fullscreen:
				if inp.right_click:
					tauon.mode_menu.activate()

				if self.tauon.d_click_timer.get() < 0.3 and inp.mouse_click:
					self.tauon.set_mini_mode()
					gui.update += 1
					return
				self.tauon.d_click_timer.set()

		# TIME----------------------

		x = window_size[0] - 57 * gui.scale
		y = window_size[1] - 29 * gui.scale

		r_start = x - 10 * gui.scale
		if gui.display_time_mode in (2, 3):
			r_start -= 20 * gui.scale
		rect = (r_start, y - 3 * gui.scale, 80 * gui.scale, 27 * gui.scale)
		# ddt.rect_r(rect, [255, 0, 0, 40], True)
		if inp.mouse_click and self.coll(rect):
			gui.display_time_mode += 1
			if gui.display_time_mode > 3:
				gui.display_time_mode = 0

		if gui.display_time_mode == 0:
			text_time = get_display_time(pctl.playing_time)
			ddt.text(
				(x + 1 * gui.scale, y), text_time, colours.time_playing,
				fonts.bottom_panel_time)
		elif gui.display_time_mode == 1:
			if pctl.playing_state == PlayingState.STOPPED:
				text_time = get_display_time(0)
			else:
				text_time = get_display_time(pctl.playing_length - pctl.playing_time)
			ddt.text(
				(x + 1 * gui.scale, y), text_time, colours.time_playing,
				fonts.bottom_panel_time)
			ddt.text(
				(x - 5 * gui.scale, y), "-", colours.time_playing,
				fonts.bottom_panel_time)
		elif gui.display_time_mode == 2:

			# colours.time_sub = alpha_blend(ColourRGBA(255, 255, 255, 80), colours.bottom_panel_colour)

			x -= 4
			text_time = get_display_time(pctl.playing_time)
			ddt.text(
				(x - 25 * gui.scale, y), text_time, colours.time_playing,
				fonts.bottom_panel_time)

			offset1 = 10 * gui.scale

			offset2 = offset1 + 7 * gui.scale

			ddt.text(
				(x + offset1, y), "/", colours.time_sub,
				fonts.bottom_panel_time)
			text_time = get_display_time(pctl.playing_length)
			if pctl.playing_state == PlayingState.STOPPED:
				text_time = get_display_time(0)
			elif pctl.playing_state == PlayingState.URL_STREAM:
				text_time = "-- : --"
			ddt.text(
				(x + offset2, y), text_time, colours.time_sub,
				fonts.bottom_panel_time)
		elif gui.display_time_mode == 3:
			# colours.time_sub = alpha_blend(ColourRGBA(255, 255, 255, 80), colours.bottom_panel_colour)
			track = pctl.playing_object()
			if track and track.index != gui.dtm3_index:

				gui.dtm3_cum = 0
				gui.dtm3_total = 0
				run = True
				collected = []
				for item in pctl.default_playlist:
					if pctl.master_library[item].parent_folder_path == track.parent_folder_path:
						if item not in collected:
							collected.append(item)
							gui.dtm3_total += pctl.master_library[item].length
							if item == track.index:
								run = False
							if run:
								gui.dtm3_cum += pctl.master_library[item].length
				gui.dtm3_index = track.index

			x -= 4
			text_time = get_display_time(gui.dtm3_cum + pctl.playing_time)

			ddt.text(
				(x - 25 * gui.scale, y), text_time, colours.time_playing,
				fonts.bottom_panel_time)

			offset1 = 10 * gui.scale
			offset2 = offset1 + 7 * gui.scale

			ddt.text(
				(x + offset1, y), "/", colours.time_sub,
				fonts.bottom_panel_time)
			text_time = get_display_time(gui.dtm3_total)
			if pctl.playing_state == PlayingState.STOPPED:
				text_time = get_display_time(0)
			elif pctl.playing_state == PlayingState.URL_STREAM:
				text_time = "-- : --"
			ddt.text(
				(x + offset2, y), text_time, colours.time_sub,
				fonts.bottom_panel_time)

		# BUTTONS
		# bottom buttons

		if gui.mode == GuiMode.MAIN:
			# PLAY---
			buttons_x_offset = 0
			compact = False
			if window_size[0] < 650 * gui.scale:
				compact = True

			play_colour = colours.media_buttons_off
			pause_colour = colours.media_buttons_off
			stop_colour = colours.media_buttons_off
			forward_colour = colours.media_buttons_off
			back_colour = colours.media_buttons_off

			if pctl.playing_state == PlayingState.PLAYING:
				play_colour = colours.media_buttons_active

			if pctl.stop_mode != StopMode.OFF:
				stop_colour = colours.media_buttons_active

			if pctl.playing_state == PlayingState.PAUSED:
				pause_colour = colours.media_buttons_active
				play_colour = colours.media_buttons_active
			elif pctl.playing_state == PlayingState.URL_STREAM:
				play_colour = colours.media_buttons_active
				if tauon.stream_proxy.encode_running:
					play_colour = ColourRGBA(220, 50, 50, 255)

			if not compact or (compact and pctl.playing_state != PlayingState.PLAYING):
				rect = (
				buttons_x_offset + (10 * gui.scale), window_size[1] - self.control_line_bottom - (13 * gui.scale),
				50 * gui.scale, 40 * gui.scale)
				self.fields.add(rect)
				if self.coll(rect):
					play_colour = colours.media_buttons_over
					if inp.mouse_click:
						if compact and pctl.playing_state == PlayingState.PLAYING:
							pctl.pause()
						elif pctl.playing_state == PlayingState.PLAYING:
							pctl.show_current(highlight=True)
						else:
							pctl.play()
						inp.mouse_click = False
					tauon.tool_tip2.test(33 * gui.scale, y - 35 * gui.scale, _("Play, RC: Go to playing"))

					if inp.right_click:
						pctl.show_current(highlight=True)

				self.play_button.render(29 * gui.scale, window_size[1] - self.control_line_bottom, play_colour)
				# ddt.rect_r(rect,[255,0,0,255], True)

			# PAUSE---
			if compact:
				buttons_x_offset = -46 * gui.scale

			x = (75 * gui.scale) + buttons_x_offset
			y = window_size[1] - self.control_line_bottom

			if not compact or (compact and pctl.playing_state == PlayingState.PLAYING):

				rect = (x - 15 * gui.scale, y - 13 * gui.scale, 50 * gui.scale, 40 * gui.scale)
				self.fields.add(rect)
				if self.coll(rect) and pctl.playing_state != PlayingState.URL_STREAM:
					pause_colour = colours.media_buttons_over
					if inp.mouse_click:
						pctl.pause()
					if inp.right_click:
						pctl.show_current(highlight=True)
					tauon.tool_tip2.test(x, y - 35 * gui.scale, _("Pause"))

				# ddt.rect_r(rect,[255,0,0,255], True)
				ddt.rect_a((x, y + 0), (4 * gui.scale, 13 * gui.scale), pause_colour)
				ddt.rect_a((x + 10 * gui.scale, y + 0), (4 * gui.scale, 13 * gui.scale), pause_colour)

			# STOP---
			x = 125 * gui.scale + buttons_x_offset
			rect = (x - 14 * gui.scale, y - 13 * gui.scale, 50 * gui.scale, 40 * gui.scale)
			self.fields.add(rect)
			if self.coll(rect):
				stop_colour = colours.media_buttons_over
				if inp.mouse_click:
					pctl.stop()
				if inp.right_click:
					#pctl.auto_stop ^= True
					tauon.stop_menu.activate(position=(x - 0 * gui.scale, y - 6 * gui.scale))
				#tauon.tool_tip2.test(x, y - 35 * gui.scale, _("Stop, RC: Toggle auto-stop"))

			ddt.rect_a((x, y + 0), (13 * gui.scale, 13 * gui.scale), stop_colour)
			# ddt.rect_r(rect,[255,0,0,255], True)

			if compact:
				buttons_x_offset -= 5 * gui.scale

			# FORWARD---
			rect = (buttons_x_offset + 230 * gui.scale, window_size[1] - self.control_line_bottom - 10 * gui.scale,
					50 * gui.scale, 35 * gui.scale)
			self.fields.add(rect)
			if self.coll(rect) and pctl.playing_state != PlayingState.URL_STREAM:
				forward_colour = colours.media_buttons_over
				if inp.mouse_click:
					pctl.advance()
					gui.tool_tip_lock_off_f = True
				if inp.right_click:
					# pctl.random_mode ^= True
					tauon.toggle_random()
					gui.tool_tip_lock_off_f = True
					# if window_size[0] < 600 * gui.scale:
					# . Shuffle set to on
					gui.mode_toast_text = _("Shuffle On")
					if not pctl.random_mode:
						# . Shuffle set to off
						gui.mode_toast_text = _("Shuffle Off")
					tauon.toast_mode_timer.set()
					gui.delay_frame(1)
				if self.inp.middle_click:
					pctl.advance(rr=True)
					gui.tool_tip_lock_off_f = True
				# tauon.tool_tip.test(buttons_x_offset + 230 * gui.scale + 50 * gui.scale, window_size[1] - self.control_line_bottom - 20 * gui.scale, "Advance")
				# if not gui.tool_tip_lock_off_f:
				# 	tauon.tool_tip2.test(x + 45 * gui.scale, y - 35 * gui.scale, _("Forward, RC: Toggle shuffle, MC: Radio random"))
			else:
				gui.tool_tip_lock_off_f = False

			self.forward_button.render(
				buttons_x_offset + 240 * gui.scale, 1 + window_size[1] - self.control_line_bottom, forward_colour)

			# ddt.rect_r(rect,[255,0,0,255], True)

			# BACK---
			rect = (buttons_x_offset + 170 * gui.scale, window_size[1] - self.control_line_bottom - 10 * gui.scale,
					50 * gui.scale, 35 * gui.scale)
			self.fields.add(rect)
			if self.coll(rect) and pctl.playing_state != PlayingState.URL_STREAM:
				back_colour = colours.media_buttons_over
				if inp.mouse_click:
					pctl.back()
					gui.tool_tip_lock_off_b = True
				if inp.right_click:
					tauon.toggle_repeat()
					gui.tool_tip_lock_off_b = True
					# if window_size[0] < 600 * gui.scale:
					# . Repeat set to on
					gui.mode_toast_text = _("Repeat On")
					if not pctl.repeat_mode:
						# . Repeat set to off
						gui.mode_toast_text = _("Repeat Off")
					tauon.toast_mode_timer.set()
					gui.delay_frame(1)
				if self.inp.middle_click:
					pctl.revert()
					gui.tool_tip_lock_off_b = True
				if not gui.tool_tip_lock_off_b:
					tauon.tool_tip2.test(x, y - 35 * gui.scale, _("Back, RC: Toggle repeat, MC: Revert"))
			else:
				gui.tool_tip_lock_off_b = False

			self.back_button.render(buttons_x_offset + 180 * gui.scale, 1 + window_size[1] - self.control_line_bottom,
									back_colour)
			# ddt.rect_r(rect,[255,0,0,255], True)

			# menu button

			x = window_size[0] - 252 * gui.scale - right_offset
			y = window_size[1] - round(26 * gui.scale)
			rpbc = colours.mode_button_off
			rect = (x - 9 * gui.scale, y - 5 * gui.scale, 40 * gui.scale, 25 * gui.scale)
			self.fields.add(rect)
			if self.coll(rect):
				if not tauon.extra_menu.active:
					tauon.tool_tip.test(x, y - 28 * gui.scale, _("Playback menu"))
				rpbc = colours.mode_button_over
				if inp.mouse_click:
					tauon.extra_menu.activate(position=(x - 115 * gui.scale, y - 6 * gui.scale), bottom_anchor=True)
				elif inp.right_click:
					tauon.mode_menu.activate(position=(x - 115 * gui.scale, y - 6 * gui.scale))
			if tauon.extra_menu.active:
				rpbc = colours.mode_button_active

			spacing = round(5 * gui.scale)
			ddt.rect_a((x, y), (24 * gui.scale, 2 * gui.scale), rpbc)
			y += spacing
			ddt.rect_a((x, y), (24 * gui.scale, 2 * gui.scale), rpbc)
			y += spacing
			ddt.rect_a((x, y), (24 * gui.scale, 2 * gui.scale), rpbc)

			if self.mode == 0 and window_size[0] > 530 * gui.scale:

				# shuffle button
				x = window_size[0] - 318 * gui.scale - right_offset
				y = window_size[1] - 27 * gui.scale

				rect = (x - 5 * gui.scale, y - 5 * gui.scale, 60 * gui.scale, 25 * gui.scale)
				self.fields.add(rect)

				rpbc = colours.mode_button_off
				off = True
				if (inp.mouse_click or inp.right_click) and self.coll(rect):
					if inp.mouse_click:
						# pctl.random_mode ^= True
						tauon.toggle_random()
						if pctl.random_mode is False:
							self.random_click_off = True
					else:
						tauon.shuffle_menu.activate(position=(x + 30 * gui.scale, y - 7 * gui.scale))

				if pctl.random_mode:
					rpbc = colours.mode_button_active
					off = False
					if self.coll(rect):
						tauon.tool_tip.test(x, y - 28 * gui.scale, _("Shuffle"))
				elif self.coll(rect):
					tauon.tool_tip.test(x, y - 28 * gui.scale, _("Shuffle"))
					if self.random_click_off is True:
						rpbc = colours.mode_button_off
					elif pctl.random_mode is True:
						rpbc = colours.mode_button_active
					else:
						rpbc = colours.mode_button_over
				else:
					self.random_click_off = False

				# Keep hover highlight on if menu is open
				if tauon.shuffle_menu.active and not pctl.random_mode:
					rpbc = colours.mode_button_over

				#self.shuffle_button.render(x + round(1 * gui.scale), y + round(1 * gui.scale), rpbc)

				#y += round(3 * gui.scale)
				#ddt.rect_a((x, y), (25 * gui.scale, 3 * gui.scale), rpbc)

				icon_x_shift = round(2 * gui.scale)
				if pctl.album_shuffle_mode:
					self.shuffle_button_a.render(x + round(1 * gui.scale) + icon_x_shift, y + round(1 * gui.scale), rpbc)
				elif off:
					self.shuffle_button_off.render(x + round(1 * gui.scale) + icon_x_shift, y + round(1 * gui.scale), rpbc)
				else:
					self.shuffle_button.render(x + round(1 * gui.scale) + icon_x_shift, y + round(1 * gui.scale), rpbc)

					#ddt.rect_a((x + 25 * gui.scale, y), (23 * gui.scale, 3 * gui.scale), rpbc)

				#y += round(5 * gui.scale)
				#ddt.rect_a((x, y), (48 * gui.scale, 3 * gui.scale), rpbc)

				# REPEAT
				x = window_size[0] - round(380 * gui.scale) - right_offset
				y = window_size[1] - round(27 * gui.scale)

				rpbc = colours.mode_button_off
				off = True

				rect = (x - 6 * gui.scale, y - 5 * gui.scale, 61 * gui.scale, 25 * gui.scale)
				self.fields.add(rect)
				if (inp.mouse_click or inp.right_click) and self.coll(rect):
					if inp.mouse_click:
						tauon.toggle_repeat()
						if pctl.repeat_mode is False:
							self.repeat_click_off = True
					else:  # right click
						tauon.repeat_menu.activate(position=(x + 30 * gui.scale, y - 7 * gui.scale))
						# pctl.album_repeat_mode ^= True
						# if not pctl.repeat_mode:
						#     self.repeat_click_off = True

				if pctl.repeat_mode:
					rpbc = colours.mode_button_active
					off = False
					if self.coll(rect):
						if pctl.album_repeat_mode:
							tauon.tool_tip.test(x, y - 28 * gui.scale, _("Repeat album"))
						else:
							tauon.tool_tip.test(x, y - 28 * gui.scale, _("Repeat track"))
				elif self.coll(rect):

					# Tooltips. But don't show tooltips if menus open
					if not tauon.repeat_menu.active and not tauon.shuffle_menu.active:
						if pctl.album_repeat_mode:
							tauon.tool_tip.test(x, y - 28 * gui.scale, _("Repeat album"))
						else:
							tauon.tool_tip.test(x, y - 28 * gui.scale, _("Repeat track"))

					if self.repeat_click_off is True:
						rpbc = colours.mode_button_off
					elif pctl.repeat_mode is True:
						rpbc = colours.mode_button_active
					else:
						rpbc = colours.mode_button_over
				else:
					self.repeat_click_off = False

				# Keep hover highlight on if menu is open
				if tauon.repeat_menu.active and not pctl.repeat_mode:
					rpbc = colours.mode_button_over

				rpbc = alpha_blend(rpbc, colours.bottom_panel_colour)  # bake in alpha in case of overlap

				y += round(3 * gui.scale)
				w = round(3 * gui.scale)
				y = round(y)
				x = round(x)

				ar = x + round(50 * gui.scale)
				h = round(5 * gui.scale)

				if pctl.album_repeat_mode:
					self.repeat_button_a.render(ar - round(45 * gui.scale) + icon_x_shift, y - round(2 * gui.scale), rpbc)
					#ddt.rect_a((x + round(4 * gui.scale), y), (round(25 * gui.scale), w), rpbc)
				elif off:
					self.repeat_button_off.render(ar - round(45 * gui.scale) + icon_x_shift, y - round(2 * gui.scale), rpbc)
				else:
					self.repeat_button.render(ar - round(45 * gui.scale) + icon_x_shift, y - round(2 * gui.scale), rpbc)
				#ddt.rect_a((ar - round(25 * gui.scale), y), (round(25 * gui.scale), w), rpbc)
				#ddt.rect_a((ar - w, y), (w, h), rpbc)
				#ddt.rect_a((ar - round(50 * gui.scale), y + h), (round(50 * gui.scale), w), rpbc)

				# ddt.rect_a((x + round(25 * gui.scale), y), (round(25 * gui.scale), w), rpbc, True)
				# ddt.rect_a((x + round(4 * gui.scale), y + round(5 * gui.scale)), (math.floor(46 * gui.scale), w), rpbc, True)
				# ddt.rect_a((x + 50 * gui.scale - w, y), (w, 8 * gui.scale), rpbc, True)
				# ddt.rect_a((x + round(50 * gui.scale) - w, y + w), (w, round(4 * gui.scale)), rpbc, True)

class BottomBarType_ao1:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon         = tauon
		self.ddt           = tauon.ddt
		self.gui           = tauon.gui
		self.inp           = tauon.inp
		self.coll          = tauon.coll
		self.pctl          = tauon.pctl
		self.fonts         = tauon.fonts
		self.prefs         = tauon.prefs
		self.fields        = tauon.fields
		self.colours       = tauon.colours
		self.renderer      = tauon.renderer
		self.window_size   = tauon.window_size
		self.smooth_scroll = tauon.smooth_scroll

		self.mode = 0
		self.seek_time = 0
		self.seek_down = False
		self.seek_hit = False
		self.volume_hit = False
		self.volume_bar_being_dragged = False
		self.control_line_bottom = 35 * self.gui.scale
		self.repeat_click_off = False
		self.random_click_off = False

		self.seek_bar_position = [300 * self.gui.scale, self.window_size[1] - self.gui.panelBY]
		self.seek_bar_size = [self.window_size[0] - (300 * self.gui.scale), 15 * self.gui.scale]
		self.volume_bar_size = [135 * self.gui.scale, 14 * self.gui.scale]
		self.volume_bar_position = [0, 45 * self.gui.scale]

		self.play_button    = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "play.png", True)
		self.forward_button = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "ff.png", True)
		self.back_button    = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "bb.png", True)

		self.scrob_stick = 0

	def update(self) -> None:
		if self.mode == 0:
			self.volume_bar_position[0] = self.window_size[0] - (210 * self.gui.scale)
			self.volume_bar_position[1] = self.window_size[1] - (27 * self.gui.scale)
			self.seek_bar_position[1]   = self.window_size[1] - self.gui.panelBY

			seek_bar_x = 300 * self.gui.scale
			if self.window_size[0] < 600 * self.gui.scale:
				seek_bar_x = 250 * self.gui.scale

			self.seek_bar_size[0] = self.window_size[0] - seek_bar_x
			self.seek_bar_position[0] = seek_bar_x

			# if gui.bb_show_art:
			#     self.seek_bar_position[0] = 300 + gui.panelBY
			#     self.seek_bar_size[0] = window_size[0] - 300 - gui.panelBY

			# self.seek_bar_position[0] = 0
			# self.seek_bar_size[0] = window_size[0]

	def render(self) -> None:

		sdl3.SDL_SetRenderDrawBlendMode(self.renderer, sdl3.SDL_BLENDMODE_NONE)
		self.ddt.rect_a((0, self.window_size[1] - self.gui.panelBY), (self.window_size[0], self.gui.panelBY), self.colours.bottom_panel_colour)
		sdl3.SDL_SetRenderDrawBlendMode(self.renderer, sdl3.SDL_BLENDMODE_BLEND)

		right_offset = 0
		if self.gui.display_time_mode >= 2:
			right_offset = 22 * self.gui.scale

		if self.window_size[0] < 670 * self.gui.scale:
			right_offset -= 90 * self.gui.scale

		# # MINI ALBUM ART
		# if gui.bb_show_art:
		# 	rect = [self.seek_bar_position[0] - gui.panelBY, self.seek_bar_position[1], gui.panelBY, gui.panelBY]
		# 	ddt.rect_r(rect, [255, 255, 255, 8], True)
		# 	if (self.pctl.playing_state in (PlayingState.PLAYING, PlayingState.PAUSED):
		# 		tauon.album_art_gen.display(pctl.track_queue[pctl.queue_step], (rect[0], rect[1]), (rect[2], rect[3]))

		# ddt.rect_r(rect, [255, 255, 255, 20])

		# Volume mouse wheel control -----------------------------------------
		if self.inp.mouse_wheel != 0 and self.inp.mouse_position[1] > self.seek_bar_position[1] + 4 and not coll_point(
			self.inp.mouse_position, self.seek_bar_position + self.seek_bar_size):
			scroll_distance = self.smooth_scroll.scroll("volume bar")
			self.pctl.player_volume += scroll_distance * self.prefs.volume_wheel_increment
			if self.pctl.player_volume < 1:
				self.pctl.player_volume = 0
			elif self.pctl.player_volume > 100:
				self.pctl.player_volume = 100

			self.pctl.player_volume = int(self.pctl.player_volume)
			self.pctl.set_volume()

		# mode menu
		if self.inp.right_click:
			if self.inp.mouse_position[0] > 190 * self.gui.scale and \
					self.inp.mouse_position[1] > self.window_size[1] - self.gui.panelBY and \
					self.inp.mouse_position[0] < self.window_size[0] - 190 * self.gui.scale:
				self.tauon.mode_menu.activate()

		# Volume Bar 2 ------------------------------------------------
		if True:
			x = self.window_size[0] - right_offset - 120 * self.gui.scale
			y = self.window_size[1] - round(21 * self.gui.scale)

			if self.gui.compact_bar:
				x -= 90 * self.gui.scale

			rect = (x - 8 * self.gui.scale, y - 17 * self.gui.scale, 55 * self.gui.scale, 23 * self.gui.scale)
			# ddt.rect(rect, [255,255,255,25])
			if self.coll(rect) and self.inp.mouse_down:
				self.gui.update_on_drag = True

			h_rect = (x - 6 * self.gui.scale, y - 17 * self.gui.scale, 4 * self.gui.scale, 23 * self.gui.scale)
			if self.coll(h_rect) and self.inp.mouse_down:
				self.pctl.player_volume = 0

			step = round(1 * self.gui.scale)
			min_h = round(4 * self.gui.scale)
			spacing = round(5 * self.gui.scale)

			if self.inp.right_click and self.coll((h_rect[0], h_rect[1], h_rect[2] + 50 * self.gui.scale, h_rect[3])):
				if self.inp.right_click:
					if self.pctl.player_volume > 0:
						self.pctl.volume_store = self.pctl.player_volume
						self.pctl.player_volume = 0
					else:
						self.pctl.player_volume = self.pctl.volume_store

					self.pctl.set_volume()

			for bar in range(8):
				h = min_h + bar * step
				rect = (x, y - h, 3 * self.gui.scale, h)
				h_rect = (x - 1 * self.gui.scale, y - 17 * self.gui.scale, 4 * self.gui.scale, 23 * self.gui.scale)

				if self.coll(h_rect) and self.inp.mouse_down:
					self.gui.update_on_drag = True

					if bar == 0:
						self.pctl.player_volume = 5
					if bar == 1:
						self.pctl.player_volume = 10
					if bar == 2:
						self.pctl.player_volume = 20
					if bar == 3:
						self.pctl.player_volume = 30
					if bar == 4:
						self.pctl.player_volume = 45
					if bar == 5:
						self.pctl.player_volume = 55
					if bar == 6:
						self.pctl.player_volume = 70
					if bar == 7:
						self.pctl.player_volume = 100

					self.pctl.set_volume()

				colour = self.colours.mode_button_off

				if bar == 0 and self.pctl.player_volume > 0:
					colour = self.colours.mode_button_active
				elif bar == 1 and self.pctl.player_volume >= 10:
					colour = self.colours.mode_button_active
				elif bar == 2 and self.pctl.player_volume >= 20:
					colour = self.colours.mode_button_active
				elif bar == 3 and self.pctl.player_volume >= 30:
					colour = self.colours.mode_button_active
				elif bar == 4 and self.pctl.player_volume >= 45:
					colour = self.colours.mode_button_active
				elif bar == 5 and self.pctl.player_volume >= 55:
					colour = self.colours.mode_button_active
				elif bar == 6 and self.pctl.player_volume >= 70:
					colour = self.colours.mode_button_active
				elif bar == 7 and self.pctl.player_volume >= 95:
					colour = self.colours.mode_button_active

				self.ddt.rect(rect, colour)
				x += spacing

		# TIME----------------------

		x = self.window_size[0] - 57 * self.gui.scale
		y = self.window_size[1] - 35 * self.gui.scale

		r_start = x - 10 * self.gui.scale
		if self.gui.display_time_mode in (2, 3):
			r_start -= 20 * self.gui.scale
		rect = (r_start, y - 3 * self.gui.scale, 80 * self.gui.scale, 27 * self.gui.scale)
		# ddt.rect_r(rect, [255, 0, 0, 40], True)
		if self.inp.mouse_click and self.coll(rect):
			self.gui.display_time_mode += 1
			if self.gui.display_time_mode > 3:
				self.gui.display_time_mode = 0

		if self.gui.display_time_mode == 0:
			text_time = get_display_time(self.pctl.playing_time)
			self.ddt.text((x + 1 * self.gui.scale, y), text_time, self.colours.time_playing, self.fonts.bottom_panel_time)
		elif self.gui.display_time_mode == 1:
			if self.pctl.playing_state == PlayingState.STOPPED:
				text_time = get_display_time(0)
			else:
				text_time = get_display_time(self.pctl.playing_length - self.pctl.playing_time)
			self.ddt.text((x + 1 * self.gui.scale, y), text_time, self.colours.time_playing, self.fonts.bottom_panel_time)
			self.ddt.text((x - 5 * self.gui.scale, y), "-", self.colours.time_playing, self.fonts.bottom_panel_time)
		elif self.gui.display_time_mode == 2:
			self.colours.time_sub = alpha_blend(ColourRGBA(255, 255, 255, 80), self.colours.bottom_panel_colour)

			x -= 4
			text_time = get_display_time(self.pctl.playing_time)
			self.ddt.text((x - 25 * self.gui.scale, y), text_time, self.colours.time_playing, self.fonts.bottom_panel_time)

			offset1 = 10 * self.gui.scale

			offset2 = offset1 + 7 * self.gui.scale

			self.ddt.text((x + offset1, y), "/", self.colours.time_sub, self.fonts.bottom_panel_time)
			text_time = get_display_time(self.pctl.playing_length)
			if self.pctl.playing_state == PlayingState.STOPPED:
				text_time = get_display_time(0)
			elif self.pctl.playing_state == PlayingState.URL_STREAM:
				text_time = "-- : --"
			self.ddt.text((x + offset2, y), text_time, self.colours.time_sub, self.fonts.bottom_panel_time)

		elif self.gui.display_time_mode == PlayingState.URL_STREAM:
			self.colours.time_sub = alpha_blend(ColourRGBA(255, 255, 255, 80), self.colours.bottom_panel_colour)

			track = self.pctl.playing_object()
			if track and track.index != self.gui.dtm3_index:

				self.gui.dtm3_cum = 0
				self.gui.dtm3_total = 0
				run = True
				collected = []
				for item in self.pctl.default_playlist:
					if self.pctl.master_library[item].parent_folder_path == track.parent_folder_path:
						if item not in collected:
							collected.append(item)
							self.gui.dtm3_total += self.pctl.master_library[item].length
							if item == track.index:
								run = False
							if run:
								self.gui.dtm3_cum += self.pctl.master_library[item].length
				self.gui.dtm3_index = track.index

			x -= 4
			text_time = get_display_time(self.gui.dtm3_cum + self.pctl.playing_time)

			self.ddt.text((x - 25 * self.gui.scale, y), text_time, self.colours.time_playing, self.fonts.bottom_panel_time)

			offset1 = 10 * self.gui.scale
			offset2 = offset1 + 7 * self.gui.scale

			self.ddt.text((x + offset1, y), "/", self.colours.time_sub, self.fonts.bottom_panel_time)
			text_time = get_display_time(self.gui.dtm3_total)
			if self.pctl.playing_state == PlayingState.STOPPED:
				text_time = get_display_time(0)
			elif self.pctl.playing_state == PlayingState.URL_STREAM:
				text_time = "-- : --"
			self.ddt.text((x + offset2, y), text_time, self.colours.time_sub, self.fonts.bottom_panel_time)

		# BUTTONS
		# bottom buttons

		if self.gui.mode == GuiMode.MAIN:
			# PLAY---
			buttons_x_offset = 0
			compact = False
			if self.window_size[0] < 650 * self.gui.scale:
				compact = True

			play_colour = self.colours.media_buttons_off
			pause_colour = self.colours.media_buttons_off
			stop_colour = self.colours.media_buttons_off
			forward_colour = self.colours.media_buttons_off
			back_colour = self.colours.media_buttons_off

			if self.pctl.playing_state == PlayingState.PLAYING:
				play_colour = self.colours.media_buttons_active

			if self.pctl.stop_mode != StopMode.OFF:
				stop_colour = self.colours.media_buttons_active

			if self.pctl.playing_state == PlayingState.PAUSED:
				pause_colour = self.colours.media_buttons_active
				play_colour = self.colours.media_buttons_active
			elif self.pctl.playing_state == PlayingState.URL_STREAM:
				play_colour = self.colours.media_buttons_active
				if self.pctl.record_stream:
					play_colour = ColourRGBA(220, 50, 50, 255)

			if not compact or (compact and self.pctl.playing_state != PlayingState.PAUSED):
				rect = (
				buttons_x_offset + (10 * self.gui.scale), self.window_size[1] - self.control_line_bottom - (13 * self.gui.scale),
				50 * self.gui.scale, 40 * self.gui.scale)
				self.fields.add(rect)
				if self.coll(rect):
					play_colour = self.colours.media_buttons_over
					if self.inp.mouse_click:
						if compact and self.pctl.playing_state == PlayingState.PLAYING:
							self.pctl.pause()
						elif self.pctl.playing_state == PlayingState.PLAYING:
							self.pctl.show_current(highlight=True)
						else:
							self.pctl.play()
						self.inp.mouse_click = False
					self.tauon.tool_tip2.test(33 * self.gui.scale, y - 35 * self.gui.scale, _("Play, RC: Go to playing"))

					if self.inp.right_click:
						self.pctl.show_current(highlight=True)

				self.play_button.render(29 * self.gui.scale, self.window_size[1] - self.control_line_bottom, play_colour)
				# self.ddt.rect_r(rect,[255,0,0,255], True)

			# PAUSE---
			if compact:
				buttons_x_offset = -46 * self.gui.scale

			x = (75 * self.gui.scale) + buttons_x_offset
			y = self.window_size[1] - self.control_line_bottom

			if not compact or (compact and self.pctl.playing_state == PlayingState.PAUSED):

				rect = (x - 15 * self.gui.scale, y - 13 * self.gui.scale, 50 * self.gui.scale, 40 * self.gui.scale)
				self.fields.add(rect)
				if self.coll(rect) and self.pctl.playing_state != PlayingState.URL_STREAM:
					pause_colour = self.colours.media_buttons_over
					if self.inp.mouse_click:
						self.pctl.pause()
					if self.inp.right_click:
						self.pctl.show_current(highlight=True)
					self.tauon.tool_tip2.test(x, y - 35 * self.gui.scale, _("Pause"))

				# self.ddt.rect_r(rect,[255,0,0,255], True)
				self.ddt.rect_a((x, y + 0), (4 * self.gui.scale, 13 * self.gui.scale), pause_colour)
				self.ddt.rect_a((x + 10 * self.gui.scale, y + 0), (4 * self.gui.scale, 13 * self.gui.scale), pause_colour)

			# FORWARD---
			rect = (
				buttons_x_offset + 125 * self.gui.scale,
				self.window_size[1] - self.control_line_bottom - 10 * self.gui.scale, 50 * self.gui.scale, 35 * self.gui.scale)
			self.fields.add(rect)
			if self.coll(rect) and self.pctl.playing_state != PlayingState.URL_STREAM:
				forward_colour = self.colours.media_buttons_over
				if self.inp.mouse_click:
					self.pctl.advance()
					self.gui.tool_tip_lock_off_f = True
				if self.inp.right_click:
					# self.pctl.random_mode ^= True
					self.tauon.toggle_random()
					self.gui.tool_tip_lock_off_f = True
					# if self.window_size[0] < 600 * self.gui.scale:
					# . Shuffle set to on
					self.gui.mode_toast_text = _("Shuffle On")
					if not self.pctl.random_mode:
						# . Shuffle set to off
						self.gui.mode_toast_text = _("Shuffle Off")
					self.tauon.toast_mode_timer.set()
					self.gui.delay_frame(1)
				if self.inp.middle_click:
					self.pctl.advance(rr=True)
					self.gui.tool_tip_lock_off_f = True
				# tool_tip.test(buttons_x_offset + 230 * self.gui.scale + 50 * self.gui.scale, self.window_size[1] - self.control_line_bottom - 20 * self.gui.scale, "Advance")
				# if not self.gui.tool_tip_lock_off_f:
				# 	tauon.tool_tip2.test(x + 45 * self.gui.scale, y - 35 * self.gui.scale, _("Forward, RC: Toggle shuffle, MC: Radio random"))
			else:
				self.gui.tool_tip_lock_off_f = False

			self.forward_button.render(
				buttons_x_offset + 125 * self.gui.scale,
				1 + self.window_size[1] - self.control_line_bottom, forward_colour)

class MiniMode:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon         = tauon
		self.ddt           = tauon.ddt
		self.inp           = tauon.inp
		self.gui           = tauon.gui
		self.coll          = tauon.coll
		self.pctl          = tauon.pctl
		self.prefs         = tauon.prefs
		self.fields        = tauon.fields
		self.colours       = tauon.colours
		self.window_size   = tauon.window_size
		self.album_art_gen = tauon.album_art_gen
		self.smooth_scroll = tauon.smooth_scroll
		self.save_position = None
		self.was_borderless = True
		self.volume_timer = Timer()
		self.volume_timer.force_set(100)

		self.left_slide  = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "left-slide.png", True)
		self.right_slide = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "right-slide.png", True)
		self.repeat      = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "repeat-mini-mode.png", True)
		self.shuffle     = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "shuffle-mini-mode.png", True)

		self.shuffle_fade_timer = Timer(100)
		self.repeat_fade_timer = Timer(100)

	def render(self) -> None:
		# We only set seek_r and seek_w if track is currently on, but use it anyway later, so make sure it exists
		if "seek_r" not in locals():
			seek_r = [0, 0, 0, 0]
			seek_w = 0

		w = self.window_size[0]
		h = self.window_size[1]

		y1 = w
		if w == h:
			y1 -= 79 * self.gui.scale

		h1 = h - y1

		# Draw background
		bg = self.colours.mini_mode_background
		# bg = ColourRGBA(250, 250, 250, 255)

		self.ddt.rect((0, 0, w, h), bg)
		self.ddt.text_background_colour = bg

		detect_mouse_rect = (3, 3, w - 6, h - 6)
		self.fields.add(detect_mouse_rect)
		mouse_in = self.coll(detect_mouse_rect)

		# Play / Pause when right clicking below art
		if self.inp.right_click:  # and self.inp.mouse_position[1] > y1:
			self.pctl.play_pause()

		# Volume change on scroll
		if self.inp.mouse_wheel != 0:
			self.volume_timer.set()
			scroll_distance = self.smooth_scroll.scroll("volume bar")
			self.pctl.player_volume += scroll_distance * self.prefs.volume_wheel_increment * 3
			if self.pctl.player_volume < 1:
				self.pctl.player_volume = 0
			elif self.pctl.player_volume > 100:
				self.pctl.player_volume = 100

			self.pctl.player_volume = int(self.pctl.player_volume)
			self.pctl.set_volume()

		track = self.pctl.playing_object()

		control_hit_area = (3, y1 - 15 * self.gui.scale, w - 6, h1 - 3 + 15 * self.gui.scale)
		mouse_in_area = self.coll(control_hit_area)
		self.fields.add(control_hit_area)

		self.ddt.rect((0, 0, w, w), ColourRGBA(0, 0, 0, 45))
		if track is not None:
			# Render album art
			self.album_art_gen.display(track, (0, 0), (w, w))

			line1c = self.colours.mini_mode_text_1
			line2c = self.colours.mini_mode_text_2

			if h == w and mouse_in_area:
				# self.ddt.pretty_rect = (0, 260 * self.gui.scale, w, 100 * self.gui.scale)
				self.ddt.rect((0, y1, w, h1), ColourRGBA(0, 0, 0, 220))
				line1c = ColourRGBA(255, 255, 255, 240)
				line2c = ColourRGBA(255, 255, 255, 77)

			# Double click bottom text to return to full window
			text_hit_area = (60 * self.gui.scale, y1 + 4, 230 * self.gui.scale, 50 * self.gui.scale)

			if self.coll(text_hit_area) and self.inp.mouse_click:
				if self.tauon.d_click_timer.get() < 0.3:
					self.tauon.restore_full_mode()
					self.gui.update += 1
					return
				self.tauon.d_click_timer.set()

			# Draw title texts
			line1 = track.artist
			line2 = track.title

			# Calculate seek bar position
			seek_w = int(w * 0.70)

			seek_r = [(w - seek_w) // 2, y1 + 58 * self.gui.scale, seek_w, 6 * self.gui.scale]
			seek_marker_rect = tuple(seek_r)
			seek_r_hit = [seek_r[0], seek_r[1] - 4 * self.gui.scale, seek_r[2], seek_r[3] + 8 * self.gui.scale]

			if w != h or mouse_in_area:

				if not line1 and not line2:
					self.ddt.text((w // 2, y1 + 18 * self.gui.scale, 2), track.filename, line1c, 214, self.window_size[0] - 30 * self.gui.scale)
				else:
					self.ddt.text((w // 2, y1 + 10 * self.gui.scale, 2), line1, line2c, 514, self.window_size[0] - 30 * self.gui.scale)
					self.ddt.text((w // 2, y1 + 31 * self.gui.scale, 2), line2, line1c, 414, self.window_size[0] - 30 * self.gui.scale)

				# Test click to seek
				if self.inp.mouse_up and self.coll(seek_r_hit):
					click_x = self.inp.mouse_position[0]
					click_x = min(click_x, seek_r[0] + seek_r[2])
					click_x = max(click_x, seek_r[0])
					click_x -= seek_r[0]

					if click_x < 6 * self.gui.scale:
						click_x = 0
					seek = click_x / seek_r[2]

					self.pctl.seek_decimal(seek)

				# Draw progress bar background
				self.ddt.rect(seek_r, ColourRGBA(255, 255, 255, 32))

				# Calculate and draw bar foreground
				progress_w = 0
				if self.pctl.playing_length > 1:
					progress_w = self.pctl.playing_time * seek_w / self.pctl.playing_length
				seek_colour = ColourRGBA(210, 210, 210, 255)
				if self.gui.theme_name == "Carbon":
					seek_colour = self.colours.bottom_panel_colour

				if self.pctl.playing_state != PlayingState.PLAYING:
					seek_colour = ColourRGBA(210, 40, 100, 255)

				seek_r[2] = progress_w

				if self.volume_timer.get() < 0.9:
					progress_w = self.pctl.player_volume * (seek_w - (4 * self.gui.scale)) / 100
					self.gui.update += 1
					seek_colour = ColourRGBA(210, 210, 210, 255)
					seek_r[2] = progress_w
					seek_r[0] += 2 * self.gui.scale
					seek_r[1] += 2 * self.gui.scale
					seek_r[3] -= 4 * self.gui.scale

				self.ddt.rect(seek_r, seek_colour)
				self.tauon.draw_ab_repeat_markers(*seek_marker_rect)

		left_area = (1, y1, seek_r[0] - 1, 45 * self.gui.scale)
		right_area = (seek_r[0] + seek_w, y1, seek_r[0] - 2, 45 * self.gui.scale)

		self.fields.add(left_area)
		self.fields.add(right_area)

		hint = 0
		if self.coll(control_hit_area):
			hint = 30
		if self.coll(left_area):
			hint = 240
		if hint and not self.prefs.shuffle_lock:
			self.left_slide.render(16 * self.gui.scale, y1 + 17 * self.gui.scale, ColourRGBA(255, 255, 255, hint))

		hint = 0
		if self.coll(control_hit_area):
			hint = 30
		if self.coll(right_area):
			hint = 240
		if hint:
			self.right_slide.render(
				self.window_size[0] - self.right_slide.w - 16 * self.gui.scale, y1 + 17 * self.gui.scale,
				ColourRGBA(255, 255, 255, hint))

		# Shuffle

		shuffle_area = (seek_r[0] + seek_w, seek_r[1] - 10 * self.gui.scale, 50 * self.gui.scale, 30 * self.gui.scale)
		# self.fields.add(shuffle_area)
		# self.ddt.rect_r(shuffle_area, [255, 0, 0, 100], True)

		if self.coll(control_hit_area) and not self.prefs.shuffle_lock:
			colour = ColourRGBA(255, 255, 255, 20)
			if self.inp.mouse_click and self.coll(shuffle_area):
				# self.pctl.random_mode ^= True
				self.tauon.toggle_random()
			if self.pctl.random_mode:
				colour = ColourRGBA(255, 255, 255, 190)

			sx = seek_r[0] + seek_w + 12 * self.gui.scale
			sy = seek_r[1] - 2 * self.gui.scale
			self.shuffle.render(sx, sy, colour)


			# sx = seek_r[0] + seek_w + 8 * self.gui.scale
			# sy = seek_r[1] - 1 * self.gui.scale
			# self.ddt.rect_a((sx, sy), (14 * self.gui.scale, 2 * self.gui.scale), colour)
			# sy += 4 * self.gui.scale
			# self.ddt.rect_a((sx, sy), (28 * self.gui.scale, 2 * self.gui.scale), colour)

		shuffle_area = (seek_r[0] - 41 * self.gui.scale, seek_r[1] - 10 * self.gui.scale, 40 * self.gui.scale, 30 * self.gui.scale)
		if self.coll(control_hit_area) and not self.prefs.shuffle_lock:
			colour = ColourRGBA(255, 255, 255, 20)
			if self.inp.mouse_click and self.coll(shuffle_area):
				self.tauon.toggle_repeat()
			if self.pctl.repeat_mode:
				colour = ColourRGBA(255, 255, 255, 190)


			sx = seek_r[0] - 36 * self.gui.scale
			sy = seek_r[1] - 1 * self.gui.scale
			self.repeat.render(sx, sy, colour)


			# sx = seek_r[0] - 39 * self.gui.scale
			# sy = seek_r[1] - 1 * self.gui.scale

			#tw = 2 * self.gui.scale
			# self.ddt.rect_a((sx + 15 * self.gui.scale, sy), (13 * self.gui.scale, tw), colour)
			# self.ddt.rect_a((sx + 4 * self.gui.scale, sy + 4 * self.gui.scale), (25 * self.gui.scale, tw), colour)
			# self.ddt.rect_a((sx + 30 * self.gui.scale - tw, sy), (tw, 6 * self.gui.scale), colour)


		# Forward and back clicking
		if self.inp.mouse_click:
			if self.coll(left_area) and not self.prefs.shuffle_lock:
				self.pctl.back()
			if self.coll(right_area):
				self.pctl.advance()

		# Show exit/min buttons when mouse over
		tool_rect = [self.window_size[0] - 110 * self.gui.scale, 2, 108 * self.gui.scale, 45 * self.gui.scale]
		if self.prefs.left_window_control:
			tool_rect[0] = 0
		self.fields.add(tool_rect)
		if self.coll(tool_rect):
			self.tauon.draw_window_tools()

		if w != h:
			self.ddt.rect_s((1, 1, w - 2, h - 2), self.colours.mini_mode_border, 1 * self.gui.scale)
			if self.gui.scale == 2:
				self.ddt.rect_s((2, 2, w - 4, h - 4), self.colours.mini_mode_border, 1 * self.gui.scale)

class MiniMode2:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon         = tauon
		self.ddt           = tauon.ddt
		self.inp           = tauon.inp
		self.gui           = tauon.gui
		self.coll          = tauon.coll
		self.pctl          = tauon.pctl
		self.prefs         = tauon.prefs
		self.fields        = tauon.fields
		self.colours       = tauon.colours
		self.window_size   = tauon.window_size
		self.album_art_gen = tauon.album_art_gen
		self.smooth_scroll = tauon.smooth_scroll
		self.save_position = None
		self.was_borderless = True
		self.volume_timer = Timer()
		self.volume_timer.force_set(100)

		self.left_slide  = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "left-slide.png", True)
		self.right_slide = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "right-slide.png", True)

	def render(self) -> None:
		w = self.window_size[0]
		h = self.window_size[1]

		x1 = h

		# Draw background
		self.ddt.rect((0, 0, w, h), self.colours.mini_mode_background)
		self.ddt.text_background_colour = self.colours.mini_mode_background

		detect_mouse_rect = (2, 2, w - 4, h - 4)
		self.fields.add(detect_mouse_rect)
		mouse_in = self.coll(detect_mouse_rect)

		# Play / Pause when right clicking below art
		if self.inp.right_click:  # and self.inp.mouse_position[1] > y1:
			self.pctl.play_pause()

		# Volume change on scroll
		if self.inp.mouse_wheel != 0:
			self.volume_timer.set()
			scroll_distance = self.smooth_scroll.scroll("volume bar")
			self.pctl.player_volume += scroll_distance * self.prefs.volume_wheel_increment * 3
			if self.pctl.player_volume < 1:
				self.pctl.player_volume = 0
			elif self.pctl.player_volume > 100:
				self.pctl.player_volume = 100

			self.pctl.player_volume = int(self.pctl.player_volume)
			self.pctl.set_volume()

		track = self.pctl.playing_object()

		if track is not None:
			# Render album art
			self.album_art_gen.display(track, (0, 0), (h, h))

			text_hit_area = (x1, 0, w, h)

			if self.coll(text_hit_area) and self.inp.mouse_click:
				if self.tauon.d_click_timer.get() < 0.3:
					self.tauon.restore_full_mode()
					self.gui.update += 1
					return
				self.tauon.d_click_timer.set()

			# Draw title texts
			line1 = track.artist
			line2 = track.title

			if not line1 and not line2:

				self.ddt.text(
					(x1 + 15 * self.gui.scale, 44 * self.gui.scale), track.filename, self.colours.grey(150), 315,
					self.window_size[0] - x1 - 30 * self.gui.scale)
			else:
				# if self.ddt.get_text_w(line2, 215) > window_size[0] - x1 - 30 * self.gui.scale:
				#     self.ddt.text((x1 + 15 * self.gui.scale, 19 * self.gui.scale), line2, self.colours.grey(249), 413,
				#              window_size[0] - x1 - 35 * self.gui.scale)
				#
				#     self.ddt.text((x1 + 15 * self.gui.scale, 43 * self.gui.scale), line1, self.colours.grey(110), 513,
				#              window_size[0] - x1 - 35 * self.gui.scale)
				# else:

				self.ddt.text(
					(x1 + 15 * self.gui.scale, 18 * self.gui.scale), line2, self.colours.grey(249), 514,
					self.window_size[0] - x1 - 30 * self.gui.scale)

				self.ddt.text(
					(x1 + 15 * self.gui.scale, 43 * self.gui.scale), line1, self.colours.grey(110), 514,
					self.window_size[0] - x1 - 30 * self.gui.scale)

		# Show exit/min buttons when mouse over
		tool_rect = [self.window_size[0] - 110 * self.gui.scale, 2, 108 * self.gui.scale, 45 * self.gui.scale]
		if self.prefs.left_window_control:
			tool_rect[0] = 0
		self.fields.add(tool_rect)
		if self.coll(tool_rect):
			self.tauon.draw_window_tools()

		# Seek bar
		bg_rect = (h, h - round(5 * self.gui.scale), w - h, round(5 * self.gui.scale))
		self.ddt.rect(bg_rect, ColourRGBA(255, 255, 255, 18))

		if self.pctl.playing_state != PlayingState.STOPPED:
			hit_rect = h - 5 * self.gui.scale, h - 12 * self.gui.scale, w - h + 5 * self.gui.scale, 13 * self.gui.scale

			if self.coll(hit_rect) and self.inp.mouse_up:
				p = (self.inp.mouse_position[0] - h) / (w - h)

				if p < 0 or self.inp.mouse_position[0] - h < 6 * self.gui.scale:
					self.pctl.seek_time(0)
				elif p > .96:
					self.pctl.advance()
				else:
					self.pctl.seek_decimal(p)

			if self.pctl.playing_length:
				seek_rect = (
					h, h - round(5 * self.gui.scale), round((w - h) * (self.pctl.playing_time / self.pctl.playing_length)),
					round(5 * self.gui.scale))
				colour = self.colours.artist_text
				if self.gui.theme_name == "Carbon":
					colour = self.colours.bottom_panel_colour
				if self.pctl.playing_state != PlayingState.PLAYING:
					colour = ColourRGBA(210, 40, 100, 255)
				self.ddt.rect(seek_rect, colour)

		self.tauon.draw_ab_repeat_markers(*bg_rect)

class MiniMode3:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon         = tauon
		self.ddt           = tauon.ddt
		self.inp           = tauon.inp
		self.gui           = tauon.gui
		self.coll          = tauon.coll
		self.pctl          = tauon.pctl
		self.prefs         = tauon.prefs
		self.fields        = tauon.fields
		self.colours       = tauon.colours
		self.window_size   = tauon.window_size
		self.album_art_gen = tauon.album_art_gen
		self.smooth_scroll = tauon.smooth_scroll
		self.save_position = None
		self.was_borderless = True
		self.volume_timer = Timer()
		self.volume_timer.force_set(100)

		self.left_slide  = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "left-slide.png", True)
		self.right_slide = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "right-slide.png", True)

		self.shuffle_fade_timer = Timer(100)
		self.repeat_fade_timer = Timer(100)

	def render(self) -> None:
		# We only set seek_r and seek_w if track is currently on, but use it anyway later, so make sure it exists
		if "seek_r" not in locals():
			seek_r = [0, 0, 0, 0]
			seek_w = 0
			volume_r = [0, 0, 0, 0]
			volume_w = 0

		w = self.window_size[0]
		h = self.window_size[1]

		y1 = w #+ 10 * gui.scale
		# if w == h:
		#     y1 -= 79 * gui.scale

		h1 = h - y1

		track = self.pctl.playing_object()
		wid = (w // 2) + round(60 * self.gui.scale)
		ins = (self.window_size[0] - wid) / 2
		art_rect = sdl3.SDL_FRect(round(ins), round(ins), round(wid), round(wid))
		self.tauon.style_overlay.hole_punches.clear()
		if track is not None:
			self.tauon.style_overlay.hole_punches.append(art_rect)

		# Draw background
		bg = self.colours.mini_mode_background
		# bg = [250, 250, 250, 255]

		self.ddt.rect((0, 0, w, h), bg)

		self.tauon.style_overlay.display()

		transit = False
		#self.ddt.text_background_colour = list(gui.center_blur_pixel) + [255,] #bg
		if self.tauon.style_overlay.fade_on_timer.get() < 0.4 or self.tauon.style_overlay.stage != 2:
			self.ddt.alpha_bg = True
			transit = True

		detect_mouse_rect = (3, 3, w - 6, h - 6)
		self.fields.add(detect_mouse_rect)
		mouse_in = self.coll(detect_mouse_rect)

		# Play / Pause when right clicking below art
		if self.inp.right_click:  # and self.inp.mouse_position[1] > y1:
			self.pctl.play_pause()

		# Volume change on scroll
		if self.inp.mouse_wheel != 0:
			self.volume_timer.set()
			scroll_distance = self.smooth_scroll.scroll("volume bar")
			self.pctl.player_volume += scroll_distance * self.prefs.volume_wheel_increment * 3
			if self.pctl.player_volume < 1:
				self.pctl.player_volume = 0
			elif self.pctl.player_volume > 100:
				self.pctl.player_volume = 100

			self.pctl.player_volume = int(self.pctl.player_volume)
			self.pctl.set_volume()

		control_hit_area = (3, y1 - 15 * self.gui.scale, w - 6, h1 - 3 + 15 * self.gui.scale)
		mouse_in_area = self.coll(control_hit_area)
		self.fields.add(control_hit_area)

		#self.ddt.rect((0, 0, w, w), (0, 0, 0, 45))
		if track is not None:

			# Render album art

			off = round(4 * self.gui.scale)

			self.tauon.drop_shadow.render(ins + off, ins + off, wid + off * 2, wid + off * 2)
			self.ddt.rect((ins + 1, ins + 1, wid - 1, wid - 1), ColourRGBA(20, 20, 20, 255))
			self.album_art_gen.display(track, (ins, ins), (wid, wid))

			line1c = ColourRGBA(255, 255, 255, 255) #self.colours.mini_mode_text_1
			line2c = ColourRGBA(255, 255, 255, 255) #self.colours.mini_mode_text_2

			# if h == w and mouse_in_area:
			# 	# self.ddt.pretty_rect = (0, 260 * self.gui.scale, w, 100 * self.gui.scale)
			# 	self.ddt.rect((0, y1, w, h1), [0, 0, 0, 220])
			# 	line1c = [255, 255, 255, 240]
			# 	line2c = [255, 255, 255, 77]

			# Double click bottom text to return to full window
			text_hit_area = (60 * self.gui.scale, y1 + 4, 230 * self.gui.scale, 50 * self.gui.scale)

			if self.coll(text_hit_area) and self.inp.mouse_click:
				if self.tauon.d_click_timer.get() < 0.3:
					self.tauon.restore_full_mode()
					self.gui.update += 1
					return
				self.tauon.d_click_timer.set()

			# Draw title texts
			line1 = track.artist
			line2 = track.title
			key = None
			if not line1 and not line2:
				if not self.ddt.alpha_bg:
					key = (track.filename, 214, self.tauon.style_overlay.current_track_id)
				self.ddt.text(
					(w // 2, y1 + 18 * self.gui.scale, 2), track.filename, line1c, 214,
					self.window_size[0] - 30 * self.gui.scale, real_bg=not transit, key=key)
			else:
				if not self.ddt.alpha_bg:
					key = (line1, 515, self.tauon.style_overlay.current_track_id)
				self.ddt.text(
					(w // 2, y1 + 5 * self.gui.scale, 2), line1, line2c, 515,
					self.window_size[0] - 30 * self.gui.scale, real_bg=not transit, key=key)
				if not self.ddt.alpha_bg:
					key = (line2, 415, self.tauon.style_overlay.current_track_id)
				self.ddt.text(
					(w // 2, y1 + 31 * self.gui.scale, 2), line2, line1c, 415,
					self.window_size[0] - 30 * self.gui.scale, real_bg=not transit, key=key)

			y1 += round(10 * self.gui.scale)

			# Calculate seek bar position
			seek_w = int(w * 0.80)

			seek_r = [(w - seek_w) // 2, y1 + 58 * self.gui.scale, seek_w, 9 * self.gui.scale]
			seek_marker_rect = tuple(seek_r)
			seek_r_hit = [seek_r[0], seek_r[1] - 5 * self.gui.scale, seek_r[2], seek_r[3] + 12 * self.gui.scale]

			if w != h or mouse_in_area:
				# Test click to seek
				if self.inp.mouse_up and self.coll(seek_r_hit):
					click_x = self.inp.mouse_position[0]
					click_x = min(click_x, seek_r[0] + seek_r[2])
					click_x = max(click_x, seek_r[0])
					click_x -= seek_r[0]

					if click_x < 6 * self.gui.scale:
						click_x = 0
					seek = click_x / seek_r[2]

					self.pctl.seek_decimal(seek)

				# Draw progress bar background
				self.ddt.rect(seek_r, ColourRGBA(255, 255, 255, 32))

				# Calculate and draw bar foreground
				progress_w = 0
				if self.pctl.playing_length > 1:
					progress_w = self.pctl.playing_time * seek_w / self.pctl.playing_length
				seek_colour = ColourRGBA(210, 210, 210, 255)
				if self.gui.theme_name == "Carbon":
					seek_colour = self.colours.bottom_panel_colour

				if self.pctl.playing_state != PlayingState.PLAYING:
					seek_colour = ColourRGBA(210, 40, 100, 255)

				seek_r[2] = progress_w

			self.ddt.rect(seek_r, seek_colour)
			self.tauon.draw_ab_repeat_markers(*seek_marker_rect)

			volume_w = int(w * 0.50)
			volume_r = [(w - volume_w) // 2, y1 + 80 * self.gui.scale, volume_w, 6 * self.gui.scale]
			volume_r_hit = [volume_r[0], volume_r[1] - 5 * self.gui.scale, volume_r[2], volume_r[3] + 10 * self.gui.scale]

			# Test click to volume
			if (self.inp.mouse_up or self.inp.mouse_down) and self.coll(volume_r_hit):
				self.gui.update_on_drag = True
				click_x = self.inp.mouse_position[0]
				click_x = min(click_x, volume_r[0] + volume_r[2])
				click_x = max(click_x, volume_r[0])
				click_x -= volume_r[0]

				if click_x < 6 * self.gui.scale:
					click_x = 0
				volume = click_x / volume_r[2]

				self.pctl.player_volume = int(volume * 100)
				self.pctl.set_volume()

			self.ddt.rect(volume_r, ColourRGBA(255, 255, 255, 32))

			#if self.volume_timer.get() < 0.9:
			progress_w = self.pctl.player_volume * (volume_w - (4 * self.gui.scale)) / 100
			volume_colour = ColourRGBA(210, 210, 210, 255)
			volume_r[2] = progress_w
			volume_r[0] += 2 * self.gui.scale
			volume_r[1] += 2 * self.gui.scale
			volume_r[3] -= 4 * self.gui.scale

			self.ddt.rect(volume_r, volume_colour)


		left_area = (1, y1, volume_r[0] - 1, 45 * self.gui.scale)
		right_area = (volume_r[0] + volume_w, y1, volume_r[0] - 2, 45 * self.gui.scale)

		self.fields.add(left_area)
		self.fields.add(right_area)

		hint = 0
		if True: #self.coll(control_hit_area):
			hint = 30
		if self.coll(left_area):
			hint = 240
		if hint and not self.prefs.shuffle_lock:
			self.left_slide.render(16 * self.gui.scale, y1 + 10 * self.gui.scale, ColourRGBA(255, 255, 255, hint))

		hint = 0
		if True: #self.coll(control_hit_area):
			hint = 30
		if self.coll(right_area):
			hint = 240
		if hint:
			self.right_slide.render(
				self.window_size[0] - self.right_slide.w - 16 * self.gui.scale, y1 + 10 * self.gui.scale, ColourRGBA(255, 255, 255, hint))

		# Shuffle
		shuffle_area = (volume_r[0] + volume_w, volume_r[1] - 10 * self.gui.scale, 50 * self.gui.scale, 30 * self.gui.scale)
		# self.fields.add(shuffle_area)
		# self.ddt.rect_r(shuffle_area, [255, 0, 0, 100], True)

		if True: #self.coll(control_hit_area) and not self.prefs.shuffle_lock:
			colour = ColourRGBA(255, 255, 255, 20)
			if self.inp.mouse_click and self.coll(shuffle_area):
				# self.pctl.random_mode ^= True
				self.tauon.toggle_random()
			if self.pctl.random_mode:
				colour = ColourRGBA(255, 255, 255, 190)

			sx = volume_r[0] + volume_w + 12 * self.gui.scale
			sy = volume_r[1] - 3 * self.gui.scale
			self.tauon.mini_mode.shuffle.render(sx, sy, colour)

			#
			# sx = volume_r[0] + volume_w + 8 * self.gui.scale
			# sy = volume_r[1] - 1 * self.gui.scale
			# self.ddt.rect_a((sx, sy), (14 * self.gui.scale, 2 * self.gui.scale), colour)
			# sy += 4 * self.gui.scale
			# self.ddt.rect_a((sx, sy), (28 * self.gui.scale, 2 * self.gui.scale), colour)

		shuffle_area = (volume_r[0] - 41 * self.gui.scale, volume_r[1] - 10 * self.gui.scale, 40 * self.gui.scale, 30 * self.gui.scale)
		if True: #self.coll(control_hit_area) and not self.prefs.shuffle_lock:
			colour = ColourRGBA(255, 255, 255, 20)
			if self.inp.mouse_click and self.coll(shuffle_area):
				self.tauon.toggle_repeat()
			if self.pctl.repeat_mode:
				colour = ColourRGBA(255, 255, 255, 190)

			sx = volume_r[0] - 39 * self.gui.scale
			sy = volume_r[1] - 1 * self.gui.scale
			self.tauon.mini_mode.repeat.render(sx, sy, colour)

			# sx = volume_r[0] - 39 * self.gui.scale
			# sy = volume_r[1] - 1 * self.gui.scale
			#
			# tw = 2 * self.gui.scale
			# self.ddt.rect_a((sx + 15 * self.gui.scale, sy), (13 * self.gui.scale, tw), colour)
			# self.ddt.rect_a((sx + 4 * self.gui.scale, sy + 4 * self.gui.scale), (25 * self.gui.scale, tw), colour)
			# self.ddt.rect_a((sx + 30 * self.gui.scale - tw, sy), (tw, 6 * self.gui.scale), colour)

		# Forward and back clicking
		if self.inp.mouse_click:
			if self.coll(left_area) and not self.prefs.shuffle_lock:
				self.pctl.back()
			if self.coll(right_area):
				self.pctl.advance()

		self.tauon.search_over.render()


		# Show exit/min buttons when mouse over
		tool_rect = [self.window_size[0] - 110 * self.gui.scale, 2, 108 * self.gui.scale, 45 * self.gui.scale]
		if self.prefs.left_window_control:
			tool_rect[0] = 0
		self.fields.add(tool_rect)
		if self.coll(tool_rect):
			self.tauon.draw_window_tools()

		# if w != h:
		# 	self.ddt.rect_s((1, 1, w - 2, h - 2), self.colours.mini_mode_border, 1 * self.gui.scale)
		# 	if self.gui.scale == 2:
		# 		self.ddt.rect_s((2, 2, w - 4, h - 4), self.colours.mini_mode_border, 1 * self.gui.scale)
		self.ddt.alpha_bg = False

class MiniModeSignal:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon         = tauon
		self.ddt           = tauon.ddt
		self.inp           = tauon.inp
		self.gui           = tauon.gui
		self.coll          = tauon.coll
		self.pctl          = tauon.pctl
		self.prefs         = tauon.prefs
		self.fields        = tauon.fields
		self.colours       = tauon.colours
		self.window_size   = tauon.window_size
		self.album_art_gen = tauon.album_art_gen
		self.smooth_scroll = tauon.smooth_scroll
		self.save_position = None
		self.was_borderless = True
		self.volume_timer = Timer()
		self.volume_timer.force_set(100)
		self.visual_timer = Timer()
		self.motion_timer = Timer()
		self.fps = FPSCounter(window_size=20, min_update_interval=0.12, max_frame_time=0.5)
		self.visual_levels = [0.0] * 18
		self.focus_position = 0.0
		self.focus_target = 0.0
		self.focus_ready = False
		self.light_theme = False
		self.pending_track_jump: tuple[int, int] | None = None
		self.pending_jump_frames = 0

	def _cut_panel(
		self,
		rect: tuple[int, int, int, int],
		fill: ColourRGBA,
		border: ColourRGBA,
		cut: int,
		accent: ColourRGBA | None = None,
	) -> None:
		x, y, w, h = (round(v) for v in rect)
		cut = max(6, min(cut, max(6, w // 4), max(6, h // 4)))
		self.ddt.rect((x, y, w - cut, h), fill)
		self.ddt.rect((x, y + cut, w, h - cut), fill)
		for offset in range(cut):
			xx = x + w - cut + offset
			self.ddt.line(xx, y + offset, xx, y + cut, fill)
		self.ddt.line(x, y, x + w - cut, y, border)
		self.ddt.line(x + w - cut, y, x + w, y + cut, border)
		self.ddt.line(x + w, y + cut, x + w, y + h, border)
		self.ddt.line(x + w, y + h, x, y + h, border)
		self.ddt.line(x, y + h, x, y, border)
		if accent is not None:
			bar_w = min(round(92 * self.gui.scale), max(24, w - cut - 16))
			self.ddt.rect((x + round(10 * self.gui.scale), y + round(10 * self.gui.scale), bar_w, round(3 * self.gui.scale)), accent)

	def _corner_marks(self, rect: tuple[int, int, int, int], colour: ColourRGBA, size: int) -> None:
		x, y, w, h = (round(v) for v in rect)
		size = max(6, size)
		self.ddt.line(x, y + size, x, y, colour)
		self.ddt.line(x, y, x + size, y, colour)
		self.ddt.line(x + w - size, y, x + w, y, colour)
		self.ddt.line(x + w, y, x + w, y + size, colour)
		self.ddt.line(x, y + h - size, x, y + h, colour)
		self.ddt.line(x, y + h, x + size, y + h, colour)
		self.ddt.line(x + w - size, y + h, x + w, y + h, colour)
		self.ddt.line(x + w, y + h - size, x + w, y + h, colour)

	def _grid(self, rect: tuple[int, int, int, int], colour: ColourRGBA, step: int) -> None:
		x, y, w, h = (round(v) for v in rect)
		step = max(8, step)
		for xx in range(x + step, x + w, step):
			self.ddt.line(xx, y, xx, y + h, colour)
		for yy in range(y + step, y + h, step):
			self.ddt.line(x, yy, x + w, yy, colour)

	def _spectrum_targets(self) -> list[float]:
		source = self.gui.spec if self.gui.spec else []
		band_count = len(self.visual_levels)
		if source:
			targets = []
			source_len = len(source)
			# Keep a light lower/mid emphasis so the visualiser stays lively
			# while remaining close to the original full-range mapping.
			usable_len = max(1, round(source_len * 0.945))
			for i in range(band_count):
				left = (i / band_count) ** 1.175
				right = ((i + 1) / band_count) ** 1.175
				start = min(source_len - 1, int(left * usable_len))
				end = max(start + 1, int(right * usable_len))
				end = min(source_len, end + 1)
				segment = source[start:end]
				value = max(segment) if segment else 0
				gain = 1.0 + (1.0 - min(1.0, i / max(1, band_count - 1))) * 0.055
				targets.append(max(0.0, min((value * gain) / 20, 1.0)))
			return targets
		t = time.time() * 1.8
		return [0.08 + ((math.sin(t + i * 0.45) + 1) * 0.08) for i in range(band_count)]

	def _update_visual_levels(self) -> list[float]:
		dt = min(self.visual_timer.hit(), 0.08)
		targets = self._spectrum_targets()
		for i, target in enumerate(targets):
			current = self.visual_levels[i]
			if target > current:
				current += (target - current) * min(1.0, dt * 12)
			else:
				current = max(target, current - dt * 1.35)
			self.visual_levels[i] = current
		return self.visual_levels

	def _current_playlist_position(self) -> int:
		playlist = self.pctl.default_playlist
		if not playlist:
			return -1
		if 0 <= self.pctl.playlist_playing_position < len(playlist):
			return self.pctl.playlist_playing_position
		track = self.pctl.playing_object()
		if track is not None:
			try:
				return playlist.index(track.index)
			except ValueError:
				pass
		if 0 <= self.pctl.selected_in_playlist < len(playlist):
			return self.pctl.selected_in_playlist
		return 0

	def _update_focus_position(self, current_index: int) -> None:
		if current_index < 0:
			return
		self.focus_target = float(current_index)
		if not self.focus_ready:
			self.focus_position = float(current_index)
			self.focus_ready = True
			self.motion_timer.force_set(0)
			return
		dt = min(self.motion_timer.hit(), 0.08)
		delta = self.focus_target - self.focus_position
		step = dt * 8.0
		if abs(delta) <= step:
			self.focus_position = self.focus_target
		else:
			self.focus_position += math.copysign(step, delta)

	def _track_title(self, track: TrackClass) -> str:
		return track.title or track.filename or "Unknown track"

	def render(self) -> None:
		self.fps.tick()
		w = self.window_size[0]
		h = self.window_size[1]
		scale = self.gui.scale

		clear_bg = ColourRGBA(0, 0, 0, 0)
		if self.light_theme:
			shell_fill = ColourRGBA(254, 254, 254, 255)
			shell_border = ColourRGBA(4, 4, 6, 255)
			art_fill = ColourRGBA(246, 246, 243, 252)
			text_main = ColourRGBA(0, 0, 0, 255)
			text_dim = ColourRGBA(34, 34, 38, 255)
			text_faint = ColourRGBA(0, 0, 0, 112)
			track_text = ColourRGBA(62, 62, 66, 255)
			primary = ColourRGBA(0, 0, 0, 255)
			secondary = ColourRGBA(72, 72, 76, 255)
			grid_line = ColourRGBA(0, 0, 0, 28)
			point_marker = ColourRGBA(0, 0, 0, 200)
		else:
			shell_fill = ColourRGBA(16, 16, 18, 236)
			shell_border = ColourRGBA(96, 96, 106, 255)
			art_fill = ColourRGBA(22, 22, 26, 244)
			text_main = ColourRGBA(236, 236, 240, 255)
			text_dim = ColourRGBA(154, 154, 164, 255)
			text_faint = ColourRGBA(255, 255, 255, 30)
			track_text = ColourRGBA(154, 154, 164, 255)
			primary = ColourRGBA(235, 72, 170, 255)
			secondary = ColourRGBA(64, 232, 224, 255)
			grid_line = ColourRGBA(255, 255, 255, 24)
			point_marker = ColourRGBA(255, 255, 255, 180)

		self.ddt.alpha_bg = True
		self.ddt.clear_rect((0, 0, w, h))
		self.ddt.text_background_colour = clear_bg

		frame = round(14 * scale)
		box_y = round(14 * scale)
		box_side = min(w - frame * 2, h - round(30 * scale))
		box_rect = (frame, box_y, w - frame * 2, box_side)
		seek_bar = [
			box_rect[0] + round(18 * scale),
			box_rect[1] + box_rect[3] - round(18 * scale),
			box_rect[2] - round(36 * scale),
			max(4, round(5 * scale)),
		]
		seek_hit = [
			seek_bar[0],
			seek_bar[1] - round(8 * scale),
			seek_bar[2],
			seek_bar[3] + round(16 * scale),
		]

		self._cut_panel(box_rect, shell_fill, shell_border, round(16 * scale), accent=primary)
		grid_rect = (box_rect[0] + round(10 * scale), box_rect[1] + round(10 * scale), box_rect[2] - round(20 * scale), box_rect[3] - round(20 * scale))
		grid_step = round(20 * scale)
		self._grid(grid_rect, grid_line, grid_step)
		self.ddt.text_background_colour = shell_fill
		self.ddt.text((box_rect[0] + round(14 * scale), box_rect[1] + round(12 * scale)), "TAUON \\\\ SIGNAL", text_main, 210, round(120 * scale), shell_fill)
		self.ddt.text((box_rect[0] + box_rect[2] - round(104 * scale), box_rect[1] + round(12 * scale)), "SPECTRUM ARRAY", text_dim, 210, round(90 * scale), shell_fill)
		fps_text = f"{int(round(self.fps.get()))} FPS"
		self.ddt.text((box_rect[0] + box_rect[2] - round(56 * scale), box_rect[1] + box_rect[3] - round(16 * scale)), fps_text, text_faint, 209, round(52 * scale), shell_fill)
		theme_rect = (
			box_rect[0] + round(16 * scale),
			box_rect[1] + box_rect[3] - round(40 * scale),
			round(54 * scale),
			round(16 * scale),
		)
		self.fields.add(theme_rect)
		theme_label = "LIGHT" if not self.light_theme else "DARK"
		theme_colour = text_faint
		if self.coll(theme_rect) and self.inp.mouse_click:
			self.light_theme = not self.light_theme
			self.inp.mouse_click = False
			theme_colour = text_main
		elif self.coll(theme_rect):
			theme_colour = text_dim
		self.ddt.text_background_colour = shell_fill
		self.ddt.text((theme_rect[0] + round(4 * scale), theme_rect[1] - round(1 * scale)), theme_label, theme_colour, 209, theme_rect[2] - round(4 * scale), shell_fill)
		self.ddt.text_background_colour = shell_fill

		track = self.pctl.playing_object()
		playlist = self.pctl.default_playlist

		if self.inp.mouse_wheel != 0:
			self.volume_timer.set()
			scroll_distance = self.smooth_scroll.scroll("volume bar")
			self.pctl.player_volume += scroll_distance * self.prefs.volume_wheel_increment * 3
			if self.pctl.player_volume < 1:
				self.pctl.player_volume = 0
			elif self.pctl.player_volume > 100:
				self.pctl.player_volume = 100
			self.pctl.player_volume = int(self.pctl.player_volume)
			self.pctl.set_volume()

		box_restore_rect = (box_rect[0], box_rect[1], box_rect[2], round(34 * scale))
		self.fields.add(box_restore_rect)
		if self.coll(box_restore_rect) and self.inp.mouse_click:
			if self.tauon.d_click_timer.get() < 0.3:
				self.ddt.alpha_bg = False
				self.tauon.restore_full_mode()
				self.gui.update += 1
				return
			self.tauon.d_click_timer.set()

		art_size = round(127 * scale)
		art_rect = (
			box_rect[0] + box_rect[2] - art_size - round(18 * scale),
			box_rect[1] + round(34 * scale),
			art_size,
			art_size,
		)
		art_inner = (art_rect[0] + round(7 * scale), art_rect[1] + round(7 * scale), art_rect[2] - round(14 * scale), art_rect[3] - round(14 * scale))

		levels = self._update_visual_levels()
		self.fields.add(tuple(seek_hit))
		self.ddt.rect(tuple(seek_bar), alpha_blend(ColourRGBA(255, 255, 255, 14), shell_fill))
		for i in range(1, 9):
			x = seek_bar[0] + round(seek_bar[2] * (i / 10))
			self.ddt.line(x, seek_bar[1] - round(4 * scale), x, seek_bar[1] + seek_bar[3] + round(4 * scale), ColourRGBA(255, 255, 255, 18))
		progress = 0.0
		if self.pctl.playing_length > 0:
			progress = max(0.0, min(self.pctl.playing_time / self.pctl.playing_length, 1.0))
		progress_w = round((seek_bar[2] - round(4 * scale)) * progress)
		if progress_w > 0:
			self.ddt.rect((seek_bar[0] + round(2 * scale), seek_bar[1] + round(2 * scale), progress_w, seek_bar[3] - round(4 * scale)), primary)
			marker_x = seek_bar[0] + round(progress * seek_bar[2])
			self.ddt.rect((marker_x - round(1 * scale), seek_bar[1] - round(4 * scale), round(3 * scale), seek_bar[3] + round(8 * scale)), secondary)
		if self.inp.mouse_up and self.coll(tuple(seek_hit)) and self.pctl.playing_length > 0:
			click_x = min(max(self.inp.mouse_position[0], seek_bar[0]), seek_bar[0] + seek_bar[2]) - seek_bar[0]
			self.pctl.seek_decimal(click_x / seek_bar[2])

		graph_rect = (
			box_rect[0] + round(12 * scale),
			box_rect[1] + round(28 * scale),
			box_rect[2] - round(24 * scale),
			box_rect[3] - round(54 * scale),
		)
		baseline = graph_rect[1] + graph_rect[3] - round(16 * scale)
		midline = graph_rect[1] + graph_rect[3] // 2
		self.ddt.line(graph_rect[0], baseline, graph_rect[0] + graph_rect[2], baseline, alpha_blend(ColourRGBA(255, 255, 255, 18), shell_border))
		self.ddt.line(graph_rect[0], midline, graph_rect[0] + graph_rect[2], midline, ColourRGBA(255, 255, 255, 10))

		current_index = self._current_playlist_position()
		self._update_focus_position(current_index)

		anchor_x = box_rect[0] + round(box_rect[2] * 0.32) + round(75 * scale)
		anchor_y = box_rect[1] + round(box_rect[3] * 0.24)
		x_step = round(18 * scale)
		y_step = round(16 * scale)
		max_delta = 7
		clicked_track = False
		if playlist:
				start = max(0, int(math.floor(self.focus_position)) - max_delta)
				end = min(len(playlist), int(math.ceil(self.focus_position)) + max_delta + 1)
				for i in range(start, end):
					rel = i - self.focus_position
					if abs(rel) > 7:
						continue
					tx = anchor_x + round(rel * x_step)
					ty = anchor_y + round(rel * y_step)
					if ty < graph_rect[1] - round(20 * scale) or ty > baseline - round(4 * scale):
						continue

					track_object = self.pctl.get_track(playlist[i])
					title = self._track_title(track_object)
					is_current = i == current_index
					font = 413 if is_current else 211
					alpha = max(18, 170 - int(abs(rel) * 22) - max(0, int(-rel)) * 12)
					colour = text_main if is_current else ColourRGBA(track_text.r, track_text.g, track_text.b, alpha)
					tw = self.ddt.get_text_w(title, font)
					th = self.ddt.get_text_w(title, font, height=True)
					if tw is None:
						tw = round(120 * scale)
					if th is None:
						th = round(14 * scale)
					if tx > graph_rect[0] + graph_rect[2] or tx + round(tw) < graph_rect[0]:
						continue
					hit_rect = (
						round(tx) - round(4 * scale),
						round(ty) - round(2 * scale),
						round(tw) + round(8 * scale),
						round(th) + round(4 * scale),
					)
					self.fields.add(hit_rect)
					if self.coll(hit_rect):
						colour = text_main if not is_current else secondary
						if self.inp.mouse_click:
							self.pctl.selected_in_playlist = i
							self.gui.shift_selection = [i]
							self.focus_target = float(i)
							self.pending_track_jump = (playlist[i], i)
							self.pending_jump_frames = 1
							self.inp.mouse_click = False
							clicked_track = True
					self.ddt.text((tx, ty), title, colour, font, round(box_rect[2] * 0.54), shell_fill)
					if is_current:
						underline_y = round(ty + 15 * scale)
						underline_w = min(round(tw), round(box_rect[2] * 0.44))
						self.ddt.rect((round(tx), underline_y, underline_w, max(1, round(2 * scale))), secondary)

		self.ddt.rect(art_rect, art_fill)
		self.ddt.rect((art_rect[0], art_rect[1], art_rect[2], 1), shell_border)
		self.ddt.rect((art_rect[0], art_rect[1] + art_rect[3] - 1, art_rect[2], 1), shell_border)
		self.ddt.rect((art_rect[0], art_rect[1], 1, art_rect[3]), shell_border)
		self.ddt.rect((art_rect[0] + art_rect[2] - 1, art_rect[1], 1, art_rect[3]), shell_border)
		self.ddt.rect((art_rect[0] + round(10 * scale), art_rect[1] + round(10 * scale), min(round(92 * self.gui.scale), max(24, art_rect[2] - 16)), round(3 * scale)), secondary)
		if track is not None:
			self.album_art_gen.display(track, (art_inner[0], art_inner[1]), (art_inner[2], art_inner[3]))
			self._corner_marks(art_inner, secondary, round(10 * scale))
		else:
			self.ddt.rect(art_inner, alpha_blend(ColourRGBA(255, 255, 255, 8), art_fill))
			self._corner_marks(art_inner, secondary, round(10 * scale))
			self.ddt.text_background_colour = art_fill
			self.ddt.text((art_rect[0] + art_rect[2] // 2, art_rect[1] + round(34 * scale), 2), "NO ART", text_main, 210, art_rect[2] - round(14 * scale), art_fill)

		if self.coll(art_rect) and self.inp.right_click:
			self.pctl.play_pause()

		if self.pending_track_jump is not None:
			if self.pending_jump_frames > 0:
				self.pending_jump_frames -= 1
			else:
				track_id, pl_position = self.pending_track_jump
				self.pending_track_jump = None
				self.pctl.jump(track_id, pl_position=pl_position)

		bar_gap = round(4 * scale)
		bar_w = max(round(5 * scale), (graph_rect[2] - bar_gap * (len(levels) - 1)) // len(levels))
		max_h = graph_rect[3] - round(24 * scale)
		prev_point = None
		time_bias = time.time() * 3
		for i, value in enumerate(levels):
			x = graph_rect[0] + i * (bar_w + bar_gap)
			bar_h = max(round(value * max_h), 0)
			segments = max(1, bar_h // max(1, round(6 * scale)))
			for seg in range(segments):
				y = baseline - round((seg + 1) * (5 * scale))
				height = round(3 * scale)
				seg_colour = colour_slide(primary, secondary, seg, max(segments, 1))
				self.ddt.rect((x, y, bar_w, height), seg_colour)
			reflection = max(round(bar_h * 0.25), 0)
			if reflection > 0 and not self.light_theme:
				self.ddt.rect((x + round(1 * scale), midline + round(4 * scale), max(1, bar_w - round(2 * scale)), reflection), ColourRGBA(primary.r, primary.g, primary.b, 36))
			point_x = x + bar_w // 2
			point_y = midline - round(value * (graph_rect[3] * 0.22)) - round(math.sin(time_bias + i * 0.35) * 4 * scale)
			if prev_point is not None:
				self.ddt.line(prev_point[0], prev_point[1], point_x, point_y, secondary)
			prev_point = (point_x, point_y)
			self.ddt.rect((point_x - round(1 * scale), point_y - round(1 * scale), round(3 * scale), round(3 * scale)), point_marker)

		self._corner_marks(graph_rect, primary, round(10 * scale))

		if self.coll(box_rect) and self.inp.mouse_click and not clicked_track and not self.coll(tuple(seek_hit)):
			self.tauon.d_click_timer.set()

		tool_rect = [self.window_size[0] - 110 * self.gui.scale, 2, 108 * self.gui.scale, 45 * self.gui.scale]
		if self.prefs.left_window_control:
			tool_rect[0] = 0
		self.fields.add(tool_rect)
		if self.coll(tool_rect):
			self.tauon.draw_window_tools()

		self.gui.update += 1
		self.ddt.alpha_bg = False

class StandardPlaylist:
	def __init__(self, tauon: Tauon, pl_bg: LoadImageAsset | None) -> None:
		self.tauon         = tauon
		self.pl_bg         = pl_bg
		self._clip_rect    = None  # set per full_render(rect) to clip the texture blit
		self.gui           = tauon.gui
		self.inp           = tauon.inp
		self.ddt           = tauon.ddt
		self.coll          = tauon.coll
		self.pctl          = tauon.pctl
		self.deco          = tauon.deco
		self.prefs         = tauon.prefs
		self.colours       = tauon.colours
		self.renderer      = tauon.renderer
		self.star_store    = tauon.star_store
		self.window_size   = tauon.window_size
		self.smooth_scroll = tauon.smooth_scroll

	def _tracklist_step_height(self, track_position: int) -> float:
		if track_position < 0 or track_position >= len(self.pctl.default_playlist):
			return float(self.gui.playlist_row_height)

		step = float(self.gui.playlist_row_height)
		if not self.pctl.multi_playlist[self.pctl.active_playlist_viewing].hide_title and self.prefs.break_enable:
			if track_position == 0 or self.pctl.get_track(self.pctl.default_playlist[track_position]).parent_folder_path != self.pctl.get_track(
				self.pctl.default_playlist[track_position - 1]
			).parent_folder_path:
				step += self.gui.playlist_row_height
		return step

	def _apply_tracklist_pixel_scroll(self) -> None:
		pctl = self.pctl
		gui = self.gui
		position_before = pctl.playlist_view_position
		pixels_before = gui.playlist_scroll_pixels
		forward_steps = 0
		backward_steps = 0

		while pctl.playlist_view_position < len(pctl.default_playlist) and gui.playlist_scroll_pixels >= self._tracklist_step_height(
			pctl.playlist_view_position
		):
			gui.playlist_scroll_pixels -= self._tracklist_step_height(pctl.playlist_view_position)
			pctl.playlist_view_position += 1
			forward_steps += 1

		while gui.playlist_scroll_pixels < 0 and pctl.playlist_view_position > 0:
			pctl.playlist_view_position -= 1
			gui.playlist_scroll_pixels += self._tracklist_step_height(pctl.playlist_view_position)
			backward_steps += 1

		if pctl.playlist_view_position <= 0 and gui.playlist_scroll_pixels < 0:
			gui.playlist_scroll_pixels = 0
			self.smooth_scroll.reset_motion("playlist")

		if pctl.playlist_view_position >= len(pctl.default_playlist):
			pctl.playlist_view_position = len(pctl.default_playlist)
			gui.playlist_scroll_pixels = 0
			self.smooth_scroll.reset_motion("playlist")

		# if logging.getLogger().isEnabledFor(logging.DEBUG) and (
		# 	forward_steps
		# 	or backward_steps
		# 	or position_before != pctl.playlist_view_position
		# 	or abs(pixels_before - gui.playlist_scroll_pixels) >= 0.01
		# ):
			# logging.debug(
			# 	"Playlist pixel scroll apply pos_before=%d pos_after=%d pixels_before=%.4f pixels_after=%.4f forward_steps=%d backward_steps=%d",
			# 	position_before,
			# 	pctl.playlist_view_position,
			# 	pixels_before,
			# 	gui.playlist_scroll_pixels,
			# 	forward_steps,
			# 	backward_steps,
			# )

	def _same_album_art_block(self, track_a: TrackClass, track_b: TrackClass) -> bool:
		return (
			track_a.parent_folder_path == track_b.parent_folder_path
			and track_a.album == track_b.album
			and track_a.album_artist == track_b.album_artist
		)

	def _album_art_block_bounds(self, track_position: int) -> tuple[int, int]:
		pctl = self.pctl
		if track_position < 0 or track_position >= len(pctl.default_playlist):
			return track_position, track_position

		start = track_position
		while start > 0:
			track = pctl.get_track(pctl.default_playlist[start])
			previous_track = pctl.get_track(pctl.default_playlist[start - 1])
			if not self._same_album_art_block(track, previous_track):
				break
			start -= 1

		end = track_position + 1
		while end < len(pctl.default_playlist):
			track = pctl.get_track(pctl.default_playlist[end - 1])
			next_track = pctl.get_track(pctl.default_playlist[end])
			if not self._same_album_art_block(track, next_track):
				break
			end += 1

		return start, end

	def _folder_title_would_appear(self, track_position: int) -> bool:
		pctl = self.pctl
		if (
			pctl.multi_playlist[pctl.active_playlist_viewing].hide_title
			or not self.prefs.break_enable
			or track_position < 0
			or track_position >= len(pctl.default_playlist)
		):
			return False
		if track_position == 0:
			return True
		track = pctl.get_track(pctl.default_playlist[track_position])
		previous_track = pctl.get_track(pctl.default_playlist[track_position - 1])
		return track.parent_folder_path != previous_track.parent_folder_path

	def _queue_album_art_column(
			self,
			track_position: int,
			row_y: float,
			column_x: float,
			column_width: float,
			rendered_blocks: set[int],
			draws: list[tuple[TrackClass, tuple[float, float], int, int]]) -> None:
		block_start, block_end = self._album_art_block_bounds(track_position)
		block_row_offset = track_position - block_start
		block_y = row_y - self.gui.playlist_row_height * block_row_offset
		self._queue_album_art_block(block_start, block_end, block_y, column_x, column_width, rendered_blocks, draws)

	def _queue_previous_album_art_for_folder_title(
			self,
			track_position: int,
			title_y: float,
			column_x: float,
			column_width: float,
			rendered_blocks: set[int],
			draws: list[tuple[TrackClass, tuple[float, float], int, int]]) -> None:
		if track_position <= 0 or not self._folder_title_would_appear(track_position):
			return

		block_start, block_end = self._album_art_block_bounds(track_position - 1)
		if block_end != track_position:
			return

		block_y = title_y - (block_end - block_start) * self.gui.playlist_row_height
		self._queue_album_art_block(block_start, block_end, block_y, column_x, column_width, rendered_blocks, draws)

	def _queue_album_art_block(
			self,
			block_start: int,
			block_end: int,
			block_y: float,
			column_x: float,
			column_width: float,
			rendered_blocks: set[int],
			draws: list[tuple[TrackClass, tuple[float, float], int, int]]) -> None:
		if column_width <= 8 * self.gui.scale:
			return

		if block_start in rendered_blocks:
			return
		rendered_blocks.add(block_start)

		horizontal_padding = round(1 * self.gui.scale)
		vertical_padding = round(5 * self.gui.scale)
		folder_title_bottom_gap = round(5 * self.gui.scale)

		art_size = max(1, round(column_width) - horizontal_padding * 2)
		block_height = (block_end - block_start) * self.gui.playlist_row_height
		allowed_bottom = block_y + block_height - vertical_padding
		if self._folder_title_would_appear(block_end):
			allowed_bottom = block_y + block_height + self.gui.playlist_row_height - folder_title_bottom_gap
		draw_y = block_y + vertical_padding
		draw_height = min(art_size, allowed_bottom - draw_y)

		playlist_bottom = self.window_size[1] - self.gui.panelBY
		if draw_height <= 0 or draw_y >= playlist_bottom or draw_y + draw_height <= self.gui.playlist_top:
			return

		track = self.pctl.get_track(self.pctl.default_playlist[block_start])
		draws.append((track, (column_x + horizontal_padding, draw_y), art_size, round(draw_height)))

	def compute_tracklist_insets(self) -> None:
		"""Compute the tracklist highlight/inset geometry from the current
		gui.playlist_left / gui.plw / window size. Mirrors the inline computation
		in update_layout_do(); used by the Custom Layout's Tracklist widget after
		it points those vars at its segment. (The preset path keeps its own inline
		copy untouched.)
		"""
		gui = self.gui
		prefs = self.prefs
		window_size = self.window_size
		width = gui.plw

		# In custom mode the Tracklist widget renders into its own segment, so use
		# a plain default margin instead of reacting to the (hidden) preset's left/
		# right side panels or tracks-only centering.
		if gui.custom_mode:
			gui.highlight_left = 0
			gui.tracklist_center_mode = False
			gui.tracklist_highlight_left = 0
			gui.tracklist_highlight_width = width
			gui.tracklist_inset_left = round(23 * gui.scale)
			gui.tracklist_inset_width = width - round(32 * gui.scale)
			return

		center_mode = True
		if gui.lsp or gui.rsp or gui.set_mode:
			center_mode = False
		if gui.set_mode and window_size[0] < 600:
			center_mode = False

		gui.highlight_left = 0
		highlight_width = width
		inset_left = gui.highlight_left + 23 * gui.scale
		inset_width = highlight_width - 32 * gui.scale
		if gui.lsp and not gui.rsp:
			inset_width -= 10 * gui.scale
		if gui.lsp:
			inset_left -= 10 * gui.scale
			inset_width += 10 * gui.scale
		if gui.rsp_on_left:
			inset_left -= 11 * gui.scale
			inset_width += 6 * gui.scale
		if center_mode:
			if gui.set_mode:
				gui.highlight_left = int(pow((window_size[0] / gui.scale * 0.005), 2) * gui.scale)
			else:
				gui.highlight_left = int(pow((window_size[0] / gui.scale * 0.01), 2) * gui.scale)
			if window_size[0] < 600 * gui.scale:
				gui.highlight_left = 3 * gui.scale
			highlight_width -= gui.highlight_left * 2
			inset_left = gui.highlight_left + 18 * gui.scale
			inset_width = highlight_width - 25 * gui.scale
		if window_size[0] < 600 and gui.lsp:
			inset_width = highlight_width - 18 * gui.scale

		gui.tracklist_center_mode = center_mode
		gui.tracklist_inset_left = inset_left
		gui.tracklist_inset_width = inset_width
		gui.tracklist_highlight_left = gui.highlight_left
		gui.tracklist_highlight_width = highlight_width

	def get_column_text(self, name: str, n_track: TrackClass, p_track: int) -> str:
		"""Return the display string for a text column (used by Magnet Mode layout
		to pre-measure widths). Must stay in sync with the text produced in the
		column render loop. Returns "" for widget/non-text columns."""
		pctl = self.pctl
		prefs = self.prefs
		if name == "Title":
			return n_track.title if n_track.title else n_track.filename
		if name == "Artist":
			return n_track.artist
		if name == "Album":
			return n_track.album
		if name == "Album Artist":
			text = n_track.album_artist
			if not text and prefs.column_aa_fallback_artist:
				text = n_track.artist
			return text
		if name == "Composer":
			return n_track.composer
		if name == "Comment":
			return n_track.comment.replace("\n", " ").replace("\r", " ")
		if name == "S":
			return str(n_track.lfm_scrobbles) if n_track.lfm_scrobbles > 0 else ""
		if name == "#":
			if prefs.use_absolute_track_index and pctl.multi_playlist[pctl.active_playlist_viewing].hide_title:
				return str(p_track)
			return self.tauon.track_number_process(n_track.track_number)
		if name == "Date":
			return n_track.date
		if name == "Filepath":
			return clean_string(n_track.fullpath)
		if name == "Filename":
			return clean_string(n_track.filename)
		if name == "Disc":
			return str(n_track.disc_number)
		if name == "Codec":
			text = n_track.file_ext
			if text == "JELY" and n_track.container is not None:
				text = n_track.container
			return text
		if name == "Lyrics":
			if n_track.synced:
				return "⧗"
			if n_track.lyrics:
				return "✓"
			return ""
		if name == "CUE":
			return "✓" if n_track.is_cue else ""
		if name == "Genre":
			return n_track.genre
		if name == "ID":
			return str(n_track.index)
		if name == "=/=":
			return get_modify_date_string(n_track.modified_time)
		if name == "Bitrate":
			text = str(n_track.bitrate)
			if text == "0":
				text = ""
			ex = n_track.file_ext
			if n_track.container is not None:
				ex = n_track.container
			if ex in ("FLAC", "WAV", "APE"):
				text = str(round(n_track.samplerate / 1000, 1)).rstrip("0").rstrip(".") + "|" + str(n_track.bit_depth)
			return text
		if name == "Time":
			return get_display_time(n_track.length)
		if name == "P":
			ratio = 0
			total = self.star_store.get_by_object(n_track)
			if total > 0 and n_track.length > 2:
				if n_track.length > 15:
					total += 2
				ratio = total / (n_track.length - 1)
			text = str(int(ratio))
			if text == "0":
				text = ""
			return text
		return ""

	def compute_magnet_layout(self, start_run: float, end: float, n_track: TrackClass, p_track: int) -> dict[int, tuple[float, float]]:
		"""Work out packed text positions for Magnet Mode column groups.

		A magnet group is a run of magnet-on columns plus the following column
		(magnet-off) which terminates but is still included in the group. Within a
		group the columns' text is drawn packed left with a standard-layout gap
		instead of each being confined to its own column. If the combined text
		fits the group's width it is drawn at natural size; otherwise each text is
		truncated to a share of the space proportional to its column's width.

		Returns (layout, separators):
		  - layout: column index -> (text_x, text_max_w) for columns drawn with
		    magnet positioning. Columns absent from the map render normally.
		  - separators: column index -> separator_x. When two magnet-packed columns
		    that sit next to each other share the same text colour (so the eye can't
		    tell them apart), a " - " separator is drawn after the first one, exactly
		    as the standard tracklist does between artist and title.
		"""
		gui = self.gui
		pl_st = gui.pl_st
		ddt = self.ddt
		colours = self.colours
		font = gui.row_font_size
		scale = gui.scale
		lead = 6 * scale
		gap = 6 * scale
		trail = 14 * scale
		sep_text = " - "
		sep_w = ddt.get_text_w(sep_text, font)

		def magnet_on(item: list) -> bool:
			return len(item) > 3 and bool(item[3])

		def base_colour(name: str):
			if name in colours.column_colours:
				return colours.column_colours[name]
			if name == "Title":
				return colours.title_text
			if name == "Artist" or name == "Album Artist":
				return colours.artist_text
			if name == "Album":
				return colours.album_text
			if name == "Time":
				return colours.bar_time
			return colours.index_text

		# Precompute the left edge (run) of each column
		run_positions = []
		run = start_run
		for item in pl_st:
			run_positions.append(run)
			run += item[1]

		layout: dict[int, tuple[float, float]] = {}
		separators: dict[int, float] = {}
		n = len(pl_st)
		h = 0
		while h < n:
			if not magnet_on(pl_st[h]):
				h += 1
				continue
			# Gather the group: magnet-on columns plus the terminating column
			group = []
			k = h
			while k < n:
				group.append(k)
				if not magnet_on(pl_st[k]):
					break
				k += 1

			if len(group) < 2:
				# Nothing to the right to magnet onto (last column) - render normally
				h = group[-1] + 1
				continue

			group_start = run_positions[group[0]]
			sum_widths = sum(pl_st[i][1] for i in group)
			group_end = min(group_start + sum_widths, end)
			group_span = group_end - group_start

			texts = [self.get_column_text(pl_st[i][0], n_track, p_track) for i in group]
			nats = [ddt.get_text_w(t, font) if t else 0 for t in texts]
			present = [idx for idx in range(len(group)) if texts[idx]]

			# Decide, per gap between two adjacent present columns, whether a " - "
			# separator is needed (same colour) or just a plain gap.
			between = {}  # position-in-present -> width consumed between it and the next
			for pos in range(len(present) - 1):
				col_a = group[present[pos]]
				col_b = group[present[pos + 1]]
				if base_colour(pl_st[col_a][0]) == base_colour(pl_st[col_b][0]):
					between[pos] = sep_w
				else:
					between[pos] = gap
			total_between = sum(between.values())

			available = group_span - lead - trail - total_between
			available = max(0, available)

			sum_nat = sum(nats)
			if sum_nat <= available:
				allocs = list(nats)
			else:
				# Truncate: share the space by column width ratio within the group
				allocs = [available * (pl_st[group[idx]][1] / sum_widths) if sum_widths else 0 for idx in range(len(group))]

			x = group_start + lead
			for pos, idx in enumerate(present):
				col = group[idx]
				alloc = max(0, allocs[idx])
				layout[col] = (x, alloc)
				x += min(nats[idx], alloc)
				if pos < len(present) - 1:
					if between[pos] == sep_w:
						separators[col] = x
					x += between[pos]

			h = group[-1] + 1

		return layout, separators

	def full_render(self, rect: tuple[float, float, float, float] | None = None) -> None:
		"""Render the tracklist.

		With rect=None (the preset path) it uses the standard gui layout vars
		unchanged. With a rect (the Custom Layout Tracklist widget) it renders into
		that segment by pointing the layout vars at it for the duration of the
		render, then restoring them — the heavy body (_render_body) is shared and
		left untouched, so preset rendering is byte-identical.
		"""
		if rect is None:
			self._clip_rect = None
			self._render_body()
			return
		gui = self.gui
		window_size = self.window_size
		saved = (gui.playlist_left, gui.plw, gui.panelY, gui.playlist_top, window_size[1], gui.show_playlist)
		x, y, w, h = rect
		gui.playlist_left = round(x)
		gui.plw = round(w)
		gui.panelY = round(y)
		gui.playlist_top = round(y) + round(8 * gui.scale)
		if gui.set_bar and gui.set_mode:
			# Drop the body below the columns header bar (same offset the preset
			# applies in update_layout_do) so the first rows aren't hidden under it.
			gui.playlist_top += round(gui.set_height) - round(6 * gui.scale)
		window_size[1] = round(y + h) + gui.panelBY
		gui.show_playlist = True
		# Copy only this segment from the texture to the main texture, so the
		# tracklist can't bleed past its region (see the final blit in _render_body).
		self._clip_rect = (round(x), round(y), round(w), round(h))
		self.compute_tracklist_insets()
		try:
			self._render_body()
		finally:
			(gui.playlist_left, gui.plw, gui.panelY, gui.playlist_top, window_size[1], gui.show_playlist) = saved

	def _render_body(self) -> None:
		tauon       = self.tauon
		prefs       = self.prefs
		pctl        = self.pctl
		gui         = self.gui
		inp         = self.inp
		window_size = self.window_size
		ddt         = self.ddt
		colours     = self.colours
		pl_bg       = self.pl_bg
		deco        = self.deco
		left        = gui.playlist_left
		width       = gui.plw

		self.update_album_rating_hover()

		highlight_width    = gui.tracklist_highlight_width
		gui.highlight_left = gui.tracklist_highlight_left
		inset_width        = gui.tracklist_inset_width
		inset_left         = gui.tracklist_inset_left
		center_mode        = gui.tracklist_center_mode
		scrollbar_width    = 15 * gui.scale
		if gui.set_mode and prefs.left_align_album_artist_title:
			scrollbar_width = 11 * gui.scale
		scrollbar_x = gui.playlist_left + gui.plw - scrollbar_width - 2 * gui.scale
		scrollbar_hitbox_width = 28 * gui.scale
		scrollbar_hitbox_right = scrollbar_x + scrollbar_width + 1 * gui.scale
		scrollbar_hitbox_left = scrollbar_hitbox_right - scrollbar_hitbox_width
		scrollbar_hitbox = (
			scrollbar_hitbox_left,
			gui.panelY,
			scrollbar_hitbox_width,
			window_size[1] - gui.panelBY - gui.panelY,
		)

		w = 0
		gui.row_extra = 0
		cv = 0  # update gui.playlist_current_visible_tracks

		# Draw the background
		sdl3.SDL_SetRenderTarget(self.renderer, gui.tracklist_texture)
		sdl3.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 0)
		sdl3.SDL_RenderClear(self.renderer)

		rect = (left, gui.panelY, width, window_size[1] - (gui.panelBY + gui.panelY))
		ddt.rect(rect, colours.playlist_panel_background)

		# This draws an optional background image
		if pl_bg:
			x = (left + highlight_width) - (pl_bg.w + round(60 * gui.scale))
			pl_bg.render(x, window_size[1] - gui.panelBY - pl_bg.h)
			ddt.pretty_rect = (x, window_size[1] - gui.panelBY - pl_bg.h, pl_bg.w, pl_bg.h)
			ddt.alpha_bg = True
		else:
			xx = left + inset_left + inset_width
			if center_mode:
				xx -= round(15 * gui.scale)
			deco.draw(ddt, xx, window_size[1] - gui.panelBY, pretty_text=True)

		# When the artist-info panel occupies the top of the tracklist column,
		# exclude its area so wheel/touch scrolling over the bio doesn't also
		# scroll the tracklist underneath it.
		artist_panel_offset = gui.artist_panel_height if gui.artist_info_panel else 0
		scroll_top = gui.panelY + artist_panel_offset
		scroll_area = (gui.playlist_left, scroll_top, gui.plw, window_size[1] - gui.panelBY - scroll_top)
		mouse_scroll = inp.mouse_wheel != 0 and window_size[1] - gui.panelBY - 1 > inp.mouse_position[
			1] > scroll_top - 2 and gui.playlist_left < inp.mouse_position[0] < gui.playlist_left + gui.plw \
				and not (self.coll(gui.pl_rect)) and not tauon.search_over.active and not tauon.radiobox.active
		touch_scroll = (
			inp.touch_scroll_y != 0
			and coll_point(inp.touch_position, scroll_area)
			and not tauon.search_over.active
			and not tauon.radiobox.active
		)
		use_smooth_scroll = (
			self.smooth_scroll.enabled()
			or touch_scroll
			or self.smooth_scroll.active("playlist")
		)
		pointer_input = (
			inp.mouse_click
			or inp.right_click
			or inp.middle_click
			or inp.mouse_down
			or inp.mouse_up
		)
		if not use_smooth_scroll and inp.k_input and not pointer_input and not mouse_scroll and not touch_scroll:
			gui.playlist_scroll_pixels = 0
		a = gui.playlist_view_length
		match a:
			case _ if a < 10:
				mx = 2
			case _ if a < 25:
				mx = 3
			case _ if a > 40:
				mx = 5
			case _:
				mx = 4

		# Mouse wheel scrolling
		if use_smooth_scroll:
			if mouse_scroll:
				self.smooth_scroll.add_wheel_motion(
					"playlist", -inp.mouse_wheel, gui.playlist_row_height * mx, SCROLL_PHYSICS_TRACKLIST_PRECISE_SCALE
				)
			
			if inp.touch_released:
				self.smooth_scroll.release_touch("playlist")
			elif touch_scroll:
				self.smooth_scroll.apply_touch_drag("playlist", -inp.touch_scroll_y)

			gui.playlist_scroll_pixels += self.smooth_scroll.step_motion("playlist")
			self._apply_tracklist_pixel_scroll()

			if mouse_scroll or touch_scroll or self.smooth_scroll.active("playlist"):
				tauon.scroll_hide_timer.set()
				gui.frame_callback_list.append(TestTimer(0.9))
		elif mouse_scroll:
			gui.playlist_scroll_pixels = 0
			pctl.playlist_view_position -= self.smooth_scroll.scroll("playlist", mx)

			pctl.playlist_view_position = min(pctl.playlist_view_position, len(pctl.default_playlist))
			if pctl.playlist_view_position < 1:
				pctl.playlist_view_position = 0
				if pctl.default_playlist:
					tauon.edge_playlist2.pulse()

			tauon.scroll_hide_timer.set()
			gui.frame_callback_list.append(TestTimer(0.9))

		align_tracklist_to_row = False

		# Show notice if playlist empty
		if len(pctl.default_playlist) == 0:
			colour = alpha_mod(colours.index_text, 200)  # colours.playlist_text_missing

			top_a = gui.panelY
			if gui.artist_info_panel:
				top_a += gui.artist_panel_height

			b = window_size[1] - top_a - gui.panelBY
			half = int(top_a + (b * 0.60))

			if pl_bg:
				rect = (
					left + int(width / 2) - 80 * gui.scale, half - 10 * gui.scale, 190 * gui.scale, 60 * gui.scale)
				ddt.pretty_rect = rect
				ddt.alpha_bg = True

			ddt.text(
				(left + int(width / 2) + 10 * gui.scale, half, 2),
				_("Playlist is empty"), colour, 213, bg=colours.playlist_panel_background)
			ddt.text(
				(left + int(width / 2) + 10 * gui.scale, half + 30 * gui.scale, 2),
				_("Drag and drop files to import"), colour, 13, bg=colours.playlist_panel_background)

			ddt.pretty_rect = None
			ddt.alpha_bg = False

		# Show notice if at end of playlist
		elif pctl.playlist_view_position > len(pctl.default_playlist) - 1:
			colour = alpha_mod(colours.index_text, 200)

			top_a = gui.panelY
			if gui.artist_info_panel:
				top_a += gui.artist_panel_height

			b = window_size[1] - top_a - gui.panelBY
			half = int(top_a + (b * 0.17))

			if pl_bg:
				rect = (
					left + int(width / 2) - 60 * gui.scale, half - 5 * gui.scale, 140 * gui.scale, 30 * gui.scale)
				ddt.pretty_rect = rect
				ddt.alpha_bg = True

			ddt.text(
				(left + int(width / 2) + 10 * gui.scale, half, 2), _("End of Playlist"),
				colour, 213)

			ddt.pretty_rect = None
			ddt.alpha_bg = False

			# line = "Contains " + str(len(pctl.default_playlist)) + ' track'
			# if len(pctl.default_playlist) > 1:
			#     line += "s"
			#
			# ddt.draw_text((left + int(width / 2) + 10 * gui.scale, half + 24 * gui.scale, 2), line,
			#           colour, 12)

		# Process Input

		# type (0 is track, 1 is fold title), track_position, track_object, box, input_box,
		list_items = []
		number = 0
		render_rows = gui.playlist_view_length + 2
		row_input_right_pad = 0
		if prefs.scroll_enable:
			tracklist_right = left + gui.highlight_left + highlight_width
			row_input_right_pad = max(0, round(tracklist_right - scrollbar_hitbox_left))

		for i in range(render_rows):
			track_position = i + pctl.playlist_view_position

			# Make sure the view position is valid
			pctl.playlist_view_position = max(pctl.playlist_view_position, 0)

			# Break if we are at end of playlist
			if len(pctl.default_playlist) <= track_position or number >= render_rows:
				break

			track_object = pctl.get_track(pctl.default_playlist[track_position])
			track_id = track_object.index
			self.gui.move_on_title = False

			line_y = gui.playlist_top + gui.playlist_row_height * number - gui.playlist_scroll_pixels

			track_box = (
				left + gui.highlight_left, line_y, highlight_width,
				gui.playlist_row_height - 1)

			input_box = (
				track_box[0] + 30 * gui.scale,
				track_box[1] + 1,
				track_box[2] - 36 * gui.scale - row_input_right_pad,
				track_box[3],
			)

			# Are folder titles enabled?
			if not pctl.multi_playlist[pctl.active_playlist_viewing].hide_title and self.prefs.break_enable:
				# Is this track from a different folder than the last?
				if track_position == 0 or track_object.parent_folder_path != pctl.get_track(
						pctl.default_playlist[track_position - 1]).parent_folder_path:
					# Make folder title

					highlight = False
					drag_highlight = False

					# Shift selection highlight
					if (track_position in self.gui.shift_selection and len(self.gui.shift_selection) > 1):
						highlight = True

					# Tracks have been dropped?
					if gui.playlist_hold is True and self.coll(input_box) and inp.mouse_up:
						gui.move_on_title = True

					# Ignore click in ratings box
					click_title = (inp.mouse_click or inp.right_click or self.inp.middle_click) and self.coll(input_box)
					if click_title and gui.show_album_ratings:
						if self.inp.mouse_position[0] > (input_box[0] + input_box[2]) - 80 * gui.scale:
							click_title = False

					# Detect folder title click
					if click_title and self.inp.mouse_position[1] < window_size[1] - gui.panelBY:

						gui.pl_update += 1
						# Add folder to queue if middle click
						if self.inp.middle_click and self.tauon.is_level_zero():
							if self.inp.key_ctrl_down:  # Add as ungrouped tracks
								i = track_position
								parent = pctl.get_track(pctl.default_playlist[i]).parent_folder_path
								while i < len(pctl.default_playlist) and parent == pctl.get_track(
										pctl.default_playlist[i]).parent_folder_path:
									pctl.force_queue.append(queue_item_gen(pctl.default_playlist[i], i, pctl.pl_to_id(
										pctl.active_playlist_viewing)))
									i += 1
								self.tauon.queue_timer_set(plural=True)
								if prefs.stop_end_queue:
									pctl.stop_mode = StopMode.OFF

							else:  # Add as grouped album
								self.tauon.add_album_to_queue(track_id, track_position, pctl.pl_to_id(pctl.active_playlist_viewing))
							pctl.selected_in_playlist = track_position
							gui.shift_selection = [pctl.selected_in_playlist]
							gui.pl_update += 1

						# Play if double click:
						if inp.d_mouse_click and track_position in self.gui.shift_selection and coll_point(
							self.inp.last_click_location, (input_box)):
							gui.click_time -= 1.5
							align_tracklist_to_row = True
							pctl.jump(track_id, track_position)
							line_hit = False
							inp.mouse_click = False

							if prefs.album_mode:
								tauon.goto_album(pctl.playlist_playing_position)

						# Show selection menu if right clicked after select
						if inp.right_click:
							self.tauon.folder_menu.activate(
								MenuTrackRef(track_id, track_position, pctl.pl_to_id(pctl.active_playlist_viewing))
							)
							gui.selection_stage = 2
							gui.pl_update = 1

							if track_position not in self.gui.shift_selection:
								self.gui.shift_selection = []
								pctl.selected_in_playlist = track_position
								u = track_position
								while u < len(pctl.default_playlist) and track_object.parent_folder_path == \
										pctl.master_library[
											pctl.default_playlist[u]].parent_folder_path:
									self.gui.shift_selection.append(u)
									u += 1

						# Add folder to selection if clicked
						if (
							inp.mouse_click
							and not (prefs.scroll_enable and self.coll(scrollbar_hitbox) and not gui.album_rating_hover)
							and not gui.side_drag
						):
							self.inp.quick_drag = True
							gui.set_drag_source()

							if not tauon.pl_is_locked(pctl.active_playlist_viewing) or self.inp.key_shift_down:
								self.gui.playlist_hold = True

							gui.selection_stage = 1
							temp = tauon.get_folder_tracks_local(track_position)
							pctl.selected_in_playlist = track_position

							if len(self.gui.shift_selection) > 0 and self.inp.key_shift_down:
								if track_position < self.gui.shift_selection[0]:
									for item in reversed(temp):
										if item not in self.gui.shift_selection:
											self.gui.shift_selection.insert(0, item)
								else:
									for item in temp:
										if item not in self.gui.shift_selection:
											self.gui.shift_selection.append(item)

							else:
								self.gui.shift_selection = copy.copy(temp)

					# Should draw drag highlight?

					if self.inp.mouse_down and self.gui.playlist_hold and self.coll(input_box) and track_position not in self.gui.shift_selection:
						if len(self.gui.shift_selection) < 2 and not self.inp.key_shift_down:
							pass
						else:
							drag_highlight = True

					# Something to do with quick search, I forgot
					if pctl.selected_in_playlist > track_position + 1:
						gui.row_extra += 1

					list_items.append(
						(1, track_position, track_object, track_box, input_box, highlight, number, drag_highlight, False))
					number += 1

			if number >= render_rows:
				break

			# Standard track ---------------------------------------------------------------------
			playing = False

			highlight = False
			drag_highlight = False
			line_y = gui.playlist_top + gui.playlist_row_height * number - gui.playlist_scroll_pixels

			track_box = (
				left + gui.highlight_left, line_y, highlight_width,
				gui.playlist_row_height - 1)

			input_box = (
				track_box[0] + 30 * gui.scale,
				track_box[1] + 1,
				track_box[2] - 36 * gui.scale - row_input_right_pad,
				track_box[3],
			)

			# Test if line has mouse over or been clicked
			line_over = False
			line_hit = False
			if self.coll(input_box) and self.inp.mouse_position[1] < window_size[1] - gui.panelBY:
				line_over = True
				if (inp.mouse_click or inp.right_click or (self.inp.middle_click and self.tauon.is_level_zero())):
					line_hit = True
					gui.pl_update += 1
				else:
					line_hit = False
			else:
				line_hit = False
				line_over = False

			# Prevent click if near scroll bar
			if prefs.scroll_enable and self.coll(scrollbar_hitbox) and not gui.album_rating_hover:
				line_hit = False

			# Double click to play
			if self.inp.key_shift_down is False and inp.d_mouse_click and line_hit and track_position == pctl.selected_in_playlist and coll_point(
					self.inp.last_click_location, input_box):

				align_tracklist_to_row = True
				pctl.jump(track_id, track_position)

				gui.click_time -= 1.5
				self.inp.quick_drag = False
				self.inp.mouse_down = False
				self.inp.mouse_up = False
				line_hit = False

				if prefs.album_mode:
					tauon.goto_album(pctl.playlist_playing_position)

			if len(pctl.track_queue) > 0 and pctl.track_queue[pctl.queue_step] == track_id:
				if track_position == pctl.playlist_playing_position and pctl.active_playlist_viewing == pctl.active_playlist_playing:
					this_line_playing = True

			# Add to queue on middle click
			if self.inp.middle_click and line_hit:
				pctl.force_queue.append(
					queue_item_gen(track_id,
					track_position, pctl.pl_to_id(pctl.active_playlist_viewing)))
				pctl.selected_in_playlist = track_position
				self.gui.shift_selection = [pctl.selected_in_playlist]
				gui.pl_update += 1
				self.tauon.queue_timer_set()
				if prefs.stop_end_queue:
					pctl.stop_mode = StopMode.OFF

			# Deselect multiple if one clicked on and not dragged (mouse up is probably a bit of a hacky way of doing it)
			if len(self.gui.shift_selection) > 1 and self.inp.mouse_up and line_over and not self.inp.key_shift_down and not self.inp.key_ctrl_down and point_proximity_test(
					gui.drag_source_position, self.inp.mouse_position, 15):  # and not self.gui.playlist_hold:
				self.gui.shift_selection = [track_position]
				pctl.selected_in_playlist = track_position
				gui.pl_update = 1
				gui.update = 2

			# # Begin drag block selection
			# if self.inp.mouse_down and line_over and track_position in self.gui.shift_selection and len(self.gui.shift_selection) > 1:
			#     if not tauon.pl_is_locked(pctl.active_playlist_viewing):
			#         self.gui.playlist_hold = True
			#     elif self.inp.key_shift_down:
			#         self.gui.playlist_hold = True

			# Begin drag single track
			if inp.mouse_click and line_hit and not gui.side_drag:
				self.inp.quick_drag = True
				gui.set_drag_source()

			# Shift Move Selection
			if gui.move_on_title or (self.inp.mouse_up and self.gui.playlist_hold is True and self.coll((
					left + gui.highlight_left, line_y, highlight_width - row_input_right_pad, gui.playlist_row_height))):

				if len(self.gui.shift_selection) > 1 or self.inp.key_shift_down:
					if track_position not in self.gui.shift_selection:  # p_track != self.gui.playlist_hold_position and

						if len(self.gui.shift_selection) == 0:
							ref = pctl.default_playlist[self.gui.playlist_hold_position]
							pctl.default_playlist[self.gui.playlist_hold_position] = "old"
							if gui.move_on_title:
								pctl.default_playlist.insert(track_position, "new")
							else:
								pctl.default_playlist.insert(track_position + 1, "new")
							pctl.default_playlist.remove("old")
							pctl.selected_in_playlist = pctl.default_playlist.index("new")
							pctl.default_playlist[pctl.default_playlist.index("new")] = ref

							gui.pl_update = 1


						else:
							ref = []
							gui.selection_stage = 2
							for item in self.gui.shift_selection:
								ref.append(pctl.default_playlist[item])

							for item in self.gui.shift_selection:
								pctl.default_playlist[item] = "old"

							for item in self.gui.shift_selection:
								if gui.move_on_title:
									pctl.default_playlist.insert(track_position, "new")
								else:
									pctl.default_playlist.insert(track_position + 1, "new")

							for b in reversed(range(len(pctl.default_playlist))):
								if pctl.default_playlist[b] == "old":
									del pctl.default_playlist[b]
							self.gui.shift_selection = []
							for b in range(len(pctl.default_playlist)):
								if pctl.default_playlist[b] == "new":
									self.gui.shift_selection.append(b)
									pctl.default_playlist[b] = ref.pop(0)

							pctl.selected_in_playlist = self.gui.shift_selection[0]
							gui.pl_update += 1

						tauon.reload_albums(True)
						pctl.notify_database_changed()

			# Test show drag indicator
			if self.inp.mouse_down and self.gui.playlist_hold and self.coll(input_box) and track_position not in self.gui.shift_selection:
				if len(self.gui.shift_selection) > 1 or self.inp.key_shift_down:
					drag_highlight = True

			# Right click menu activation
			if self.inp.right_click and line_hit and self.inp.mouse_position[0] > gui.playlist_left + 10:
				if len(self.gui.shift_selection) > 1 and track_position in self.gui.shift_selection:
					self.tauon.selection_menu.activate(pctl.default_playlist[track_position])
					gui.selection_stage = 2
				else:
					self.tauon.track_menu.activate(
						MenuTrackRef(
							pctl.default_playlist[track_position],
							track_position,
							pctl.pl_to_id(pctl.active_playlist_viewing),
						)
					)
					gui.pl_update += 1
					gui.update += 1

					if track_position not in self.gui.shift_selection:
						pctl.selected_in_playlist = track_position
						self.gui.shift_selection = [pctl.selected_in_playlist]

			if line_over and inp.mouse_click:
				if track_position in self.gui.shift_selection:
					pass
				else:
					gui.selection_stage = 2
					if self.inp.key_shift_down:
						start_s = track_position
						end_s = pctl.selected_in_playlist
						if end_s < start_s:
							end_s, start_s = start_s, end_s
						for y in range(start_s, end_s + 1):
							if y not in self.gui.shift_selection:
								self.gui.shift_selection.append(y)
						self.gui.shift_selection.sort()
						pctl.selected_in_playlist = track_position
					elif self.inp.key_ctrl_down:
						self.gui.shift_selection.append(track_position)
					else:
						pctl.selected_in_playlist = track_position
						self.gui.shift_selection = [pctl.selected_in_playlist]

				if not tauon.pl_is_locked(pctl.active_playlist_viewing) or self.inp.key_shift_down:
					self.gui.playlist_hold = True
					self.gui.playlist_hold_position = track_position

			# Activate drag if shift key down
			if self.inp.quick_drag and tauon.pl_is_locked(pctl.active_playlist_viewing) and self.inp.mouse_down:
				if self.inp.key_shift_down:
					self.gui.playlist_hold = True
				else:
					self.gui.playlist_hold = False

			# Multi Select Highlight
			if track_position in self.gui.shift_selection or track_position == pctl.selected_in_playlist:
				highlight = True

			if pctl.playing_state != PlayingState.URL_STREAM and len(pctl.track_queue) > 0 \
			and pctl.track_queue[pctl.queue_step] == pctl.default_playlist[track_position]:
				if track_position == pctl.playlist_playing_position and pctl.active_playlist_viewing == pctl.active_playlist_playing:
					playing = True

			list_items.append(
				(0, track_position, track_object, track_box, input_box, highlight, number, drag_highlight, playing))
			number += 1

			if number >= render_rows:
				break
		# ---------------------------------------------------------------------------------------

		if align_tracklist_to_row:
			gui.playlist_scroll_pixels = 0

		# For every track in view
		# for i in range(gui.playlist_view_length + 1):
		gui.tracklist_bg_is_light = test_lumi(colours.playlist_panel_background) < 0.55
		album_art_rendered_blocks: set[int] = set()
		album_art_column_draws: list[tuple[TrackClass, tuple[float, float], int, int]] = []

		for type, track_position, tr, track_box, input_box, highlight, number, drag_highlight, playing in list_items:
			line_y = gui.playlist_top + gui.playlist_row_height * number - gui.playlist_scroll_pixels
			ddt.text_background_colour = colours.playlist_panel_background

			if type == 1:
				# Is type ALBUM TITLE
				# separator = " - "
				# if prefs.row_title_separator_type == 1:
				# 	separator = " ‒ "  # noqa: RUF003 - The separator is correct here
				# if prefs.row_title_separator_type == 2:
				# 	separator = " ⦁ "  # noqa: RUF003 - The separator is correct here
				separator = " ‒ "

				date = ""
				duration = ""

				line = tr.parent_folder_name

				# Use folder name if mixed/singles?
				if len(pctl.default_playlist) > track_position + 1 and pctl.get_track(
						pctl.default_playlist[track_position + 1]).album != tr.album and \
						pctl.get_track(pctl.default_playlist[track_position + 1]).parent_folder_path == tr.parent_folder_path:
					line = tr.parent_folder_name
				else:
					if tr.album_artist and tr.album:
						line = tr.album_artist + separator + tr.album

						if prefs.left_align_album_artist_title and not True:
							album_artist_mode = True
							line = tr.album

					if len(line) < 6 and "CD" in line:
						line = tr.album

					if prefs.append_date and year_search.search(tr.date):
						year = d_date_display2(tr)
						if not year:
							year = d_date_display(tr)
						date = "(" + year + ")"

					if line.endswith(")"):
						b = line.split("(")
						if len(b) > 1 and len(b[1]) <= 11:

							match = year_search.search(b[1])

							if match:
								line = b[0]
								date = "(" + b[1]
					elif line.startswith("("):
						b = line.split(")")
						if len(b) > 1 and len(b[0]) <= 11:

							match = year_search.search(b[0])

							if match:
								line = b[1]
								date = b[0] + ")"

					if "(" in line and year_search.search(line):
						date = ""

				line = line.replace(" - ", separator)

				qq = 0
				d_date = date
				title_line = line

				# Calculate folder duration

				q = track_position

				total_time = 0
				while q < len(pctl.default_playlist):
					if pctl.get_track(pctl.default_playlist[q]).parent_folder_path != tr.parent_folder_path:
						break

					total_time += pctl.get_track(pctl.default_playlist[q]).length

					q += 1
					qq += 1

				if qq > 1:
					duration = " [ " + get_display_time(total_time) + " ]" # Hair space inside brackets for better visual spacing

				if prefs.append_total_time:
					date += duration

				folder_title_right_pad = round(3 * gui.scale) if gui.rsp_on_left else 0
				ex = left + gui.highlight_left + highlight_width - 7 * gui.scale
				ex -= folder_title_right_pad

				height = line_y + gui.playlist_row_height - 19 * gui.scale  # gui.pl_title_y_offset

				star_offset = 0
				if gui.show_album_ratings:
					star_offset = round(72 * gui.scale)
					ex -= star_offset
					self.tauon.draw_rating_widget(
						ex + 6 * gui.scale,
						height,
						tr,
						album=True,
						allow_input=not gui.scrollbar_active,
					)

				light_offset = 0
				if colours.lm:
					light_offset = 3 * gui.scale
				ex -= light_offset

				if qq > 1:
					ex += 1 * gui.scale

				ddt.text_background_colour = colours.playlist_panel_background

				if gui.scale == 2:
					height += 1

				if highlight:
					ddt.text_background_colour = alpha_blend(
						colours.row_select_highlight,
						colours.playlist_panel_background)
					ddt.rect_a(
						(left + gui.highlight_left, gui.playlist_top + gui.playlist_row_height * number - gui.playlist_scroll_pixels),
						(highlight_width, gui.playlist_row_height), colours.row_select_highlight)

				#logging.info(d_date) # date of album release / release year
				#logging.info(tr.parent_folder_name) # folder name
				#logging.info(tr.album)
				#logging.info(tr.artist)
				#logging.info(tr.album_artist)
				#logging.info(tr.genre)

				if prefs.row_title_format == 2:
					separator = " | "

					start_offset = round(15 * gui.scale)
					xx = left + gui.highlight_left + start_offset
					ww = highlight_width

					was = False
					run = 0
					duration = get_display_time(total_time)
					colour = copy.deepcopy(colours.folder_title)
					colour.a = max(colour.a - 50, 0)

					if prefs.append_total_time and duration:
						was = True
						run += ddt.text(
							(ex - run, height, 1), duration, colour,
							gui.row_font_size + gui.pl_title_font_offset)
					if d_date:
						if was:
							run += ddt.text(
								(ex - run, height, 1), separator, colour,
								gui.row_font_size + gui.pl_title_font_offset)
						was = True
						run += ddt.text(
							(ex - run, height, 1), d_date.rstrip(")").lstrip("("), colour,
							gui.row_font_size + gui.pl_title_font_offset)
					if tr.genre and prefs.row_title_genre:
						if was:
							run += ddt.text(
								(ex - run, height, 1), separator, colour,
								gui.row_font_size + gui.pl_title_font_offset)
						was = True
						run += ddt.text(
							(ex - run, height, 1), tr.genre, colour,
							gui.row_font_size + gui.pl_title_font_offset)

					w2 = ddt.text((xx, height), title_line, colours.folder_title, gui.row_font_size + gui.pl_title_font_offset, max_w=ww - (start_offset + run + round(10 * gui.scale) + folder_title_right_pad))
				else:
					date_w = 0
					if date:
						date_w = ddt.text(
							(ex, height, 1), date, colours.folder_title,
							gui.row_font_size + gui.pl_title_font_offset)
						date_w += 4 * gui.scale
						if qq > 1:
							date_w -= 1 * gui.scale

					aa = 0

					ft_width = ddt.get_text_w(line, gui.row_font_size + gui.pl_title_font_offset)
					left_align = highlight_width - date_w - 13 * gui.scale - light_offset
					left_align -= star_offset
					extra = aa
					left_align -= extra

					if ft_width > left_align:
						date_w += 19 * gui.scale
						ddt.text(
							(left + gui.highlight_left + 8 * gui.scale + extra, height), line,
							colours.folder_title,
							gui.row_font_size + gui.pl_title_font_offset,
							highlight_width - date_w - extra - star_offset - folder_title_right_pad)
					else:
						ddt.text(
							(ex - date_w, height, 1), line,
							colours.folder_title,
							gui.row_font_size + gui.pl_title_font_offset)

				# -----

				# Draw separation line below title
				ddt.rect(
					(left + gui.highlight_left, line_y + gui.playlist_row_height - 1 * gui.scale, highlight_width,
					1 * gui.scale), colours.folder_line)

				# Draw blue highlight insert line
				if drag_highlight:
					ddt.rect(
						[left + gui.highlight_left, line_y + gui.playlist_row_height - 1 * gui.scale,
						highlight_width, 3 * gui.scale], ColourRGBA(135, 145, 190, 255))

				if gui.set_mode:
					start = (gui.pl_st_left + 2) * gui.scale
					if center_mode:
						start = inset_left
					elif gui.playlist_left:
						start += gui.playlist_left

					run = start
					end = start + gui.plw
					if center_mode:
						end = highlight_width + start

					for item in gui.pl_st:
						column_width = min(item[1], end - run)
						if run > end - 50 * gui.scale:
							break
						if item[0] == "Album Art":
							self._queue_previous_album_art_for_folder_title(
								track_position,
								line_y,
								run,
								column_width,
								album_art_rendered_blocks,
								album_art_column_draws)
						run += item[1]

				continue

			# Draw playing highlight
			if playing:
				ddt.rect(track_box, colours.row_playing_highlight)
				ddt.text_background_colour = alpha_blend(colours.row_playing_highlight, ddt.text_background_colour)

			# Blue drop line
			if drag_highlight:  # self.gui.playlist_hold_position != p_track:

				ddt.rect(
					[left + gui.highlight_left, line_y + gui.playlist_row_height - 1 * gui.scale, highlight_width,
					3 * gui.scale], ColourRGBA(125, 105, 215, 255))

			# Highlight
			if highlight:
				ddt.rect_a(
					(left + gui.highlight_left, line_y), (highlight_width, gui.playlist_row_height),
					colours.row_select_highlight)

				ddt.text_background_colour = alpha_blend(colours.row_select_highlight, ddt.text_background_colour)

			if not self.pctl.multi_playlist[self.pctl.active_playlist_viewing].hide_title and track_position > 0 and track_position < len(pctl.default_playlist) and tr.disc_number and tr.disc_number != "0" and tr.album and tr.disc_number != pctl.get_track(pctl.default_playlist[track_position - 1]).disc_number \
					and tr.album == pctl.get_track(pctl.default_playlist[track_position - 1]).album and tr.parent_folder_path == pctl.get_track(pctl.default_playlist[track_position - 1]).parent_folder_path:
				# Draw disc change line
				ddt.rect(
					(left + gui.highlight_left, line_y + 0 * gui.scale, highlight_width,
					1 * gui.scale), colours.folder_line)

			if not gui.set_mode:
				left_trim = round(9 * gui.scale) if gui.custom_mode else 0
				tauon.line_render(
					tr, track_position, gui.playlist_text_offset + line_y,
					playing, 255, left + inset_left - left_trim, inset_width + left_trim, 1, line_y)
			else:
				# NEE ---------------------------------------------------------
				n_track = tr
				p_track = track_position
				this_line_playing = playing

				start = (gui.pl_st_left + 2) * gui.scale

				if center_mode:
					start = inset_left

				elif gui.playlist_left:
					start += gui.playlist_left

				run = start
				end = start + gui.plw

				if center_mode:
					end = highlight_width + start

				# gui.tracklist_center_mode = center_mode
				# gui.tracklist_inset_left = inset_left - round(20 * gui.scale)
				# gui.tracklist_inset_width = inset_width + round(20 * gui.scale)

				magnet_layout, magnet_seps = self.compute_magnet_layout(start, end, n_track, p_track)

				for h, item in enumerate(gui.pl_st):
					wid = item[1] - 20 * gui.scale
					column_width = min(item[1], end - run)
					y = gui.playlist_text_offset + gui.playlist_top + gui.playlist_row_height * number - gui.playlist_scroll_pixels
					ry = gui.playlist_top + gui.playlist_row_height * number - gui.playlist_scroll_pixels

					if run > end - 50 * gui.scale and h not in magnet_layout:
						# Magnet-packed text lives within the group's clamped span, so
						# it stays on-screen even when this column's own run does not.
						break

					if len(gui.pl_st) == h + 1:
						wid -= 6 * gui.scale

					if item[0] == "Album Art":
						self._queue_album_art_column(
							track_position,
							ry,
							run,
							column_width,
							album_art_rendered_blocks,
							album_art_column_draws)

					if item[0] == "Rating":
						if wid > 45 * gui.scale:
							yy = ry + (gui.playlist_row_height // 2) - (6 * gui.scale)
							self.tauon.draw_rating_widget(run + 4 * gui.scale, yy, n_track)

					if item[0] == "Starline":
						total = self.star_store.get_by_object(n_track)

						if total > 0 and n_track.length != 0 and wid > 0:
							ratio = total / n_track.length
							if ratio > 0.55:
								star_x = int(ratio * (4 * gui.scale))
								star_x = min(star_x, wid)

								colour = colours.star_line
								if playing and colours.star_line_playing is not None:
									colour = colours.star_line_playing

								sy = (gui.playlist_top + gui.playlist_row_height * number - gui.playlist_scroll_pixels) + int(
									gui.playlist_row_height / 2)
								ddt.rect((run + 4 * gui.scale, sy, star_x, 1 * gui.scale), colour)
					else:
						text = ""
						font = gui.row_font_size
						colour = ColourRGBA(200, 200, 200, 255)
						norm_colour = colour
						y_off = 0
						if item[0] == "Title":
							colour = colours.title_text
							if n_track.title:
								text = n_track.title
							else:
								text = n_track.filename
							#     colour = colours.index_playing
							if this_line_playing is True:
								colour = colours.title_playing

						elif item[0] == "Artist":
							text = n_track.artist
							colour = colours.artist_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.artist_playing
						elif item[0] == "Album":
							text = n_track.album
							colour = colours.album_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.album_playing
						elif item[0] == "Album Artist":
							text = n_track.album_artist
							if not text and prefs.column_aa_fallback_artist:
								text = n_track.artist
							colour = colours.artist_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.artist_playing
						elif item[0] == "Composer":
							text = n_track.composer
							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing
						elif item[0] == "Comment":
							text = n_track.comment.replace("\n", " ").replace("\r", " ")
							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing
						elif item[0] == "S":
							if n_track.lfm_scrobbles > 0:
								text = str(n_track.lfm_scrobbles)

							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing
						elif item[0] == "#":

							if prefs.use_absolute_track_index and pctl.multi_playlist[pctl.active_playlist_viewing].hide_title:
								text = str(p_track)
							else:
								text = tauon.track_number_process(n_track.track_number)

							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing
						elif item[0] == "Date":
							text = n_track.date
							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing
						elif item[0] == "Filepath":
							text = clean_string(n_track.fullpath)
							colour = colours.index_text
							norm_colour = colour
						elif item[0] == "Filename":
							text = clean_string(n_track.filename)
							colour = colours.index_text
							norm_colour = colour
						elif item[0] == "Disc":
							text = str(n_track.disc_number)
							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing
						elif item[0] == "Codec":
							text = n_track.file_ext
							if text == "JELY" and tr.container is not None:
								text = tr.container
							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing
						elif item[0] == "Lyrics":
							text = ""
							if n_track.lyrics:
								text = "✓"
							if n_track.synced:
								text = "⧗"
							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing
						elif item[0] == "CUE":
							text = ""
							if n_track.is_cue:
								text = "✓"
							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing
						elif item[0] == "Genre":
							text = n_track.genre
							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing
						elif item[0] == "ID":
							text = str(n_track.index)
							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing
						elif item[0] == "=/=":
							text = get_modify_date_string( n_track.modified_time )
							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing
						elif item[0] == "Bitrate":
							text = str(n_track.bitrate)
							if text == "0":
								text = ""

							ex = n_track.file_ext
							if n_track.container is not None:
								ex = n_track.container
							if ex in ("FLAC", "WAV", "APE"):
								text = str(round(n_track.samplerate / 1000, 1)).rstrip("0").rstrip(".") + "|" + str(
									n_track.bit_depth)
							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing
						elif item[0] == "Time":
							text = get_display_time(n_track.length)
							colour = colours.bar_time
							norm_colour = colour
							# colour = colours.time_text
							if this_line_playing is True:
								colour = colours.time_text
						elif item[0] == "❤":
							# col love
							u = 5 * gui.scale
							yy = ry + (gui.playlist_row_height // 2) - (5 * gui.scale)
							if gui.scale == 1.25:
								yy += 1

							if tauon.get_love(n_track):
								j = 0  # justify right
								if run < start + 100 * gui.scale:
									j = 1  # justify left
								self.tauon.display_you_heart(run + 6 * gui.scale, yy, j)
								u += 18 * gui.scale

							count = 0
							for name in n_track.lfm_friend_likes:
								spacing = 6 * gui.scale
								if u + (gui.heart_row_icon.w + spacing) * count > wid + 7 * gui.scale:
									break

								x = run + u + (gui.heart_row_icon.w + spacing) * count

								j = 0  # justify right
								if run < start + 100 * gui.scale:
									j = 1  # justify left

								self.tauon.display_friend_heart(x, yy, name, j)
								count += 1

							# if n_track.track_number == 1 or n_track.track_number == "1":
							#     ss = wid - (wid % 15)
							#     tauon.gall_ren.render(n_track, (run, y), ss)


						elif item[0] == "P":
							ratio = 0
							total = self.star_store.get_by_object(n_track)
							if total > 0 and n_track.length > 2:
								if n_track.length > 15:
									total += 2
								ratio = total / (n_track.length - 1)

							text = str(int(ratio))
							if text == "0":
								text = ""
							colour = colours.index_text
							norm_colour = colour
							if this_line_playing is True:
								colour = colours.index_playing

						if prefs.dim_art and prefs.album_mode and \
								n_track.parent_folder_name \
								!= pctl.master_library[pctl.track_queue[pctl.queue_step]].parent_folder_name:
							colour = alpha_mod(colour, 150)
						if n_track.found is False:
							colour = colours.playlist_text_missing

						if text:
							if item[0] in colours.column_colours:
								colour = colours.column_colours[item[0]]

							if this_line_playing and item[0] in colours.column_colours_playing:
								colour = colours.column_colours_playing[item[0]]

							text_x = run + 6 * gui.scale
							if h in magnet_layout:
								# Magnet Mode: draw packed after the previous column's text
								text_x, wid = magnet_layout[h]
							elif run + 6 * gui.scale + wid > end:
								wid = end - run - 40 * gui.scale
								if center_mode:
									wid += 25 * gui.scale

							col_left = text_x - 6 * gui.scale
							wid = max(0, wid)

							# # Hacky. Places a dark background behind light text for readability over mascot
							# if pl_bg and gui.set_mode and colour_value(norm_colour) < 400 and not colours.lm:
							# 	w, h = ddt.get_text_wh(text, font, wid)
							# 	quick_box = [run + round(5 * gui.scale), y + y_off, w + round(2 * gui.scale), h]
							# 	if coll_rect((left + width - pl_bg.w - 60 * gui.scale, window_size[1] - gui.panelBY - pl_bg.h, pl_bg.w, pl_bg.h), quick_box):
							# 		quick_box = (run, ry, item[1], gui.playlist_row_height)
							# 		ddt.rect(quick_box, [0, 0, 0, 40], True)
							# 		ddt.rect(quick_box, alpha_mod(colours.playlist_panel_background, 150), True)

							ddt.text(
								(text_x, y + y_off),
								text,
								colour,
								font,
								max_w=wid)

							# Magnet Mode: draw a " - " separator after this column when
							# the next packed column shares its colour (readability).
							if h in magnet_seps:
								ddt.text(
									(magnet_seps[h], y + y_off),
									" - ",
									colour,
									font)

							if ddt.was_truncated:
								#logging.info(text)
								rect = (col_left, y, wid - 1, gui.playlist_row_height - 1)
								gui.heart_fields.append(rect)

								if self.coll(rect):
									self.tauon.columns_tool_tip.set(col_left - 7 * gui.scale, y, text, font, rect)

					run += item[1]

			# -----------------------------------------------------------------
			# Count the number if visible tracks (used by Show Current function)
			if gui.playlist_top + gui.playlist_row_height * w > window_size[0] - gui.panelBY - gui.playlist_row_height:
				pass
			else:
				cv += 1

			# w += 1
			# if w > gui.playlist_view_length:
			#     break

		for track, location, art_size, draw_height in album_art_column_draws:
			tauon.gall_ren.render(track, location, art_size, max_height=draw_height)

		# This is a bit hacky since its only generated after drawing
		# Used to keep track of how many tracks are actually in view
		gui.playlist_current_visible_tracks = cv
		gui.playlist_current_visible_tracks_id = pctl.multi_playlist[pctl.active_playlist_viewing].uuid_int

		if (inp.right_click and gui.playlist_top + 5 * gui.scale + gui.playlist_row_height * len(list_items) <
				self.inp.mouse_position[1] < window_size[1] - 55 and width + left > self.inp.mouse_position[0] > gui.playlist_left + 15):
			tauon.playlist_menu.activate()

		sdl3.SDL_SetRenderTarget(self.renderer, gui.main_texture)
		self._blit_tracklist()

		if self.inp.mouse_down is False:
			self.gui.playlist_hold = False

		ddt.pretty_rect = None
		ddt.alpha_bg = False

	def _blit_tracklist(self) -> None:
		"""Copy the tracklist texture to the main texture. In the preset path the
		whole texture is copied; with a clip rect (Custom Layout) only that segment
		is copied so the tracklist can't draw past its region."""
		if self._clip_rect is not None:
			cx, cy, cw, ch = self._clip_rect
			r = sdl3.SDL_FRect(cx, cy, cw, ch)
			sdl3.SDL_RenderTexture(self.renderer, self.gui.tracklist_texture, r, r)
		else:
			sdl3.SDL_RenderTexture(self.renderer, self.gui.tracklist_texture, None, self.gui.tracklist_texture_rect)

	def cache_render(self) -> None:
		self.update_album_rating_hover()
		self._blit_tracklist()

	def update_album_rating_hover(self) -> None:
		gui = self.gui
		gui.album_rating_hover = False

		if (
			not gui.show_album_ratings
			or gui.combo_mode
			or not gui.show_playlist
			or not self.prefs.break_enable
			or not self.pctl.multi_playlist
			or self.pctl.active_playlist_viewing >= len(self.pctl.multi_playlist)
			or self.pctl.multi_playlist[self.pctl.active_playlist_viewing].hide_title
		):
			return

		boundary = 3 * gui.scale
		if not gui.playlist_top + boundary < self.inp.mouse_position[1] <= self.window_size[1] - gui.panelBY - boundary:
			return

		track_position = max(self.pctl.playlist_view_position, 0)
		number = 0
		render_rows = gui.playlist_view_length + 2
		playlist_length = len(self.pctl.default_playlist)

		while track_position < playlist_length and number < render_rows:
			if self._folder_title_would_appear(track_position):
				line_y = gui.playlist_top + gui.playlist_row_height * number - gui.playlist_scroll_pixels
				rating_x = (
					gui.playlist_left
					+ gui.tracklist_highlight_left
					+ gui.tracklist_highlight_width
					- 7 * gui.scale
					- (round(3 * gui.scale) if gui.rsp_on_left else 0)
					- round(72 * gui.scale)
					+ 6 * gui.scale
				)
				rating_y = line_y + gui.playlist_row_height - 19 * gui.scale
				rating_rect = (
					rating_x - round(5 * gui.scale),
					rating_y - round(4 * gui.scale),
					round(80 * gui.scale),
					round(24 * gui.scale),
				)
				if self.coll(rating_rect):
					gui.album_rating_hover = True
					return
				number += 1

			number += 1
			track_position += 1

class ArtBox:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon   = tauon
		self.gui     = tauon.gui
		self.inp     = tauon.inp
		self.ddt     = tauon.ddt
		self.pctl    = tauon.pctl
		self.coll    = tauon.coll
		self.fields  = tauon.fields
		self.colours = tauon.colours

	def draw(self, x: int, y: int, w: int, h: int, target_track: TrackClass | None = None, tight_border: bool = False, default_border: tuple[int, int, int, int] | None = None, inset: bool = True, quick_draw: bool = False) -> None:
		tauon   = self.tauon
		ddt     = self.ddt
		colours = self.colours
		gui     = self.gui
		inp     = self.inp

		# Draw a background for whole area
		ddt.clear_rect((x, y, w, h))
		ddt.rect((x, y, w, h), colours.side_panel_background)
		# ddt.rect_r((x, y, w ,h), [255, 0, 0, 200], True)

		# We need to find the size of the inner square for the artwork
		# box = min(w, h)

		box_w = w
		box_h = h

		if inset:  # The Custom Layout Art Box passes inset=False (its gutter spaces it)
			box_w -= 17 * gui.scale  # Inset the square a bit
			box_h -= 17 * gui.scale  # Inset the square a bit

		box_x = x + ((w - box_w) // 2)
		box_y = y + ((h - box_h) // 2)

		# And position the square
		rect = (box_x, box_y, box_w, box_h)
		gui.main_art_box = rect

		# Draw the album art. If side bar is being dragged set quick draw flag
		showc = None
		result = 1
		show_vis = False

		if target_track:  # Only show if song playing or paused

			result = tauon.album_art_gen.display(target_track, (rect[0], rect[1]), (box_w, box_h), gui.side_drag or quick_draw, async_hold=True, caller_id="art_box")
			showc = tauon.album_art_gen.get_info(target_track)

			# Milkdrop visualiser
			# code mirrored in l_panel
			if tauon.prefs.milk and not gui.milkdrop_in_widget and self.tauon.pctl.playing_state in (PlayingState.PLAYING, PlayingState.URL_STREAM, PlayingState.PAUSED):
				if self.pctl.a_time < 1.3:
					if 1 < self.pctl.a_time < 1.3:
						tauon.milky.render(discard=True)
						tauon.milky.burn(target_track)
				else:
					tauon.milky.render()
					show_vis = True
				if self.tauon.pctl.playing_state != PlayingState.PAUSED:
					#gui.update += 1
					gui.delay_frame(self.tauon.frame_pace())

		# Draw faint border on album art
		if tight_border and not show_vis:
			if result == 0 and gui.art_drawn_rect:
				border = gui.art_drawn_rect
				ddt.rect_s(gui.art_drawn_rect, colours.art_box, 1 * gui.scale)
			elif default_border:
				border = default_border
				ddt.rect_s(default_border, colours.art_box, 1 * gui.scale)
			else:
				border = rect
		else:
			ddt.rect_s(rect, colours.art_box, 1 * gui.scale)
			border = rect

		self.fields.add(border)

		# Draw image downloading indicator
		if gui.image_downloading:
			ddt.text(
				(x + int(box_w / 2), 38 * gui.scale + int(box_h / 2), 2), _("Fetching image..."),
				colours.side_bar_line1,
				14, bg=colours.side_panel_background)
			gui.update = 2

		# Input for album art
		if target_track:
			# Cycle images on click
			if self.coll(gui.main_art_box) and inp.mouse_click is True and inp.key_focused == 0:

				if show_vis:
					tauon.milky.projectm.load_next = "random"
				else:
					tauon.album_art_gen.cycle_offset(target_track)

					if self.pctl.mpris:
						self.pctl.mpris.update(force=True)

		# Activate picture context menu on right click
		if inp.right_click and tauon.prefs.milk and not gui.milkdrop_in_widget and self.coll(rect):
			self.tauon.milky_menu.activate(in_reference=target_track)
		elif tight_border and gui.art_drawn_rect:
			if inp.right_click and self.coll(gui.art_drawn_rect) and target_track:
				self.tauon.picture_menu.activate(in_reference=target_track)
		elif inp.right_click and self.coll(rect) and target_track:
			self.tauon.picture_menu.activate(in_reference=target_track)

		# Draw picture metadata
		if showc is not None and self.coll(border) \
			and tauon.rename_track_box.active is False \
			and tauon.radiobox.active is False \
			and tauon.pref_box.enabled is False \
			and gui.rename_playlist_box is False \
			and gui.message_box is False \
			and gui.track_box is False \
			and gui.layer_focus == 0:

			padding = 6 * gui.scale

			xw = box_x + box_w
			yh = box_y + box_h
			if tight_border and gui.art_drawn_rect and gui.art_drawn_rect[2] > 50 * gui.scale:
				xw = gui.art_drawn_rect[0] + gui.art_drawn_rect[2]
				yh = gui.art_drawn_rect[1] + gui.art_drawn_rect[3]

			if not show_vis:
				self.tauon.art_metadata_overlay(xw, yh, showc)

			if show_vis:
				line = tauon.milky.projectm.get_current_name()

				padding = round(0 * gui.scale)
				xx = x + round(12 * gui.scale)
				if gui.custom_mode:
					# Match the Custom Layout Milkdrop widget's tag position
					xx -= 5
				yy = y + round(25 * gui.scale)
				mw = box_w - round(25 * gui.scale)
				tag_width, tag_height = self.ddt.get_text_wh(line, 312, max_x = mw)
				tag_width += round(17 * self.gui.scale)

				self.ddt.rect_a((xx, yy), (tag_width, 18 * self.gui.scale),
								ColourRGBA(8, 8, 8, 255))
				self.ddt.text(((xx) + (6 * self.gui.scale + padding), yy), line, ColourRGBA(220, 220, 220, 255),
							  312, bg=ColourRGBA(30, 30, 30, 255), max_w = mw)

				if self.tauon.prefs.auto_milk:
					line = _("Auto Cycle")
					yy += round(30 * gui.scale)
					tag_width, tag_height = self.ddt.get_text_wh(line, 12, max_x = mw)
					tag_width += round(14 * self.gui.scale)

					self.ddt.rect_a(
						(xx, yy), (tag_width, 18 * self.gui.scale),
						ColourRGBA(8, 8, 8, 255))
					self.ddt.text(
						((xx) + (6 * self.gui.scale + padding), yy), line, ColourRGBA(210, 210, 210, 255),
						12, bg=ColourRGBA(30, 30, 30, 255), max_w = mw)

				if self.tauon.pctl.playing_state not in (PlayingState.PLAYING, PlayingState.URL_STREAM):
					tauon.milky.fps.reset()
				line = f"FPS: {round(tauon.milky.fps.get())}"
				yy += round(30 * gui.scale)
				tag_width, tag_height = self.ddt.get_text_wh(line, 12, max_x = mw)
				tag_width += round(14 * self.gui.scale)

				self.ddt.rect_a((xx, yy), (tag_width, 18 * self.gui.scale),
								ColourRGBA(8, 8, 8, 255))
				self.ddt.text(((xx) + (6 * self.gui.scale + padding), yy), line, ColourRGBA(210, 210, 210, 255),
							  12, bg=ColourRGBA(30, 30, 30, 255), max_w = mw)

class ScrollBox:

	def __init__(self, tauon: Tauon, pctl: PlayerCtl) -> None:
		self.tauon     = tauon
		self.pctl      = pctl
		self.gui       = tauon.gui
		self.inp       = tauon.inp
		self.ddt       = tauon.ddt
		self.coll      = tauon.coll
		self.fields    = tauon.fields
		self.colours   = tauon.colours
		self.t_window  = tauon.t_window
		self.input_sdl = tauon.input_sdl
		self.held = False
		self.slide_hold = False
		self.source_click_y = 0
		self.source_bar_y = 0
		self.direction_lock = -1
		self.d_position = 0

	def draw(
		self, x: int, y: int, w: int, h: int, value: float, max_value: float, force_dark_theme: bool = False, click: bool | None = None, r_click: bool = False, jump_distance: int = 4, extend_field: int = 0) -> float:
		if max_value <= 0:
			return 0

		if click is None:
			click = self.inp.mouse_click

		bar_height = round(90 * self.gui.scale)

		if h > 400 * self.gui.scale and max_value < 20:
			bar_height = round(180 * self.gui.scale)

		bg     = ColourRGBA(255, 255, 255, 7)
		fg     = ColourRGBA(255, 255, 255, 30)
		fg_h   = ColourRGBA(255, 255, 255, 40)
		fg_off = ColourRGBA(255, 255, 255, 15)

		if self.colours.lm and not force_dark_theme:
			bg     = ColourRGBA(0, 0, 0, 15)
			fg_off = ColourRGBA(0, 0, 0, 30)
			fg     = ColourRGBA(0, 0, 0, 60)
			fg_h   = ColourRGBA(0, 0, 0, 70)

		self.ddt.rect((x, y, w, h), bg)

		half = bar_height // 2

		ratio = value / max_value

		mi = y + half
		mo = y + h - half
		distance = mo - mi
		position = round(distance * ratio)

		fw = w + extend_field
		fx = x - extend_field

		if self.coll((fx, y, fw, h)):
			if self.inp.mouse_down:
				self.gui.update += 1

			if r_click:
				p = self.inp.mouse_position[1] - half - y
				p = max(0, p)

				range = h - bar_height
				p = min(p, range)

				per = p / range

				value = round(max_value * per)

				ratio = value / max_value

				mi = y + half
				mo = y + h - half
				distance = mo - mi
				position = round(distance * ratio)

			in_bar = False
			if self.coll((x, mi + position - half, w, bar_height)):
				in_bar = True
				if click:
					self.held = True

					# p_y = pointer(c_int(0))
					# sdl3.SDL_GetGlobalMouseState(None, p_y)
					self.input_sdl.mouse_capture_want = True
					self.source_click_y = self.inp.mouse_position[1]
					self.source_bar_y = position

			if self.pctl.playlist_view_position < 0:
				self.pctl.playlist_view_position = 0
			elif self.inp.mouse_down and not self.held:
				if click and not in_bar:
					self.slide_hold = True
					self.direction_lock = 1
					if self.inp.mouse_position[1] - y < position:
						self.direction_lock = 0

					self.d_position = value / max_value

				if self.slide_hold:
					if (self.direction_lock == 1 and self.inp.mouse_position[1] - y < position + half) or \
							(self.direction_lock == 0 and self.inp.mouse_position[1] - y > position + half):
						pass
					else:

						tt = self.tauon.scroll_timer.hit()
						if tt > 0.1:
							tt = 0

						flip = -1
						if self.direction_lock:
							flip = 1

						self.d_position = min(max(self.d_position + (((tt * jump_distance) / max_value) * flip), 0), 1)
			else:
				self.slide_hold = False

		if (self.held and self.inp.mouse_up) or not self.inp.mouse_down:
			self.held = False

		if self.held and not window_is_focused(self.t_window):
			self.held = False

		if self.held:
			self.input_sdl.mouse_capture_want = True
			new_y = self.inp.mouse_position[1]
			self.gui.update += 1

			offset = new_y - self.source_click_y

			position = self.source_bar_y + offset

			position = max(position, 0)
			position = min(position, distance)

			ratio = position / distance
			value = max_value * ratio

		colour = fg_off
		rect = (x, mi + position - half, w, bar_height)
		self.fields.add(rect)
		if self.coll(rect):
			colour = fg
		if self.held:
			colour = fg_h

		self.ddt.rect(rect, colour)

		if self.slide_hold:
			return round(max_value * self.d_position)

		return value

class RadioBox:

	def __init__(self, tauon: Tauon, pctl: PlayerCtl) -> None:
		self.pctl: PlayerCtl           = pctl
		self.tauon: Tauon          = tauon
		self.ddt: TDraw            = tauon.ddt
		self.inp: Input            = tauon.inp
		self.gui: GuiVar            = tauon.gui
		self.coll           = tauon.coll
		self.draw: Drawing           = pctl.draw
		self.prefs: Prefs          = tauon.prefs
		self.fields: Fields         = tauon.fields
		self.colours: ColoursClass        = tauon.colours
		self.window_size: list[int]    = tauon.window_size
		self.show_message   = tauon.show_message
		self.smooth_scroll: SmoothScroll  = tauon.smooth_scroll
		self.thread_manager: ThreadManager = tauon.thread_manager
		self.active: bool = False
		self.station_editing: RadioStation | None = None
		self.edit_mode: bool = True
		self.add_mode: bool = False
		self.radio_field_active: int = 1
		self.radio_field: TextBox2        = TextBox2(tauon)
		self.radio_field_title: TextBox2  = TextBox2(tauon)
		self.radio_field_search: TextBox2 = TextBox2(tauon)

		self.x = 1
		self.y = 1
		self.w = 1
		self.h = 1
		self.center: bool = False

		self.scroll_position: int = 0
		self.scroll: ScrollBox = ScrollBox(tauon=tauon, pctl=pctl)

		self.dummy_track: TrackClass = TrackClass()
		self.dummy_track.index = -2
		self.dummy_track.is_network = True
		self.dummy_track.art_url_key = ""  # radio"
		self.dummy_track.file_ext = "RADIO"
		self.playing_title: str = ""

		self.proxy_started: bool = False
		self.loaded_url: str | None = None
		self.loaded_station: RadioStation | None = None
		self.load_connecting: bool = False
		self.load_failed: bool = False
		self.load_request_id: int = 0
		self.searching: bool = False
		self.load_failed_timer: Timer = Timer()
		self.right_clicked_station: RadioStation | None = None
		self.right_clicked_station_p: int | None = None
		self.click_point = (0, 0)

		self.song_key = ""

		self.drag = None

		self.tab = 0
		self.temp_list: list[RadioStation] = []

		self.hosts = None
		self.host = None

		self.search_menu = Menu(tauon, 170)
		self.search_menu.add(MenuItem(_("Search Tag"), self.search_tag, pass_ref=True))
		self.search_menu.add(MenuItem(_("Search Country Code"), self.search_country, pass_ref=True))
		self.search_menu.add(MenuItem(_("Search Title"), self.search_title, pass_ref=True))

		self.websocket = None
		self.ws_interval = 4.5
		self.websocket_source_urls = ("https://listen.moe/kpop/stream", "https://listen.moe/stream")
		self.run_proxy: bool = True

	def parse_vorbis_okay(self) -> bool:
		return (
			self.loaded_url not in self.websocket_source_urls) and \
			"radio.plaza.one" not in self.loaded_url and \
			"gensokyoradio.net" not in self.loaded_url

	def search_country(self, text: str) -> None:
		if len(text) == 2 and text.isalpha():
			self.search_radio_browser(
				f"/json/stations/search?countrycode={text}&order=votes&limit=250&reverse=true")
		else:
			self.search_radio_browser(
				f"/json/stations/search?country={text}&order=votes&limit=250&reverse=true")

	def search_tag(self, text) -> None:
		text = text.lower()
		self.search_radio_browser(f"/json/stations/search?order=votes&limit=250&reverse=true&tag={text}")

	def search_title(self, text) -> None:
		text = text.lower()
		self.search_radio_browser(f"/json/stations/search?order=votes&limit=250&reverse=true&name={text}")

	def is_m3u(self, url: str) -> bool:
		return url.lower().endswith(".m3u") or url.lower().endswith(".m3u8")

	def extract_stream_m3u(self, url: str, recursion_limit: int = 5) -> str | None:
		if recursion_limit <= 0:
			return None
		logging.info("Fetching M3U...")

		try:
			response = requests.get(url, timeout=10)
			if response.status_code != 200:
				logging.error(f"M3U Fetch error code: {response.status_code}")
				return None

			content = response.text
			lines = content.strip().split("\n")

			for line in lines:
				line = line.strip()
				if not line.startswith("#") and len(line) > 0:
					if self.is_m3u(line):
						next_url = urllib.parse.urljoin(url, line)
						return self.extract_stream_m3u(next_url, recursion_limit - 1)
					return urllib.parse.urljoin(url, line)

			return None

		except Exception:
			logging.exception("Failed to extract M3U")
			return None

	def abort_load(self, clear_station: bool = True) -> None:
		logging.info(
			f"Radio load abort requested clear_station={clear_station} "
			f"load_request_id={self.load_request_id} load_connecting={self.load_connecting} "
			f"loaded_url={self.loaded_url}"
		)
		self.load_request_id += 1
		self.load_connecting = False
		self.load_failed = False
		self.loaded_url = None
		if clear_station:
			self.loaded_station = None
		if self.websocket:
			self.websocket.close()
			logging.info("Websocket closed")
		self.tauon.stream_proxy.stop()
		self.pctl.record_stream = False
		self.gui.update += 1

	def start(self, station: RadioStation) -> None:
		url = station.stream_url
		logging.info(
			f"Start radio station title={station.title!r} url={url!r} "
			f"load_request_id={self.load_request_id} load_connecting={self.load_connecting} "
			f"playing_state={self.pctl.playing_state}"
		)
		# Otherwise we'll be mute if we're starting from a paused state
		self.pctl.set_volume()
		if self.is_m3u(url):
			url = self.extract_stream_m3u(url)
			logging.info(f"Extracted URL is: {url}")
			if not url:
				logging.info("Failed to extract stream from M3U")
				return

		if self.load_connecting:
			self.abort_load(clear_station=False)

		if self.websocket:
			self.websocket.close()
			logging.info("Websocket closed")

		self.playing_title = ""
		self.playing_title = station.title
		self.dummy_track.art_url_key = ""
		self.dummy_track.title = ""
		self.dummy_track.artist = ""
		self.dummy_track.album = ""
		self.dummy_track.date = ""
		self.pctl.radio_meta_on = ""

		self.tauon.album_art_gen.clear_cache()

		if not self.tauon.test_ffmpeg():
			self.prefs.auto_rec = False
			return

		self.run_proxy = True
		if url.endswith(".ts"):
			self.run_proxy = False

		if self.run_proxy and not self.proxy_started and self.prefs.backend != Backend.PHAZOR:
			shoot = threading.Thread(target=stream_proxy, args=[self.tauon])
			shoot.daemon = True
			shoot.start()
			self.proxy_started = True

		# self.pctl.url = url
		self.pctl.url = f"http://127.0.0.1:{7812}"
		if not self.run_proxy:
			self.pctl.url = station.stream_url
		self.loaded_url = None
		self.pctl.tag_meta = ""
		self.pctl.radio_meta_on = ""
		self.pctl.found_tags = {}
		self.song_key = ""
		self.pctl.playing_time = 0
		self.pctl.decode_time = 0
		self.loaded_station = station

		if self.tauon.stream_proxy.download_running or self.tauon.stream_proxy.encode_running or self.tauon.stream_proxy.pump_running:
			logging.info(
				f"Stopping existing radio stream before new request: "
				f"{self.tauon.stream_proxy.state_log()}"
			)
			self.tauon.stream_proxy.stop()

		self.load_request_id += 1
		request_id = self.load_request_id
		self.load_connecting = True
		self.load_failed = False
		logging.info(f"Radio start request queued request_id={request_id} run_proxy={self.run_proxy} url={url!r}")

		shoot = threading.Thread(target=self.start2, args=[url, request_id, self.run_proxy])
		shoot.daemon = True
		shoot.start()

	def start2(self, url: str, request_id: int, run_proxy: bool) -> None:
		logging.info(
			f"Radio start2 entered request_id={request_id} current_request={self.load_request_id} "
			f"run_proxy={run_proxy} url={url!r}"
		)
		if request_id != self.load_request_id:
			logging.info(f"Radio start2 ignoring stale request_id={request_id}")
			return

		if run_proxy and not self.tauon.stream_proxy.start_download(url, request_id):
			if request_id == self.load_request_id:
				self.load_failed_timer.set()
				self.load_failed = True
				self.load_connecting = False
				self.gui.update += 1
				logging.error(
					f"Starting radio failed request_id={request_id}: "
					f"{self.tauon.stream_proxy.state_log()}"
				)
			# self.show_message(_("Failed to establish a connection"), mode="error")
			return

		if request_id != self.load_request_id:
			logging.info(
				f"Radio start2 became stale after download start request_id={request_id} "
				f"current_request={self.load_request_id}"
			)
			if run_proxy and self.tauon.stream_proxy.request_id == request_id:
				self.tauon.stream_proxy.stop()
			return

		logging.info(f"Radio start2 activating playback request_id={request_id} url={url!r}")
		self.loaded_url = url
		self.pctl.record_stream = False
		self.pctl.playerCommand = "url"
		self.pctl.playerCommandReady = True
		self.pctl.playing_state = PlayingState.URL_STREAM
		self.pctl.playing_time = 0
		self.pctl.decode_time = 0
		self.pctl.playing_length = 0
		self.pctl.windows_progress.update()
		self.tauon.thread_manager.ready_playback()
		# ensure RPC is started and woken immediately for radio start
		try:
			self.tauon._signal_discord()
		except Exception:
			self.tauon.hit_discord()

		if self.tauon.update_play_lock is not None:
			self.tauon.update_play_lock()

		time.sleep(0.1)
		self.load_connecting = False
		self.load_failed = False
		self.gui.update += 1

		wss = ""
		if url == "https://listen.moe/kpop/stream":
			wss = "wss://listen.moe/kpop/gateway_v2"
		if url == "https://listen.moe/stream":
			wss = "wss://listen.moe/gateway_v2"
		if wss:
			logging.info("Connecting to Listen.moe")
			import _thread as th

			import websocket

			def send_heartbeat(ws: WebSocketApp) -> None:
				#logging.info(self.ws_interval)
				time.sleep(self.ws_interval)
				try:
					ws.send('{"op":9}')
				except Exception:
					logging.info("Websocket already closed")
				logging.info("Send heartbeat")

			def on_message(ws: WebSocketApp, message: str) -> None:
				logging.info(message)
				d = json.loads(message)
				if d["op"] == 10:
					shoot = threading.Thread(target=send_heartbeat, args=[ws])
					shoot.daemon = True
					shoot.start()

				if d["op"] == 0:
					self.ws_interval = d["d"]["heartbeat"] / 1000
					try:
						ws.send('{"op":9}')
					except Exception:
						logging.info("Websocket already closed")

				if d["op"] == 1:
					try:

						found_tags = {}
						found_tags["title"] = d["d"]["song"]["title"]
						if d["d"]["song"]["artists"]:
							found_tags["artist"] = d["d"]["song"]["artists"][0]["name"]
						line = ""
						if "title" in found_tags:
							line += found_tags["title"]
							if "artist" in found_tags:
								line = found_tags["artist"] + " - " + line

						self.pctl.found_tags = found_tags
						self.pctl.tag_meta = line

						album_image = d["d"]["song"]["albums"][0].get("image")
						if album_image:
							filename = album_image
							fulllink = "https://cdn.listen.moe/covers/" + filename

							#logging.info(fulllink)
							art_response = requests.get(fulllink, timeout=10)
							#logging.info(art_response.status_code)

							if art_response.status_code == 200:
								if self.pctl.radio_image_bin:
									self.pctl.radio_image_bin.close()
									self.pctl.radio_image_bin = None
								self.pctl.radio_image_bin = io.BytesIO(art_response.content)
								self.pctl.radio_image_bin.seek(0)
								self.dummy_track.art_url_key = "ok"
								logging.info("Got new art")

					except Exception:
						logging.exception("No image")
						if self.pctl.radio_image_bin:
							self.pctl.radio_image_bin.close()
							self.pctl.radio_image_bin = None
					self.gui.clear_image_cache_next += 1
					self.gui.update += 1

			def on_error(ws: WebSocketApp, error) -> None:
				logging.error(error)

			def on_close(ws: WebSocketApp) -> None:
				logging.info("### closed ###")

			def on_open(ws: WebSocketApp) -> None:
				def run(*args) -> None:
					pass
					# for i in range(3):
					#     time.sleep(4.5)
					#     ws.send("{\"op\":9}")
					# time.sleep(10)
					# ws.close()
					#logging.info("thread terminating...")

				th.start_new_thread(run, ())

			# websocket.enableTrace(True)
			#logging.info(wss)
			ws = websocket.WebSocketApp(wss, on_message=on_message, on_error=on_error)
			ws.on_open = on_open
			self.websocket = ws
			shoot = threading.Thread(target=ws.run_forever)
			shoot.daemon = True
			shoot.start()

	def delete_radio_entry(self, station: RadioStation) -> None:
		for i, saved in enumerate(self.prefs.radio_urls):
			if saved.stream_url == station.stream_url and saved.title == station.title:
				del self.prefs.radio_urls[i]

	def delete_radio_entry_after(self, station) -> None:
		p = self.right_clicked_station_p
		del self.prefs.radio_urls[p + 1:]

	def edit_entry(self, station: RadioStation) -> None:
		self.radio_field_title.text = station.title
		self.radio_field.text = station.stream_url

	def browser_get_hosts(self) -> list[str]:
		"""Get all base urls of all currently available radiobrowser servers

		Returns:
		list: a list of strings

		"""
		hosts = []
		# get all hosts from DNS
		ips = socket.getaddrinfo(
			"all.api.radio-browser.info", 80, 0, 0, socket.IPPROTO_TCP)
		for ip_tupple in ips:
			try:
				ip = ip_tupple[4][0]

				# do a reverse lookup on every one of the ips to have a nice name for it
				host_addr = socket.gethostbyaddr(ip)
				# add the name to a list if not already in there
				if host_addr[0] not in hosts:
					hosts.append(host_addr[0])
			except socket.herror:
				logging.exception(f"IP PTR lookup fail for {ip}")
			except Exception:
				logging.exception(f"Unknown exception - IP PTR lookup fail for {ip}")

		# sort list of names
		hosts.sort()
		# add "https://" in front to make it a url
		return list(map(lambda x: "https://" + x, hosts))

	def search_page(self) -> None:
		y = self.y
		x = self.x
		w = self.w
		h = self.h

		yy = y + round(40 * self.gui.scale)

		width = round(330 * self.gui.scale)
		rect = (x + 8 * self.gui.scale, yy - round(2 * self.gui.scale), width, 22 * self.gui.scale)
		self.fields.add(rect)
		# if (self.coll(rect) and self.gui.level_2_click) or (input.key_tab_press and self.radio_field_active == 2):
		#     self.radio_field_active = 1
		#     input.key_tab_press = False
		if not self.radio_field_search.text and not self.gui.editline:
			self.ddt.text((x + 14 * self.gui.scale, yy), _("Search text…"), self.colours.box_text_label, 312)
		self.radio_field_search.draw(
			x + 14 * self.gui.scale, yy, self.colours.box_input_text,
			active=True,
			width=width, click=self.gui.level_2_click)

		self.ddt.rect_s(rect, self.colours.box_text_border, 1 * self.gui.scale)

		if self.draw.button(
			_("Search"), x + width + round(21 * self.gui.scale), yy - round(3 * self.gui.scale),
			press=self.gui.level_2_click, w=round(80 * self.gui.scale)) or self.inp.level_2_enter:

			text = self.radio_field_search.text.replace("/", "").replace(":", "").replace("\\", "").replace(".", "").replace(
				"-", "").upper()
			text = urllib.parse.quote(text)
			if len(text) > 1:
				self.search_menu.activate(text, position=(x + width + round(21 * self.gui.scale), yy + round(20 * self.gui.scale)))
		if self.draw.button(_("Get Top Voted"), x + round(8 * self.gui.scale), yy + round(30 * self.gui.scale), press=self.gui.level_2_click):
			self.search_radio_browser("/json/stations?order=votes&limit=250&reverse=true")

		ww = self.ddt.get_text_w(_("Get Top Voted"), 212)
		if self.draw.button(_("Developer Picks"), x + ww + round(35 * self.gui.scale), yy + round(30 * self.gui.scale), press=self.gui.level_2_click):
			self.temp_list.clear()

			self.temp_list.append(
				RadioStation(
					title="Nightwave Plaza",
					stream_url_fallback="https://radio.plaza.one/ogg",
					stream_url="https://radio.plaza.one/ogg",
					website_url="https://plaza.one/",
					icon="https://plaza.one/icons/apple-touch-icon.png",
					country="Japan"))

			self.temp_list.append(
				RadioStation(
					title="Gensokyo Radio",
					stream_url_fallback="https://stream.gensokyoradio.net/GensokyoRadio-enhanced.m3u",
					stream_url="https://stream.gensokyoradio.net/1",
					website_url="https://gensokyoradio.net/",
					icon="https://gensokyoradio.net/favicon.ico",
					country="Japan"))

			self.temp_list.append(
				RadioStation(
					title="Listen.moe | Jpop",
					stream_url_fallback="https://listen.moe/stream",
					stream_url="https://listen.moe/stream",
					website_url="https://listen.moe/",
					icon="https://avatars.githubusercontent.com/u/26034028?s=200&v=4",
					country="Japan"))

			self.temp_list.append(
				RadioStation(
					title="Listen.moe | Kpop",
					stream_url_fallback="https://listen.moe/kpop/stream",
					stream_url="https://listen.moe/kpop/stream",
					website_url="https://listen.moe/",
					icon="https://avatars.githubusercontent.com/u/26034028?s=200&v=4",
					country="Korea"))

			self.temp_list.append(
				RadioStation(
					title="HBR1 Dream Factory | Ambient",
					stream_url_fallback="http://radio.hbr1.com:19800/ambient.ogg",
					stream_url="http://radio.hbr1.com:19800/ambient.ogg",
					website_url="http://www.hbr1.com/"))

			self.temp_list.append(
				RadioStation(
					title="Yggdrasil Radio | Anime & Jpop",
					stream_url_fallback="http://shirayuki.org:9200/",
					stream_url="http://shirayuki.org:9200/",
					website_url="https://yggdrasilradio.net/"))

			for station in self.tauon.primary_stations:
				self.temp_list.append(station)

	def search_radio_browser(self, param: str) -> None:
		if self.searching:
			return
		self.searching = True
		shoot = threading.Thread(target=self.search_radio_browser2, args=[param])
		shoot.daemon = True
		shoot.start()

	def search_radio_browser2(self, param: str) -> None:
		if not self.hosts:
			self.hosts = self.browser_get_hosts()
			# In case we get an empty list for some reason
			if not self.hosts:
				logging.warning("Got an empty radio list back, returning early!")
				self.searching = False
				return
		if not self.host:
			self.host = random.choice(self.hosts)

		uri = self.host + param
		req = urllib.request.Request(uri)
		req.add_header("User-Agent", self.tauon.t_agent)
		req.add_header("Content-Type", "application/json")
		response = urllib.request.urlopen(req, context=self.tauon.tls_context)
		data = response.read()
		data = json.loads(data.decode())
		self.parse_data(data)
		self.searching = False

	def parse_data(self, data: dict) -> None:
		self.temp_list.clear()
		for station in data:
			#logging.info(station)
			radio: RadioStation = RadioStation(
				title=station["name"],
				stream_url_fallback=station["url"],
				stream_url=station["url_resolved"],
				icon=station["favicon"],
				country=station["country"])
			if radio.country == "The Russian Federation":
				radio.country = "Russia"
			elif radio.country == "The United States Of America":
				radio.country = "USA"
			elif radio.country == "The United Kingdom Of Great Britain And Northern Ireland":
				radio.country = "United Kingdom"
			elif radio.country == "Islamic Republic Of Iran":
				radio.country = "Iran"
			elif len(station["country"]) > 20:
				radio.country = station["countrycode"]
			radio.website_url = station["homepage"]
			self.temp_list.append(radio)
		self.gui.update += 1

	def render(self) -> None:
		if self.edit_mode:
			w = round(510 * self.gui.scale)
			h = round(120 * self.gui.scale)  # + sh

			self.w = w
			self.h = h
			# self.x = x
			# self.y = y
			width = w
			if self.center:
				x = int(self.window_size[0] / 2) - int(w / 2)
				y = int(self.window_size[1] / 2) - int(h / 2)
				yy = y
				self.y = y
				self.x = x
			else:
				yy = self.y
				y = self.y
				x = self.x
			self.ddt.rect_a((x - 2 * self.gui.scale, y - 2 * self.gui.scale), (w + 4 * self.gui.scale, h + 4 * self.gui.scale), self.colours.box_border)
			self.ddt.rect_a((x, y), (w, h), self.colours.box_background)
			self.ddt.text_background_colour = self.colours.box_background
			if self.inp.key_esc_press or (self.gui.level_2_click and not self.coll((x, y, w, h))):
				self.active = False

			if self.add_mode:
				self.ddt.text((x + 10 * self.gui.scale, yy + 8 * self.gui.scale), _("Add Station"), self.colours.box_title_text, 213)
			else:
				self.ddt.text((x + 10 * self.gui.scale, yy + 8 * self.gui.scale), _("Edit Station"), self.colours.box_title_text, 213)

			self.saved()
			return

		w = round(510 * self.gui.scale)
		h = round(356 * self.gui.scale)  # + sh
		x = int(self.window_size[0] / 2) - int(w / 2)
		y = int(self.window_size[1] / 2) - int(h / 2)

		self.w = w
		self.h = h
		self.x = x
		self.y = y

		yy = y

		self.ddt.rect_a((x - 2 * self.gui.scale, y - 2 * self.gui.scale), (w + 4 * self.gui.scale, h + 4 * self.gui.scale), self.colours.box_border)
		self.ddt.rect_a((x, y), (w, h), self.colours.box_background)

		self.ddt.text_background_colour = self.colours.box_background

		if self.inp.key_esc_press or (self.gui.level_2_click and not self.coll((x, y, w, h))):
			self.active = False

		self.ddt.text((x + 10 * self.gui.scale, yy + 8 * self.gui.scale), _("Station Browser"), self.colours.box_title_text, 213)

		# ---
		if self.load_connecting:
			self.ddt.text((x + 495 * self.gui.scale, yy + 8 * self.gui.scale, 1), _("Connecting..."), self.colours.box_title_text, 311)
		elif self.load_failed:
			self.ddt.text((x + 495 * self.gui.scale, yy + 8 * self.gui.scale, 1), _("Failed to connect!"), self.colours.box_title_text, 311)
			if self.load_failed_timer.get() > 3:
				self.gui.delay_frame(0.2)
				self.load_failed = False
		elif self.searching:
			self.ddt.text((x + 495 * self.gui.scale, yy + 8 * self.gui.scale, 1), _("Searching..."), self.colours.box_title_text, 311)
		elif self.pctl.playing_state == PlayingState.URL_STREAM:
			text = ""
			if self.tauon.stream_proxy.s_format:
				text = str(self.tauon.stream_proxy.s_format)
			if self.tauon.stream_proxy.s_bitrate and self.tauon.stream_proxy.s_bitrate.isnumeric():
				text += " " + self.tauon.stream_proxy.s_bitrate + "kbps"

			self.ddt.text((x + 495 * self.gui.scale, yy + 8 * self.gui.scale, 1), text, self.colours.box_title_text, 311)
			# if tauon.stream_proxy.s_format:
			#     self.ddt.text((x + 425 * self.gui.scale, yy + 8 * self.gui.scale,), tauon.stream_proxy.s_format, self.colours.box_title_text, 311)
			# if tauon.stream_proxy.s_bitrate:
			#     self.ddt.text((x + 454 * self.gui.scale, yy + 8 * self.gui.scale,), tauon.stream_proxy.s_bitrate + "kbps", self.colours.box_title_text, 311)

		# --- ----------------------------------------------------------------------
		if self.tab == 1:
			self.search_page()
		elif self.tab == 0:
			self.saved()
		self.draw_list()
		# self.footer()
		return

	def saved(self) -> None:
		y = self.y
		x = self.x
		w = self.w
		h = self.h

		yy = y + round(40 * self.gui.scale)

		width = round(370 * self.gui.scale)

		rect = (x + 8 * self.gui.scale, yy - round(2 * self.gui.scale), width, 22 * self.gui.scale)
		self.fields.add(rect)
		if (self.coll(rect) and self.gui.level_2_click) or (self.inp.key_tab_press and self.radio_field_active == 2):
			self.radio_field_active = 1
			self.inp.key_tab_press = False
		if not self.radio_field_title.text and not (self.radio_field_active == 1 and self.gui.editline):
			self.ddt.text((x + 14 * self.gui.scale, yy), _("Name / Title"), self.colours.box_text_label, 312)
		self.radio_field_title.draw(
			x + 14 * self.gui.scale, yy, self.colours.box_input_text,
			active=self.radio_field_active == 1,
			width=width, click=self.gui.level_2_click)

		self.ddt.rect_s(rect, self.colours.box_text_border, 1 * self.gui.scale)

		yy += round(30 * self.gui.scale)

		rect = (x + 8 * self.gui.scale, yy - round(2 * self.gui.scale), width, 22 * self.gui.scale)
		self.ddt.rect_s(rect, self.colours.box_text_border, 1 * self.gui.scale)
		self.fields.add(rect)
		if (self.coll(rect) and self.gui.level_2_click) or (self.inp.key_tab_press and self.radio_field_active == 1):
			self.radio_field_active = 2
			self.inp.key_tab_press = False

		if not self.radio_field.text and not (self.radio_field_active == 2 and self.gui.editline):
			self.ddt.text((x + 14 * self.gui.scale, yy), _("Raw Stream URL http://example.stream:1234"), self.colours.box_text_label, 312)
		self.radio_field.draw(
			x + 14 * self.gui.scale, yy, self.colours.box_input_text, active=self.radio_field_active == 2,
			width=width, click=self.gui.level_2_click)

		if self.draw.button(_("Save"), x + width + round(21 * self.gui.scale), yy - round(20 * self.gui.scale), press=self.gui.level_2_click):
			if not self.radio_field.text:
				self.show_message(_("Enter a stream URL"))
			elif "http://" in self.radio_field.text or "https://" in self.radio_field.text:
				radio = self.station_editing
				if self.add_mode:
					radio: RadioStation = RadioStation(
						title=self.radio_field_title.text,
						stream_url=self.radio_field.text)
				radio.title = self.radio_field_title.text
				if radio.stream_url != self.radio_field.text:
					radio.stream_url = self.radio_field.text
					radio.website_url = "" # Different URL, null the website # TODO(Martin): no way to edit for now

				if self.add_mode:
					self.pctl.radio_playlists[self.pctl.radio_playlist_viewing].stations.append(radio)
				self.active = False
			else:
				self.show_message(_("Could not validate URL. Must start with https:// or http://"))

	def draw_list(self) -> None:
		x = self.x
		y = self.y
		w = self.w
		h = self.h

		if self.drag:
			self.gui.update_on_drag = True

		yy = y + round(100 * self.gui.scale)
		x += round(10 * self.gui.scale)

		radio_list = self.prefs.radio_urls
		if self.tab == 1:
			radio_list = self.temp_list

		rect = (x, y, w, h)
		if self.coll(rect):
			scroll_distance = self.smooth_scroll.scroll("radio box")
			self.scroll_position -= scroll_distance
		self.scroll_position = max(self.scroll_position, 0)
		self.scroll_position = min(self.scroll_position, len(radio_list) // 2 - 7)

		if len(radio_list) // 2 > 9:
			self.scroll_position = self.scroll.draw(
				(x + w) - round(35 * self.gui.scale), yy, round(15 * self.gui.scale),
				round(210 * self.gui.scale), self.scroll_position,
				len(radio_list) // 2 - 7, True, click=self.gui.level_2_click)

		self.scroll_position = max(self.scroll_position, 0)

		p = self.scroll_position * 2
		offset = 0
		to_delete = None
		swap = None

		while True:
			if p > len(radio_list) - 1:
				break

			xx = x + offset
			station = radio_list[p]

			rect = (xx, yy, round(233 * self.gui.scale), round(40 * self.gui.scale))
			self.fields.add(rect)

			bg = self.colours.box_background
			text_colour = self.colours.box_input_text

			playing = self.pctl.playing_state == PlayingState.URL_STREAM and self.loaded_url == station.stream_url

			if playing:

				bg = self.colours.tab_background_active
				text_colour = self.colours.tab_text_active
				self.ddt.rect(rect, bg)

			if self.tauon.radio_view.drag:
				if station == self.tauon.radio_view.drag:
					text_colour = self.colours.box_sub_text
					bg = ColourRGBA(255, 255, 255, 10)
					self.ddt.rect(rect, bg)
			elif (self.tauon.radio_entry_menu.active and self.tauon.radio_entry_menu.reference == p) or \
					((not self.tauon.radio_entry_menu.active and self.coll(rect)) and not playing):
				text_colour = self.colours.box_sub_text
				bg = ColourRGBA(255, 255, 255, 10)
				self.ddt.rect(rect, bg)

			if self.coll(rect):
				if self.gui.level_2_click:
					# self.drag = p
					# self.click_point = copy.copy(self.inp.mouse_position)
					self.tauon.radio_view.drag = station
					self.tauon.radio_view.click_point = copy.copy(self.inp.mouse_position)
				if self.inp.mouse_up:  # self.gui.level_2_click:
					self.gui.update += 1
					# if self.drag is not None and p != self.drag:
					#     swap = p
					if point_proximity_test(self.tauon.radio_view.click_point, self.inp.mouse_position, round(4 * self.gui.scale)):
						self.start(station)
				if self.inp.middle_click:
					to_delete = p
				if self.inp.level_2_right_click:
					self.right_clicked_station = station
					self.right_clicked_station_p = p
					self.tauon.radio_entry_menu.activate(station)

			bg = alpha_blend(bg, self.colours.box_background)

			boxx = round(32 * self.gui.scale)
			toff = boxx + round(10 * self.gui.scale)
			if station.title:
				self.ddt.text(
					(xx + toff, yy + round(3 * self.gui.scale)), station.title, text_colour, 212, bg=bg,
					max_w=rect[2] - (15 * self.gui.scale + toff))
			else:
				self.ddt.text(
					(xx + toff, yy + round(3 * self.gui.scale)), station.stream_url, text_colour, 212, bg=bg,
					max_w=rect[2] - (15 * self.gui.scale + toff))

			country = station.country
			if country:
				self.ddt.text(
					(xx + toff, yy + round(18 * self.gui.scale)), country, text_colour, 11, bg=bg,
					max_w=rect[2] - (15 * self.gui.scale + toff))

			b_rect = (xx + round(4 * self.gui.scale), yy + round(4 * self.gui.scale), boxx, boxx)
			self.ddt.rect(b_rect, self.colours.box_thumb_background)
			self.tauon.radio_thumb_gen.draw(station, b_rect[0], b_rect[1], b_rect[2])

			if offset == 0:
				offset = rect[2] + round(4 * self.gui.scale)
			else:
				offset = 0
				yy += round(43 * self.gui.scale)

			if yy > y + 300 * self.gui.scale:
				break

			p += 1

		# if to_delete is not None:
		#     del radio_list[to_delete]
		#
		# if self.inp.mouse_up and self.drag and self.inp.mouse_position[1] > yy + round(22 * self.gui.scale):
		#     swap = len(radio_list)

		# if self.drag and not point_proximity_test(self.click_point, self.inp.mouse_position, round(4 * self.gui.scale)):
		#     self.ddt.rect((
		#              self.inp.mouse_position[0] + round(8 * self.gui.scale), self.inp.mouse_position[1] - round(8 * self.gui.scale), 45 * self.gui.scale,
		#              13 * self.gui.scale), self.colours.grey(70))

		# if swap is not None:
		#
		#     old = radio_list[self.drag]
		#     radio_list[self.drag] = None
		#
		#     if swap > self.drag:
		#         swap += 1
		#
		#     radio_list.insert(swap, old)
		#     radio_list.remove(None)
		#
		#     self.drag = None
		#     self.gui.update += 1

		# if not self.inp.mouse_down:
		#     self.drag = None

	def footer(self) -> None:
		y = self.y
		x = self.x + round(15 * self.gui.scale)
		w = self.w
		h = self.h

		yy = y + round(328 * self.gui.scale)
		if self.pctl.playing_state == PlayingState.URL_STREAM and not self.prefs.auto_rec:
			old = self.prefs.auto_rec
			if not old and self.tauon.pref_box.toggle_square(
				x, yy, self.prefs.auto_rec, _("Record and auto split songs"),
				click=self.gui.level_2_click):
				self.show_message(_("Please stop playback first before toggling this setting"))
		elif self.pctl.playing_state == PlayingState.URL_STREAM:
			old = self.prefs.auto_rec
			if old and not self.tauon.pref_box.toggle_square(
				x, yy, self.prefs.auto_rec, _("Record and auto split songs"),
				click=self.gui.level_2_click):
				self.show_message(_("Please stop playback first to end current recording"))

		else:
			old = self.prefs.auto_rec
			self.prefs.auto_rec = self.tauon.pref_box.toggle_square(
				x, yy, self.prefs.auto_rec, _("Record and auto split songs"),
				click=self.gui.level_2_click)
			if self.prefs.auto_rec != old and self.prefs.auto_rec:
				self.show_message(
					_("Tracks will now be recorded."),
					_("Tip: You can press F9 to view the output folder."), mode="info")

		if self.tab == 0:
			if self.draw.button(
				_("Browse"), (x + w) - round(130 * self.gui.scale), yy - round(3 * self.gui.scale),
				press=self.gui.level_2_click, w=round(100 * self.gui.scale)):
				self.tab = 1
		elif self.tab == 1:
			if self.draw.button(
				_("Saved"), (x + w) - round(130 * self.gui.scale), yy - round(3 * self.gui.scale),
				press=self.gui.level_2_click, w=round(100 * self.gui.scale)):
				self.tab = 0
		self.gui.level_2_click = False

class RenamePlaylistBox:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon            = tauon
		self.ddt              = tauon.ddt
		self.gui              = tauon.gui
		self.inp              = tauon.inp
		self.coll             = tauon.coll
		self.pctl             = tauon.pctl
		self.colours          = tauon.colours
		self.window_size      = tauon.window_size
		self.thread_manager   = tauon.thread_manager
		self.rename_text_area = tauon.rename_text_area
		self.x = 300
		self.y = 300
		self.playlist_index = 0

		self.edit_generator = False
		# When set, the box renames something other than a playlist: on commit
		# the callback receives the entered text (skipped when empty) and the
		# playlist paths are bypassed. Cleared after every commit and whenever
		# the box opens for a playlist. Used by the Custom Layout slot rename.
		self.done_callback: Callable[[str], None] | None = None

	def toggle_edit_gen(self) -> None:
		self.edit_generator ^= True
		if self.edit_generator:

			if len(self.rename_text_area.text) > 0:
				self.pctl.multi_playlist[self.playlist_index].title = self.rename_text_area.text

			pl = self.playlist_index
			id = self.pctl.pl_to_id(pl)

			text = self.pctl.gen_codes.get(id)
			if not text:
				text = ""

			self.rename_text_area.set_text(text)
			self.rename_text_area.highlight_none()

			self.gui.regen_single = self.tauon.rename_playlist_box.playlist_index
			self.thread_manager.ready("worker")
		else:
			self.rename_text_area.set_text(self.pctl.multi_playlist[self.playlist_index].title)
			self.rename_text_area.highlight_none()
			# self.rename_text_area.highlight_all()

	def render(self) -> None:
		if self.gui.level_2_click:
			self.inp.mouse_click = True
		self.gui.level_2_click = False

		if self.inp.key_tab_press and self.done_callback is None:
			self.toggle_edit_gen()

		text_w = self.ddt.get_text_w(self.rename_text_area.text, 315)
		min_w = max(250 * self.gui.scale, text_w + 50 * self.gui.scale)

		# Keep the box on-screen horizontally - its x can come from a menu-item
		# callback's mouse position, which may sit near (or past) the edge.
		margin = round(10 * self.gui.scale)
		self.x = max(margin, min(self.x, self.window_size[0] - min_w - margin))

		rect = [self.x, self.y, min_w, 37 * self.gui.scale]
		bg = ColourRGBA(40, 40, 40, 255)
		if self.edit_generator:
			bg = ColourRGBA(70, 50, 100, 255)
		self.ddt.text_background_colour = bg

		# Draw background
		self.ddt.rect(rect, bg)

		# Draw text entry
		self.rename_text_area.draw(
			rect[0] + 10 * self.gui.scale, rect[1] + 8 * self.gui.scale, self.colours.alpha_grey(250),
			width=350 * self.gui.scale, font=315)

		# Draw accent
		rect2 = [self.x, self.y + rect[3] - 4 * self.gui.scale, min_w, 4 * self.gui.scale]
		self.ddt.rect(rect2, ColourRGBA(255, 255, 255, 60))

		if self.edit_generator:
			pl = self.playlist_index
			id = self.pctl.pl_to_id(pl)
			self.pctl.gen_codes[id] = self.rename_text_area.text

			if self.inp.input_text or self.inp.key_backspace_press:
				self.gui.regen_single = self.tauon.rename_playlist_box.playlist_index
				self.thread_manager.ready("worker")

				# self.regenerate_playlist(self.tauon.rename_playlist_box.playlist_index)
			# if self.gui.gen_code_errors:
			#     del_icon.render(rect[0] + rect[2] - 21 * self.gui.scale, rect[1] + 10 * self.gui.scale, ColourRGBA(255, 70, 70, 255))
			self.ddt.text_background_colour = ColourRGBA(4, 4, 4, 255)
			hint_rect = [rect[0], rect[1] + round(50 * self.gui.scale), round(560 * self.gui.scale), round(300 * self.gui.scale)]

			if hint_rect[0] + hint_rect[2] > self.window_size[0]:
				hint_rect[0] = self.window_size[0] - hint_rect[2]

			self.ddt.rect(hint_rect, ColourRGBA(0, 0, 0, 245))
			xx0 = hint_rect[0] + round(15 * self.gui.scale)
			xx = hint_rect[0] + round(25 * self.gui.scale)
			xx2 = hint_rect[0] + round(85 * self.gui.scale)
			yy = hint_rect[1] + round(10 * self.gui.scale)

			text_colour = ColourRGBA(150, 150, 150, 255)
			title_colour = text_colour
			code_colour = ColourRGBA(250, 250, 250, 255)
			hint_colour = ColourRGBA(110, 110, 110, 255)

			title_font = 311
			code_font = 311
			hint_font = 310

			# self.ddt.pretty_rect = hint_rect

			self.ddt.text(
				(xx0, yy), _("Type codes separated by spaces. Codes will be executed left to right."), text_colour, title_font)
			yy += round(18 * self.gui.scale)
			self.ddt.text((xx0, yy), _("Select sources: (default: all playlists)"), title_colour, title_font)
			yy += round(14 * self.gui.scale)
			self.ddt.text((xx, yy), "s\"name\"", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Select source playlist by name"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "self", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Select playlist itself"), hint_colour, hint_font)

			yy += round(16 * self.gui.scale)
			self.ddt.text((xx0, yy), _("Add tracks from sources: (at least 1 required)"), title_colour, title_font)
			yy += round(14 * self.gui.scale)

			self.ddt.text((xx, yy), "a\"name\"", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Search artist name"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "g\"genre\"", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Search genre"), hint_colour, hint_font)
			# yy += round(12 * self.gui.scale)
			# self.ddt.text((xx, yy), "p\"text\"", code_colour, code_font)
			# self.ddt.text((xx2, yy), "Search filepath segment", hint_colour, hint_font)

			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "f\"terms\"", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Find / Search / Path"), hint_colour, hint_font)

			# yy += round(12 * self.gui.scale)
			# self.ddt.text((xx, yy), "ext\"flac\"", code_colour, code_font)
			# self.ddt.text((xx2, yy), "Search by file type", hint_colour, hint_font)

			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "a", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Add all tracks"), hint_colour, hint_font)

			yy += round(16 * self.gui.scale)
			self.ddt.text((xx0, yy), _("Filters"), title_colour, title_font)
			yy += round(14 * self.gui.scale)
			self.ddt.text((xx, yy), "n123", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Limit to number of tracks"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "y>1999", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Year: >, <, ="), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "pc>5", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Play count: >, <"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "d>3", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Track duration (minutes): >, <"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "ad>5", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Album duration (minutes): >, <"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "rat>3.5", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Track rating 0-5: >, <, ="), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "l", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Loved tracks"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "ly", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Has lyrics"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "ff\"terms\"", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Search and keep"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "fx\"terms\"", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Search and exclude"), hint_colour, hint_font)

			# yy += round(12 * self.gui.scale)
			# self.ddt.text((xx, yy), "com\"text\"", code_colour, code_font)
			# self.ddt.text((xx2, yy), "Search in comment", hint_colour, hint_font)
			# yy += round(12 * self.gui.scale)

			xx += round(260 * self.gui.scale)
			xx2 += round(260 * self.gui.scale)
			xx0 += round(260 * self.gui.scale)
			yy = hint_rect[1] + round(10 * self.gui.scale)
			yy += round(18 * self.gui.scale)

			# yy += round(16 * self.gui.scale)
			self.ddt.text((xx0, yy), _("Sorters"), title_colour, title_font)
			yy += round(14 * self.gui.scale)

			self.ddt.text((xx, yy), "st", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Shuffle tracks"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "ra", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Shuffle albums"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "y>", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Year: >, <"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "d>", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Track duration: >, <"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "pt>", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Track Playtime: >, <"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "pa>", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Album playtime: >, <"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "ad>", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Album duration: >, <"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "rv", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Invert tracks"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "rva", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Invert albums"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "rat>", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Track rating: >, <"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "rata>", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Album rating: >, <"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "m>", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Modification date: >, <"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "path", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Filepath"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "tn", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Track number per album"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "ypa", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Year per artist"), hint_colour, hint_font)
			yy += round(12 * self.gui.scale)
			self.ddt.text((xx, yy), "\"artist\">", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Sort by column name: >, <"), hint_colour, hint_font)

			yy += round(16 * self.gui.scale)
			self.ddt.text((xx0, yy), _("Special"), title_colour, title_font)
			yy += round(14 * self.gui.scale)
			self.ddt.text((xx, yy), "auto", code_colour, code_font)
			self.ddt.text((xx2, yy), _("Automatically reload on imports"), hint_colour, hint_font)

			yy += round(24 * self.gui.scale)
			# xx += round(80 * self.gui.scale)
			xx2 = xx
			xx2 += self.ddt.text((xx2, yy), _("Status:"), ColourRGBA(90, 90, 90, 255), 212) + round(6 * self.gui.scale)
			if self.rename_text_area.text:
				if self.gui.gen_code_errors:
					if self.gui.gen_code_errors == "playlist":
						self.ddt.text((xx2, yy), _("Playlist not found"), ColourRGBA(255, 100, 100, 255), 212)
					elif self.gui.gen_code_errors == "empty":
						self.ddt.text((xx2, yy), _("Result is empty"), ColourRGBA(250, 190, 100, 255), 212)
					elif self.gui.gen_code_errors == "close":
						self.ddt.text((xx2, yy), _("Close quotation..."), ColourRGBA(110, 110, 110, 255), 212)
					else:
						self.ddt.text((xx2, yy), "...", ColourRGBA(255, 100, 100, 255), 212)
				else:
					self.ddt.text((xx2, yy), _("OK"), ColourRGBA(100, 255, 100, 255), 212)
			else:
				self.ddt.text((xx2, yy), _("Disabled"), ColourRGBA(110, 110, 110, 255), 212)

		# self.ddt.pretty_rect = None

		# If enter or click outside of box: save and close
		if self.inp.key_return_press or (self.inp.key_esc_press and len(self.gui.editline) == 0) \
				or ((self.inp.mouse_click or self.inp.level_2_right_click) and not self.coll(rect)):
			self.gui.rename_playlist_box = False

			if self.done_callback is not None:
				cb = self.done_callback
				self.done_callback = None
				if len(self.rename_text_area.text) > 0:
					cb(self.rename_text_area.text)
			elif self.edit_generator:
				pass
			elif len(self.rename_text_area.text) > 0:
				if self.gui.radio_view:
					self.pctl.radio_playlists[self.playlist_index].name = self.rename_text_area.text
				else:
					self.pctl.multi_playlist[self.playlist_index].title = self.rename_text_area.text
			self.inp.key_return_press = False

class PlaylistBox:

	def recalc(self) -> None:
		self.tab_h = round(25 * self.gui.scale)
		self.gap = round(2 * self.gui.scale)

		self.text_offset = 2 * self.gui.scale
		if self.gui.scale == 1.25:
			self.text_offset = 3

	def __init__(self, tauon: Tauon) -> None:
		self.tauon       = tauon
		self.inp         = tauon.inp
		self.gui         = tauon.gui
		self.ddt         = tauon.ddt
		self.coll        = tauon.coll
		self.pctl        = tauon.pctl
		self.prefs       = tauon.prefs
		self.fields      = tauon.fields
		self.colours     = tauon.colours
		self.window_size = tauon.window_size
		self.scroll_on   = tauon.prefs.old_playlist_box_position
		self.drag = False
		self.drag_source = 0
		self.drag_on = -1

		self.adds = []

		self.indicate_w = round(2 * self.gui.scale)

		bag              = tauon.bag
		self.lock_icon = asset_loader(bag, bag.loaded_asset_dc, "lock-corner.png", True)
		self.pin_icon = asset_loader(bag, bag.loaded_asset_dc, "dia-pin.png", True)
		self.gen_icon = asset_loader(bag, bag.loaded_asset_dc, "gen-gear.png", True)


		# if gui.scale == 1.25:
		self.tab_h = 0
		self.gap = 0

		self.text_offset = 2 * self.gui.scale
		self.recalc()

	def draw(self, x: int, y: int, w: int, h: int) -> None:
		tauon = self.tauon
		ddt   = self.ddt
		pctl  = self.pctl
		gui   = self.gui

		# self.ddt.rect_r((x, y, w, h), self.colours.side_panel_background, True)
		self.ddt.rect((x, y, w, h), self.colours.playlist_box_background)
		self.ddt.text_background_colour = self.colours.playlist_box_background

		row_step = self.gap + self.tab_h
		top_pad = 5 * gui.scale
		max_tabs = max(0, int((h - top_pad + self.gap) // max(row_step, 1)))
		scroll_needed = len(pctl.multi_playlist) > max_tabs
		bottom_pad = 12 * gui.scale if scroll_needed else 0
		visible_scroll_rows = max(0, ((h - top_pad - bottom_pad - self.tab_h) / max(row_step, 1)) + 1)
		max_scroll = max(len(pctl.multi_playlist) - visible_scroll_rows, 0)

		tab_title_colour = self.colours.tab_text

		bg_lumi = test_lumi(self.colours.playlist_box_background)
		light_mode = False

		if bg_lumi < 0.55:
			light_mode = True
			tab_title_colour = ColourRGBA(20, 20, 20, 255)

		dark_mode = False
		if bg_lumi > 0.8:
			dark_mode = True

		indicate_w = round(3 * gui.scale) if light_mode else round(2 * gui.scale)

		show_scroll = False
		tab_start = x + 10 * self.gui.scale

		if self.window_size[0] < 700 * self.gui.scale:
			tab_start = x + 4 * self.gui.scale

		scroll_area = (x, y, w, h)
		scroll_source = "playlist side pane"
		touch_scroll = self.inp.touch_scroll_y != 0 and coll_point(self.inp.touch_position, scroll_area)
		use_smooth_scroll = (
			self.tauon.smooth_scroll.enabled()
			or touch_scroll
			or self.tauon.smooth_scroll.active(scroll_source)
		)
		if use_smooth_scroll:
			if self.inp.mouse_wheel != 0 and self.coll(scroll_area):
				self.tauon.smooth_scroll.add_wheel_motion(scroll_source, -self.inp.mouse_wheel, row_step)
			if self.inp.touch_released:
				self.tauon.smooth_scroll.release_touch(scroll_source)
			elif touch_scroll:
				self.tauon.smooth_scroll.apply_touch_drag(scroll_source, -self.inp.touch_scroll_y)
			self.scroll_on += self.tauon.smooth_scroll.step_motion(scroll_source) / max(row_step, 1)
		elif self.inp.mouse_wheel != 0 and self.coll(scroll_area):
			self.scroll_on -= self.inp.mouse_wheel

		self.scroll_on = min(self.scroll_on, max_scroll)
		self.scroll_on = max(self.scroll_on, 0)

		if scroll_needed:
			show_scroll = True
		else:
			self.scroll_on = 0

		if show_scroll:
			tab_start += 15 * self.gui.scale

		if self.colours.lm:
			w -= round(6 * gui.scale)
		tab_width = w - tab_start  # - 0 * gui.scale
		visible_tab_limit = max_tabs + 1 if show_scroll else max_tabs

		def clipped_to_box(rect):
			rect_y = max(rect[1], y)
			rect_bottom = min(rect[1] + rect[3], y + h)
			if rect_bottom <= rect_y:
				return None
			return (rect[0], rect_y, rect[2], rect_bottom - rect_y)

		# Draw scroll bar
		if show_scroll:
			self.scroll_on = self.tauon.playlist_panel_scroll.draw(
				x + 2, y + 1, 15 * self.gui.scale, h, self.scroll_on, max_scroll)

		draw_pin_indicator = False  # self.prefs.tabs_on_top

		# if not gui.album_tab_mode:
		# 	if self.inp.key_left_press or self.inp.key_right_press:
		# 		if pctl.active_playlist_viewing < self.scroll_on:
		# 			self.scroll_on = pctl.active_playlist_viewing
		# 		elif pctl.active_playlist_viewing + 1 > self.scroll_on + max_tabs:
		# 			self.scroll_on = (pctl.active_playlist_viewing - max_tabs) + 1

		# Process inputs
		delete_pl = None
		tab_on = 0
		scroll_start = int(self.scroll_on)
		scroll_offset = (self.scroll_on - scroll_start) * max(row_step, 1)
		yy = y + top_pad - scroll_offset
		for i, pl in enumerate(pctl.multi_playlist):

			if tab_on >= visible_tab_limit:
				break
			if i < scroll_start:
				continue

			# if not pl.hidden and i in tabs_on_top:
			# 	continue

			tab_on += 1
			tab_hit_rect = clipped_to_box((tab_start, yy - 1, tab_width, (self.tab_h + 1)))

			if tab_hit_rect is not None and self.coll(tab_hit_rect):
				if self.inp.right_click:
					if gui.radio_view:
						tauon.radio_tab_menu.activate(i, self.inp.mouse_position)
					else:
						tauon.tab_menu.activate(i, self.inp.mouse_position)
					gui.tab_menu_pl = i

				if tauon.tab_menu.active is False and self.inp.middle_click:
					delete_pl = i
					# delete_playlist(i)
					# break

				if self.inp.mouse_up and self.drag and coll_point(self.inp.mouse_up_position, tab_hit_rect):
					# If drag from top bar to side panel, make hidden
					if self.drag_source == 0 and self.prefs.drag_to_unpin:
						pctl.multi_playlist[self.drag_on].hidden = True

					# Move playlist tab
					if i != self.drag_on and not point_proximity_test(gui.drag_source_position, self.inp.mouse_position, 10 * gui.scale):
						if self.inp.key_shift_down:
							pctl.multi_playlist[i].playlist_ids += pctl.multi_playlist[self.drag_on].playlist_ids
							pctl.delete_playlist(self.drag_on, force=True)
						else:
							pctl.move_playlist(self.drag_on, i)

					gui.update += 1

				# Double click to play
				if self.inp.mouse_up and pctl.pl_to_id(i) == self.tauon.top_panel.tab_d_click_ref == pctl.pl_to_id(pctl.active_playlist_viewing) and \
					self.tauon.top_panel.tab_d_click_timer.get() < 0.25 and \
					point_distance(self.inp.last_click_location, self.inp.mouse_up_position) < 5 * gui.scale:

					if pctl.playing_state == PlayingState.PAUSED and pctl.active_playlist_playing == i:
						pctl.play()
					elif pctl.selected_ready() and (pctl.playing_state != PlayingState.PLAYING or pctl.active_playlist_playing != i):
						pctl.jump(pctl.default_playlist[pctl.selected_in_playlist], pl_position=pctl.selected_in_playlist)
				if self.inp.mouse_up:
					self.tauon.top_panel.tab_d_click_timer.set()
					self.tauon.top_panel.tab_d_click_ref = pctl.pl_to_id(i)

				if not draw_pin_indicator and self.inp.mouse_click:
					pctl.switch_playlist(i)
					self.drag_on = i
					self.drag = True
					self.drag_source = 1
					gui.set_drag_source()

				# Process input of dragging tracks onto tab
				if self.inp.quick_drag is True and self.inp.mouse_up:
					self.tauon.top_panel.tab_d_click_ref = -1
					self.tauon.top_panel.tab_d_click_timer.force_set(100)
					if (pctl.gen_codes.get(pctl.pl_to_id(i)) and "self" not in pctl.gen_codes[pctl.pl_to_id(i)]):
						self.tauon.clear_gen_ask(pctl.pl_to_id(i))
					self.inp.quick_drag = False
					modified = False
					gui.pl_update += 1

					for item in self.gui.shift_selection:
						pctl.multi_playlist[i].playlist_ids.append(pctl.default_playlist[item])
						modified = True
					if len(self.gui.shift_selection) > 0:
						self.adds.append(
							[pctl.multi_playlist[i].uuid_int, len(self.gui.shift_selection), Timer()])  # ID, num, timer
						modified = True
					if modified:
						pctl.after_import_flag = True
						tauon.dropped_playlist = i
						tauon.thread_manager.ready("worker")
						pctl.notify_database_changed()
						pctl.update_shuffle_pool(pctl.multi_playlist[i].uuid_int)
						tauon.tree_view_box.clear_target_pl(i)

			# Toggle hidden flag on click
			pin_hit_rect = clipped_to_box((tab_start + 5 * gui.scale, yy + 3 * gui.scale, 25 * gui.scale, 26 * gui.scale))
			if draw_pin_indicator and self.inp.mouse_click and pin_hit_rect is not None and self.coll(pin_hit_rect):
				pl.hidden ^= True

			yy += self.tab_h + self.gap

		# Draw tabs
		# delete_pl = None
		tab_on = 0
		yy = y + top_pad - scroll_offset
		for i, pl in enumerate(pctl.multi_playlist):

			# if yy + self.tab_h > y + h:
			#     break
			if tab_on >= visible_tab_limit:
				break
			if i < scroll_start:
				continue

			tab_on += 1

			name = pl.title
			hidden = pl.hidden

			# Background is invisible by default (for highlighting if selected)
			bg = ColourRGBA(0, 0, 0, 0)
			if self.prefs.transparent_mode:
				bg = rgb_add_hls(self.colours.playlist_box_background, 0, 0.09, 0)
				bg = ColourRGBA(bg.r, bg.g, bg.b, 255)

			drop_hit_rect = clipped_to_box(
				(tab_start + 50 * gui.scale, yy - 1, tab_width - 50 * gui.scale, (self.tab_h + 1))
			)

			# Highlight if playlist selected (viewing)
			if i == pctl.active_playlist_viewing or (tauon.tab_menu.active and tauon.tab_menu.reference == i):
				# bg = [255, 255, 255, 25]

				# Adjust highlight for different background brightnesses
				bg = rgb_add_hls(self.colours.playlist_box_background, 0, 0.06, 0)
				if light_mode:
					bg = ColourRGBA(0, 0, 0, 25)
				if self.prefs.transparent_mode:
					bg = rgb_add_hls(self.colours.playlist_box_background, 0, 0.03, 0)
					bg = ColourRGBA(bg.r, bg.g, bg.b, 255)

			# Highlight target playlist when tragging tracks over
			if drop_hit_rect is not None and self.coll(drop_hit_rect) and self.inp.quick_drag and not (
				pctl.gen_codes.get(pctl.pl_to_id(i)) and "self" not in pctl.gen_codes[pctl.pl_to_id(i)]):
				# bg = [255, 255, 255, 15]
				bg = rgb_add_hls(self.colours.playlist_box_background, 0, 0.04, 0)
				if light_mode:
					bg = ColourRGBA(0, 0, 0, 16)

			# Get actual bg from blend for text bg
			real_bg = alpha_blend(bg, self.colours.playlist_box_background)

			# Draw highlight
			self.ddt.rect((tab_start, yy - round(1 * gui.scale), tab_width, self.tab_h), bg)

			# Draw title text
			text_start = 10 * gui.scale
			if draw_pin_indicator:
				# text_start = 40 * gui.scale
				text_start = 32 * gui.scale

			if not pl.hidden and self.prefs.tabs_on_top:
				cl = ColourRGBA(255, 255, 255, 25)

				if light_mode:
					cl = ColourRGBA(0, 0, 0, 40)

				xx = tab_start + tab_width - self.lock_icon.w
				self.lock_icon.render(xx, yy, cl)

			text_max_w = tab_width - text_start - 15 * gui.scale
			# if indicator_run_x:
			#     text_max_w = tab_width - (indicator_run_x + text_start + 17 * gui.scale + slide)
			self.ddt.text(
				(tab_start + text_start, yy + self.text_offset), name, tab_title_colour if i != pctl.active_playlist_viewing else self.colours.tab_text_active, 211, max_w=text_max_w, bg=real_bg)

			# Is mouse collided with tab?
			hit = drop_hit_rect is not None and self.coll(drop_hit_rect)

			# if not self.prefs.tabs_on_top:
			if i == pctl.active_playlist_playing:
				indicator_colour = self.colours.title_playing
				if self.colours.lm:
					indicator_colour = self.colours.seek_bar_fill

				ddt.rect((tab_start + 0 - 2 * gui.scale, yy - round(1 * gui.scale), indicate_w, self.tab_h), indicator_colour)

			# # If mouse over
			if hit:
				# Draw indicator for dragging tracks
				if (self.inp.quick_drag or gui.ext_drop_mode) and self.tauon.pl_is_mut(i):
					ddt.rect((tab_start + tab_width - self.indicate_w, yy, self.indicate_w, self.tab_h), ColourRGBA(80, 200, 180, 255))

				# Draw indicators for moving tab
				if self.drag and i != self.drag_on and not point_proximity_test(
					gui.drag_source_position, self.inp.mouse_position, 10 * gui.scale):
					if self.inp.key_shift_down:
						ddt.rect(
							(tab_start + tab_width - 4 * gui.scale, yy, self.indicate_w, self.tab_h),
							ColourRGBA(80, 160, 200, 255))
					elif i < self.drag_on:
						ddt.rect((tab_start, yy - self.indicate_w, tab_width, self.indicate_w), ColourRGBA(80, 160, 200, 255))
					else:
						ddt.rect((tab_start, yy + (self.tab_h - self.indicate_w), tab_width, self.indicate_w), ColourRGBA(80, 160, 200, 255))

			elif self.inp.quick_drag and not point_proximity_test(gui.drag_source_position, self.inp.mouse_position, 15 * gui.scale):
				for item in gui.shift_selection:
					if len(pctl.default_playlist) > item and pctl.default_playlist[item] in pl.playlist_ids:
						ddt.rect((tab_start + tab_width - self.indicate_w, yy, self.indicate_w, self.tab_h), ColourRGBA(190, 170, 20, 255))
						break
			# Drag red line highlight if playlist is generator playlist
			if self.inp.quick_drag and not point_proximity_test(gui.drag_source_position, self.inp.mouse_position, 15 * gui.scale):
				if not self.tauon.pl_is_mut(i):
					ddt.rect((tab_start + tab_width - self.indicate_w, yy, self.indicate_w, self.tab_h), ColourRGBA(200, 70, 50, 255))

			# Draw effect of adding tracks to playlist
			if len(self.adds) > 0:
				for k in reversed(range(len(self.adds))):
					if pctl.multi_playlist[i].uuid_int == self.adds[k][0]:
						if self.adds[k][2].get() > 0.3:
							del self.adds[k]
						else:
							ay = yy + 4 * gui.scale
							ay -= 6 * gui.scale * self.adds[k][2].get() / 0.3

							ddt.text(
								(tab_start + tab_width - 10 * gui.scale, round(ay), 1),
								"+" + str(self.adds[k][1]), self.colours.pulse_colour, 212, bg=real_bg)
							gui.update += 1

							ddt.rect(
								(tab_start + tab_width, yy, self.indicate_w, self.tab_h - self.indicate_w),
								ColourRGBA(244, 212, 66, int(255 * self.adds[k][2].get() / 0.3) * -1))

			yy += self.tab_h + self.gap

		if delete_pl is not None:
			# delete_playlist(delete_pl)
			self.pctl.delete_playlist_ask(delete_pl)
			gui.update += 1

		# Create new playlist if drag in blank space after tabs
		rect_y = max(yy, y)
		rect = (x, rect_y, w - 10 * gui.scale, max(0, y + h - rect_y))
		if rect[3] > 0:
			self.fields.add(rect)

		if rect[3] > 0 and self.coll(rect):
			if self.inp.quick_drag or gui.ext_drop_mode:
				ddt.rect((tab_start, yy, tab_width, self.indicate_w), ColourRGBA(80, 160, 200, 255))
				if self.inp.mouse_up:
					self.tauon.drop_tracks_to_new_playlist(gui.shift_selection)

			if self.inp.right_click:
				self.tauon.extra_tab_menu.activate(pctl.active_playlist_viewing)

			# Move tab to end playlist if dragged past end
			if self.drag:
				if self.inp.mouse_up:
					if self.inp.key_ctrl_down:
						# Duplicate playlist on ctrl
						self.tauon.gen_dupe(tauon.playlist_box.drag_on)
						gui.update += 2
						self.drag = False
					else:
						# If drag from top bar to side panel, make hidden
						if self.drag_source == 0 and self.prefs.drag_to_unpin:
							pctl.multi_playlist[self.drag_on].hidden = True

						pctl.move_playlist(self.drag_on, i)
						gui.update += 2
						self.drag = False
				elif self.inp.key_ctrl_down:
					ddt.rect((tab_start, yy, tab_width, self.indicate_w), ColourRGBA(255, 190, 0, 255))
				else:
					ddt.rect((tab_start, yy, tab_width, self.indicate_w), ColourRGBA(80, 160, 200, 255))

@dataclass
class ArtistListSaveState:

	all_artists: list[str]
	album_counts: dict[str, list[str]]
	scroll_position: int
	playlist_length: int
	artist_track_counts: dict[str, int]
	filtered_artists: int

class ArtistList:

	def __init__(self, tauon: Tauon, pctl: PlayerCtl) -> None:
		self.tauon                 = tauon
		self.pctl                  = pctl
		self.ddt                   = tauon.ddt
		self.inp                   = tauon.inp
		self.gui                   = tauon.gui
		self.coll                  = tauon.coll
		self.prefs                 = tauon.prefs
		self.fields                = tauon.fields
		self.colours               = tauon.colours
		self.renderer              = tauon.renderer
		self.lastfm                = pctl.lastfm
		self.star_store            = pctl.star_store
		self.window_size           = tauon.window_size
		self.smooth_scroll         = tauon.smooth_scroll
		self.thread_manager        = tauon.thread_manager
		self.artist_info_box       = pctl.artist_info_box
		self.artist_list_menu      = tauon.artist_list_menu
		self.a_cache_directory     = tauon.a_cache_directory
		self.artist_list_scroll    = pctl.artist_list_scroll
		self.artist_preview_render = tauon.artist_preview_render
		self.tab_h = round(60 * self.gui.scale)
		self.thumb_size = round(55 * self.gui.scale)

		self.current_artists: list[str] = []
		self.current_album_counts: dict[str, list[str]] = {}
		self.current_artist_track_counts: dict[str, int] = {}

		self.thumb_cache: dict[str, list[sdl3.LP_SDL_Texture | sdl3.SDL_FRect] | None] = {}

		self.to_fetch = ""
		self.to_fetch_mbid_a = ""

		self.scroll_position: int = 0

		self.id_to_load = ""

		self.d_click_timer = Timer()
		self.d_click_ref = -1

		self.click_ref = -1
		self.click_highlight_timer = Timer()

		self.saves: dict[int, ArtistListSaveState] = {}

		self.load = False

		self.shown_letters = []

		self.hover_on = "NONE"
		self.hover_timer = Timer(10)

		self.sample_tracks = {}

	def load_img(self, artist: str) -> None:
		filepath = self.artist_info_box.get_data(artist, get_img_path=True)

		if filepath and os.path.isfile(filepath):
			try:
				g = io.BytesIO()
				g.seek(0)

				with Image.open(filepath) as im:
					w, h = im.size
					if w != h:
						m = min(w, h)
						im = im.crop((
							round((w - m) / 2),
							round((h - m) / 2),
							round((w + m) / 2),
							round((h + m) / 2),
						))

					im.thumbnail((self.thumb_size, self.thumb_size), Image.Resampling.LANCZOS)

					im.save(g, "PNG")
				g.seek(0)

				s_image = self.ddt.load_image(g)
				texture = sdl3.SDL_CreateTextureFromSurface(self.renderer, s_image)
				sdl3.SDL_DestroySurface(s_image)
				tex_w = pointer(c_float(0))
				tex_h = pointer(c_float(0))
				sdl3.SDL_GetTextureSize(texture, tex_w, tex_h)
				rect = sdl3.SDL_FRect(0, 0)
				rect.w = int(tex_w.contents.value)
				rect.h = int(tex_h.contents.value)

				self.thumb_cache[artist] = [texture, rect]
			except Exception:
				logging.exception("Artist thumbnail processing error")
				self.thumb_cache[artist] = None

		elif artist in self.prefs.failed_artists:
			self.thumb_cache[artist] = None
		elif not self.to_fetch:
			if self.prefs.auto_dl_artist_data:
				self.to_fetch = artist
				self.thread_manager.ready("worker")
			else:
				self.thumb_cache[artist] = None

	def worker(self) -> None:
		if self.load:
			if self.tauon.after_scan:
				return

			self.prep()
			self.load = False
			return

		if self.to_fetch:
			if self.tauon.get_lfm_wait_timer.get() < 6:
				return

			artist = self.to_fetch
			f_artist = filename_safe(artist)

			self.tauon.artist_info_box.get_data(artist, silent=True)
			if not self.tauon.artist_info_box.get_data(artist, get_img_path=True):
				if artist not in self.prefs.failed_artists:
					logging.error(f"Failed fetching: {artist}")
					self.prefs.failed_artists.append(artist)

			self.to_fetch = ""

	def prep(self) -> None:
		self.scroll_position = 0

		curren_pl_no = self.pctl.id_to_pl(self.id_to_load)
		if curren_pl_no is None:
			return
		current_pl = self.pctl.multi_playlist[curren_pl_no]

		all: list[str] = []
		artist_parents = {}
		counts: dict[str, int] = {}
		play_time = {}
		filtered = 0
		b = 0

		try:
			for item in current_pl.playlist_ids:
				b += 1
				if b % 100 == 0:
					time.sleep(0.001)

				track = self.pctl.get_track(item)

				if track.artists is not None:
					artists = track.artists
				else:
					if self.prefs.artist_list_prefer_album_artist and track.album_artist:
						artists = track.album_artist
					else:
						artists = get_artist_strip_feat(track)

					artists = [x.strip() for x in artists.split(";")]

				pp = 0
				if self.prefs.artist_list_sort_mode == "play":
					pp = self.star_store.get(item)

				for artist in artists:
					if artist:
						# Add play time
						if self.prefs.artist_list_sort_mode == "play":
							p = play_time.get(artist, 0)
							play_time[artist] = p + pp

						# Get a sample track for fallback art
						if artist not in self.sample_tracks:
							self.sample_tracks[artist] = track

						# Confirm to final list if appeared at least 5 times
						# if artist not in all:
						if artist not in counts:
							counts[artist] = 0
						counts[artist] += 1
						if artist not in all:
							if counts[artist] > self.prefs.artist_list_threshold or len(current_pl.playlist_ids) < 1000:
								all.append(artist)
							else:
								filtered += 1

						if artist not in artist_parents:
							artist_parents[artist] = []
						if track.parent_folder_path not in artist_parents[artist]:
							artist_parents[artist].append(track.parent_folder_path)

			current_album_counts = artist_parents

			if self.prefs.artist_list_sort_mode == "popular":
				all.sort(key=counts.get, reverse=True)
			elif self.prefs.artist_list_sort_mode == "play":
				all.sort(key=play_time.get, reverse=True)
			else:
				all.sort(key=lambda y: y.lower().removeprefix("the "))
		except Exception:
			logging.exception("Album scan failure")
			time.sleep(4)
			return

		# Artist-list, album-counts, scroll-position, playlist-length, number ignored
		save = ArtistListSaveState(all, current_album_counts, 0, len(current_pl.playlist_ids), counts, filtered)

		# Scroll to playing artist
		scroll = 0
		if self.pctl.playing_ready():
			track = self.pctl.playing_object()
			for i, item in enumerate(save.all_artists):
				if item in (track.artist, track.album_artist):
					scroll = i
					break
		save.scroll_position = scroll

		viewing_pl_id = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].uuid_int
		if viewing_pl_id in self.saves:
			self.saves[viewing_pl_id].scroll_position = self.scroll_position

		self.saves[current_pl.uuid_int] = save
		self.gui.update += 1

	def locate_artist_letter(self, text: str) -> None:
		if not text or self.prefs.artist_list_sort_mode != "alpha":
			return

		letter = text[0].lower()
		letter_upper = letter.upper()
		for i, item in enumerate(self.current_artists):
			if item.startswith(("the ", "The ")):
				if len(item) > 4 and (item[4] == letter or item[4] == letter_upper):
					self.scroll_position = i
					break
			elif item and (item[0] == letter or item[0] == letter_upper):
				self.scroll_position = i
				break

		viewing_pl_id = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].uuid_int
		if self.pctl.multi_playlist[self.pctl.active_playlist_viewing].parent_playlist_id:
			viewing_pl_id = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].parent_playlist_id
		if viewing_pl_id in self.saves:
			self.saves[viewing_pl_id].scroll_position = self.scroll_position

	def locate_artist(self, track: TrackClass) -> None:
		for i, item in enumerate(self.current_artists):
			if item in (track.artist, track.album_artist) or (track.artists is not None and item in track.artists):
				self.scroll_position = i
				break

		viewing_pl_id = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].uuid_int
		if viewing_pl_id in self.saves:
			self.saves[viewing_pl_id].scroll_position = self.scroll_position

	def draw_card_text_only(self, artist: str, x: int, y: int, w: int, area: list[int], thin_mode: bool, line1_colour: ColourRGBA, line2_colour: ColourRGBA, light_mode: bool, bg: ColourRGBA) -> None:
		album_mode = False
		for albums in self.current_album_counts.values():
			if len(albums) > 1:
				album_mode = True
				break

		if not album_mode:
			count = self.current_artist_track_counts[artist]
			if count > 1:
				text = _("{N} tracks").format(N=str(count))
			else:
				text = _("{N} track").format(N=str(count))
		else:
			album_count = len(self.current_album_counts[artist])
			if album_count > 1:
				text = _("{N} albums").format(N=str(album_count))
			else:
				text = _("{N} album").format(N=str(album_count))

		if self.gui.preview_artist_loading == artist:
			# . Max 20 chars. Alt: Downloading image, Loading image
			text = _("Downloading data...")

		x_text = round(10 * self.gui.scale)
		artist_font = 313
		count_font = 312
		extra_text_space = 0
		self.ddt.text(
			(x_text, y + round(2 * self.gui.scale)), artist, line1_colour, artist_font,
			extra_text_space + w - x_text - 30 * self.gui.scale, bg=bg)
		# self.ddt.text((x_text, y + self.tab_h // 2 - 2 * self.gui.scale), text, line2_colour, count_font,
		#          extra_text_space + w - x_text - 15 * self.gui.scale, bg=bg)

	def draw_card_with_thumbnail(self, artist: str, x: int, y: int, w: int, area: list[int], thin_mode: bool, line1_colour: ColourRGBA, line2_colour: ColourRGBA, light_mode: bool, bg: ColourRGBA) -> None:
		if artist not in self.thumb_cache:
			self.load_img(artist)

		thumb_x = round(x + 10 * self.gui.scale)
		x_text = x + self.thumb_size + 19 * self.gui.scale
		artist_font = 513
		count_font = 312
		extra_text_space = 0
		if thin_mode:
			thumb_x = round(x + 10 * self.gui.scale)
			x_text = x + self.thumb_size + 17 * self.gui.scale
			artist_font = 211
			count_font = 311
			extra_text_space = 135 * self.gui.scale
			thin_mode = True
			area = (4 * self.gui.scale, y, w - 7 * self.gui.scale, self.tab_h - 2)
			self.fields.add(area)

		back_colour = ColourRGBA(30, 30, 30, 255)
		back_colour_2 = ColourRGBA(27, 27, 27, 255)
		border_colour = ColourRGBA(60, 60, 60, 255)
		# if self.colours.lm:
		# 	back_colour = ColourRGBA(200, 200, 200, 255)
		# 	back_colour_2 = ColourRGBA(240, 240, 240, 255)
		# 	border_colour = ColourRGBA(160, 160, 160, 255)
		rect = (thumb_x, round(y), self.thumb_size, self.thumb_size)

		if thin_mode and self.coll(area) and self.tauon.is_level_zero() and y + self.tab_h < self.window_size[1] - self.gui.panelBY:
			tab_rect = (x, y - round(2 * self.gui.scale), round(190 * self.gui.scale), self.tab_h - round(1 * self.gui.scale))

			for r in subtract_rect(tab_rect, rect):
				r = sdl3.SDL_FRect(r[0], r[1], r[2], r[3])
				self.tauon.style_overlay.hole_punches.append(r)

			self.ddt.rect(tab_rect, back_colour_2)
			bg = back_colour_2

		self.ddt.rect(rect, back_colour)
		self.ddt.rect(rect, border_colour)

		self.fields.add(rect)
		if self.coll(rect) and self.tauon.is_level_zero(True):
			self.hover_any = True

			hover_delay = 0.5
			if self.gui.compact_artist_list:
				hover_delay = 2

			if self.gui.preview_artist != artist:
				if self.hover_on != artist:
					self.hover_on = artist
					self.gui.preview_artist = ""
					self.hover_timer.set()
					self.gui.delay_frame(hover_delay)
				elif self.hover_timer.get() > hover_delay and not self.gui.preview_artist_loading:
					self.gui.preview_artist = ""
					path = self.artist_info_box.get_data(artist, get_img_path=True)
					if not path:
						self.gui.preview_artist_loading = artist
						shoot = threading.Thread(
							target=self.tauon.get_artist_preview,
							args=((artist, round(thumb_x + self.thumb_size), round(y))))
						shoot.daemon = True
						shoot.start()

					if path:
						self.tauon.set_artist_preview(path, artist, round(thumb_x + self.thumb_size), round(y))

			if self.inp.mouse_click:
				self.hover_timer.force_set(-2)
				self.gui.delay_frame(2 + hover_delay)

		drawn = False
		if artist in self.thumb_cache:
			thumb = self.thumb_cache[artist]
			if thumb is not None:
				thumb[1].x = thumb_x
				thumb[1].y = round(y)
				sdl3.SDL_RenderTexture(self.renderer, thumb[0], None, thumb[1])
				drawn = True
				if self.prefs.art_bg:
					rect = sdl3.SDL_FRect(thumb_x, round(y), self.thumb_size, self.thumb_size)
					if (rect.y + rect.h) > self.window_size[1] - self.gui.panelBY:
						diff = (rect.y + rect.h) - (self.window_size[1] - self.gui.panelBY)
						rect.h -= round(diff)
					self.tauon.style_overlay.hole_punches.append(rect)
		if not drawn:
			track = self.sample_tracks.get(artist)
			if track:
				self.tauon.gall_ren.render(track, (round(thumb_x), round(y)), self.thumb_size)

		if thin_mode:
			text = artist[:2].title()
			if text not in self.shown_letters:
				ww = self.ddt.get_text_w(text, 211)
				self.ddt.rect(
					(thumb_x + round(1 * self.gui.scale), y + self.tab_h - 20 * self.gui.scale, ww + 5 * self.gui.scale, 13 * self.gui.scale),
					ColourRGBA(20, 20, 20, 255))
				self.ddt.text(
					(thumb_x + 3 * self.gui.scale, y + self.tab_h - 23 * self.gui.scale), text, ColourRGBA(240, 240, 240, 255), 210,
					bg=ColourRGBA(20, 20, 20, 255))
				self.shown_letters.append(text)

		# Draw labels
		if not thin_mode or (self.coll(area) and self.tauon.is_level_zero() and y + self.tab_h < self.window_size[1] - self.gui.panelBY):
			album_mode = False
			for albums in self.current_album_counts.values():
				if len(albums) > 1:
					album_mode = True
					break

			if not album_mode:
				count = self.current_artist_track_counts[artist]
				if count > 1:
					text = _("{N} tracks").format(N=str(count))
				else:
					text = _("{N} track").format(N=str(count))
			else:
				album_count = len(self.current_album_counts[artist])
				if album_count > 1:
					text = _("{N} albums").format(N=str(album_count))
				else:
					text = _("{N} album").format(N=str(album_count))

			if self.gui.preview_artist_loading == artist:
				# . Max 20 chars. Alt: Downloading image, Loading image
				text = _("Downloading data...")

			self.ddt.text(
				(x_text, y + self.tab_h // 2 - 19 * self.gui.scale), artist, line1_colour, artist_font,
				extra_text_space + w - x_text - 30 * self.gui.scale, bg=bg)
			self.ddt.text(
				(x_text, y + self.tab_h // 2 - 2 * self.gui.scale), text, line2_colour, count_font,
				extra_text_space + w - x_text - 15 * self.gui.scale, bg=bg)

	def draw_card(self, artist, x, y, w) -> None:
		area = (4 * self.gui.scale, y, w - 26 * self.gui.scale, self.tab_h - 2)
		if self.prefs.artist_list_style == 2:
			area = (4 * self.gui.scale, y, w - 26 * self.gui.scale, self.tab_h - 1)

		self.fields.add(area)

		light_mode = False
		line1_colour = ColourRGBA(235, 235, 235, 255)
		line2_colour = ColourRGBA(255, 255, 255, 120)
		fade_max = 50

		thin_mode = False
		if self.gui.compact_artist_list:
			thin_mode = True
			line2_colour = ColourRGBA(115, 115, 115, 255)
		elif test_lumi(self.colours.side_panel_background) < 0.55 and not thin_mode:
			light_mode = True
			fade_max = 20
			line1_colour = ColourRGBA(35, 35, 35, 255)
			line2_colour = ColourRGBA(100, 100, 100, 255)

		# Fade on click
		bg = self.colours.side_panel_background
		if not thin_mode:
			if self.coll(area) and self.tauon.is_level_zero(True):
			# or pctl.get_track(pctl.default_playlist[pctl.playlist_view_position]).artist == artist:
				self.ddt.rect(area, ColourRGBA(50, 50, 50, 50))
				bg = alpha_blend(ColourRGBA(50, 50, 50, 50), self.colours.side_panel_background)
				if self.prefs.transparent_mode:
					bg = rgb_add_hls(self.colours.playlist_box_background, 0, 0.2, 0)
					self.ddt.rect(area, bg)
			else:
				fade = 0
				t = self.click_highlight_timer.get()
				if self.click_ref == artist and (t < 2.2 or self.artist_list_menu.active):
					if t < 1.9 or self.artist_list_menu.active:
						fade = fade_max
					else:
						fade = fade_max - round((t - 1.9) / 0.3 * fade_max)

					self.gui.update += 1
					self.ddt.rect(area, ColourRGBA(50, 50, 50, fade))

				bg = alpha_blend(ColourRGBA(50, 50, 50, fade), self.colours.side_panel_background)
				if self.prefs.transparent_mode:
					bg = self.colours.side_panel_background

		if self.prefs.artist_list_style == 1:
			self.draw_card_with_thumbnail(artist, x, y, w, area, thin_mode, line1_colour, line2_colour, light_mode, bg)
		else:
			self.draw_card_text_only(artist, x, y, w, area, thin_mode, line1_colour, line2_colour, light_mode, bg)

		if self.coll(area) and self.inp.mouse_position[1] < self.window_size[1] - self.gui.panelBY:
			if self.inp.mouse_click:
				if self.click_ref != artist:
					self.pctl.playlist_view_position = 0
					self.pctl.selected_in_playlist = 0
				self.click_ref = artist

				double_click = False
				if self.d_click_timer.get() < 0.4 and self.d_click_ref == artist:
					double_click = True

				self.click_highlight_timer.set()
				replace = False
				parent_playlist_id = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].parent_playlist_id
				if parent_playlist_id:
					if self.pctl.id_to_pl(parent_playlist_id) is None:
						self.pctl.multi_playlist[self.pctl.active_playlist_viewing].parent_playlist_id = 0
					else:
						self.tauon.create_artist_pl(artist, replace=True)
						replace = True

				blocks = []
				current_block = []

				in_artist = False
				this_artist = artist.casefold()
				last_ref = None
				on = 0

				for i in range(len(self.pctl.default_playlist)):
					track = self.pctl.get_track(self.pctl.default_playlist[i])
					if track.artist.casefold() == this_artist or track.album_artist.casefold() == this_artist or (
							track.artists is not None and artist in track.artists):
						# Matching artist
						if not in_artist:
							in_artist = True
							last_ref = track
							current_block.append(i)

						elif (last_ref and track.album != last_ref.album) or track.parent_folder_path != last_ref.parent_folder_path:
							current_block.append(i)
							last_ref = track
					# Not matching
					elif in_artist:
						blocks.append(current_block)
						current_block = []
						in_artist = False

				if current_block:
					blocks.append(current_block)
					current_block = []

				#logging.info(blocks)
				# return

				# block_starts = []
				# current = False
				# for i in range(len(self.pctl.default_playlist)):
				# 	track = self.pctl.get_track(self.pctl.default_playlist[i])
				# 	if current is False:
				# 		if track.artist == artist or track.album_artist == artist or (
				# 				track.artists is not None and artist in track.artists):
				# 			block_starts.append(i)
				# 			current = True
				# 	else:
				# 		if track.artist != artist and track.album_artist != artist or (
				# 				track.artists is not None and artist in track.artists):
				# 			current = False
				#
				# if not block_starts:
				# 	logging.info("No matching artists found in playlist")
				# 	return

				if not blocks:
					return

				#select = block_starts[0]

				# if len(block_starts) > 1:
				# 	if -1 < self.pctl.selected_in_playlist < len(self.pctl.default_playlist):
				# 		if self.pctl.selected_in_playlist in block_starts:
				# 			tauon.scroll_hide_timer.set()
				# 			self.gui.frame_callback_list.append(TestTimer(0.9))
				# 			if block_starts[-1] == self.pctl.selected_in_playlist:
				# 				pass
				# 			else:
				# 				select = block_starts[block_starts.index(self.pctl.selected_in_playlist) + 1]

				self.gui.pl_update += 1

				self.click_highlight_timer.set()

				select = blocks[0][0]

				if double_click:
					# Stat first artist track in playlist

					self.pctl.jump(self.pctl.default_playlist[select], pl_position=select)
					self.pctl.playlist_view_position = select
					self.pctl.selected_in_playlist = select
					self.gui.shift_selection.clear()
					self.d_click_timer.force_set(10)
				elif not replace:
					# Goto next artist section in playlist
					c = self.pctl.selected_in_playlist
					next = False
					track = self.pctl.get_track_in_playlist(c, -1)
					if track is None:
						logging.error("Index out of range!")
						self.pctl.selected_in_playlist = 0
						return
					if track.artist.casefold != artist.casefold:
						self.pctl.selected_in_playlist = 0
						self.pctl.playlist_view_position = 0
					if len(blocks) == 1:
						block = blocks[0]
						if len(block) > 1:
							if c < block[0] or c >= block[-1]:
								select = block[0]
								self.tauon.toast(_("First of artist's albums ({N} albums)")
									.format(N=len(block)))
							else:
								select = block[-1]
								self.tauon.toast(_("Last of artist's albums ({N} albums)")
									.format(N=len(block)))
					else:
						select = None
						for bb, block in enumerate(blocks):
							for i, al in enumerate(block):
								if al <= c:
									continue
								next = True
								if i == 0:
									select = al
									if len(block) > 1:
										self.tauon.toast(_("Start of location {N} of {T} ({Nb} albums)")
											.format(N=bb + 1, T=len(blocks), Nb=len(block)))
									else:
										self.tauon.toast(_("Location {N} of {T}")
											.format(N=bb + 1, T=len(blocks)))
									break

							if next and not select:
								select = block[-1]
								if len(block) > 1:
									self.tauon.toast(_("End of location {N} of {T} ({Nb} albums)")
										.format(N=bb + 1, T=len(blocks), Nb=len(block)))
								else:
									self.tauon.toast(_("Location {N} of {T}")
										.format(N=bb, T=len(blocks)))
								break
							if select:
								break
					if not select:
						select = blocks[0][0]
						if len(blocks[0]) > 1:
							if len(blocks) > 1:
								self.tauon.toast(_("Start of location 1 of {N} ({Nb} albums)")
									.format(N=len(blocks), Nb=len(blocks[0])))
							else:
								self.tauon.toast(_("Location 1 of {N} ({Nb} albums)")
									.format(N=len(blocks), Nb=len(blocks[0])))
						else:
							self.tauon.toast(_("Location 1 of {N}")
								.format(N=len(blocks)))

					self.pctl.playlist_view_position = select
					self.pctl.selected_in_playlist = select
					self.d_click_ref = artist
					self.d_click_timer.set()
					if self.prefs.album_mode:
						self.tauon.goto_album(select)
				else:
					self.d_click_ref = artist
					self.d_click_timer.set()

			if self.inp.middle_click:
				self.click_ref = artist
				self.click_highlight_timer.set()
				self.tauon.create_artist_pl(artist)

			if self.inp.right_click:
				self.click_ref = artist
				self.click_highlight_timer.set()

				self.artist_list_menu.activate(in_reference=artist)

	def render(self, x: int, y: int, w: int, h: int) -> None:
		if self.prefs.artist_list_style == 1:
			self.tab_h = round(60 * self.gui.scale)
		else:
			self.tab_h = round(22 * self.gui.scale)

		viewing_pl_id = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].uuid_int

		# use parent playlst is set
		if self.pctl.multi_playlist[self.pctl.active_playlist_viewing].parent_playlist_id:

			# test if parent still exists
			new = self.pctl.id_to_pl(self.pctl.multi_playlist[self.pctl.active_playlist_viewing].parent_playlist_id)
			if new is None:
				self.pctl.multi_playlist[self.pctl.active_playlist_viewing].parent_playlist_id = 0
			else:
				viewing_pl_id = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].parent_playlist_id

		if viewing_pl_id in self.saves:
			self.current_artists = self.saves[viewing_pl_id].all_artists
			self.current_album_counts = self.saves[viewing_pl_id].album_counts
			self.current_artist_track_counts = self.saves[viewing_pl_id].artist_track_counts
			self.scroll_position = self.saves[viewing_pl_id].scroll_position

			if self.saves[viewing_pl_id].playlist_length != len(self.pctl.multi_playlist[self.pctl.id_to_pl(viewing_pl_id)].playlist_ids):
				del self.saves[viewing_pl_id]
				return
		else:
			# if self.current_pl != viewing_pl_id:
			self.id_to_load = viewing_pl_id
			if not self.load:
				# self.prep()
				self.current_artists = []
				self.current_album_counts = []
				self.current_artist_track_counts = {}
				self.load = True
				self.thread_manager.ready("worker")

		area = (x, y, w, h)
		area2 = (x + 1, y, w - 3, h)

		self.ddt.rect(area, self.colours.side_panel_background)
		self.ddt.text_background_colour = self.colours.side_panel_background

		item_height = self.tab_h
		touch_scroll = self.inp.touch_scroll_y != 0 and coll_point(self.inp.touch_position, area)
		use_smooth_scroll = (
			self.smooth_scroll.enabled()
			or touch_scroll
			or self.smooth_scroll.active("artist list")
		)
		if use_smooth_scroll:
			if self.coll(area) and self.inp.mouse_wheel:
				mx = 1
				if self.prefs.artist_list_style == 2:
					mx = 3
				self.smooth_scroll.add_wheel_motion("artist list", -self.inp.mouse_wheel, item_height * mx)
			if self.inp.touch_released:
				self.smooth_scroll.release_touch("artist list")
			elif touch_scroll:
				self.smooth_scroll.apply_touch_drag("artist list", -self.inp.touch_scroll_y)
			self.scroll_position += self.smooth_scroll.step_motion("artist list") / max(item_height, 1)
		elif self.coll(area) and self.inp.mouse_wheel:
			mx = 1
			if self.prefs.artist_list_style == 2:
				mx = 3
			self.scroll_position -= self.smooth_scroll.scroll("artist list", mx)

		self.scroll_position = max(self.scroll_position, 0)

		range = (h // self.tab_h) - 1

		whole_range = math.floor(h // self.tab_h)

		if range > 4 and self.scroll_position > len(self.current_artists) - range:
			self.scroll_position = len(self.current_artists) - range

		if len(self.current_artists) <= whole_range:
			self.scroll_position = 0

		self.fields.add(area2)
		scroll_x = x + w - 18 * self.gui.scale
		if self.colours.lm:
			scroll_x = x + w - 22 * self.gui.scale
		if (self.coll(area2) or self.tauon.artist_list_scroll.held) and not self.tauon.pref_box.enabled:
			scroll_width = 15 * self.gui.scale
			inset = 0
			if self.gui.compact_artist_list:
				pass
				# scroll_width = round(6 * self.gui.scale)
				# scroll_x += round(9 * self.gui.scale)
			else:
				self.scroll_position = self.tauon.artist_list_scroll.draw(
					scroll_x, y + 1, scroll_width, h, self.scroll_position,
					len(self.current_artists) - range, r_click=self.inp.right_click,
					jump_distance=35, extend_field=6 * self.gui.scale)

		if not self.current_artists:
			text = _("No artists in playlist")

			if self.pctl.default_playlist:
				text = _("Artist threshold not met")
			if self.load:
				text = _("Loading Artist List...")
				if self.pctl.loading_in_progress or self.tauon.transcode_list or self.tauon.after_scan:
					text = _("Busy...")

			self.ddt.text(
				(x + w // 2, y + (h // 7), 2), text, alpha_mod(self.colours.side_bar_line2, 100), 212,
				max_w=w - 17 * self.gui.scale)

		i = int(self.scroll_position)
		yy = y + 12 * self.gui.scale - ((self.scroll_position - i) * self.tab_h)

		if viewing_pl_id in self.saves:
			self.saves[viewing_pl_id].scroll_position = self.scroll_position

		prefetch_mode = False
		prefetch_distance = 22

		self.shown_letters.clear()

		self.hover_any = False

		for i, artist in enumerate(self.current_artists[i:], start=i):
			if not prefetch_mode:
				self.draw_card(artist, x, round(yy), w)

				yy += self.tab_h

				# Enter prefetch (stop drawing) only once the *next* card's top has
				# passed the bottom edge of the widget. Using a 24px margin here
				# culled cards while their top was still inside the widget, so with
				# smooth scrolling items vanished ~24px before scrolling off.
				if yy - y >= h:
					prefetch_mode = True
					continue

			if prefetch_mode:
				if self.prefs.artist_list_style == 2:
					break
				prefetch_distance -= 1
				if prefetch_distance < 1:
					break
				if artist not in self.thumb_cache:
					self.load_img(artist)
					break

		if not self.hover_any:
			self.gui.preview_artist = ""
			self.hover_timer.force_set(10)
			self.artist_preview_render.show = False
			self.hover_on = False

class TreeView:

	def __init__(self, tauon: Tauon, pctl: PlayerCtl) -> None:
		self.tauon                 = tauon
		self.pctl                  = pctl
		self.ddt                   = tauon.ddt
		self.inp                   = tauon.inp
		self.gui                   = tauon.gui
		self.coll                  = tauon.coll
		self.windows                  = tauon.windows
		self.prefs                 = tauon.prefs
		self.fields                = tauon.fields
		self.colours               = tauon.colours
		self.formats               = tauon.formats
		self.window_size           = tauon.window_size
		self.smooth_scroll         = tauon.smooth_scroll
		self.tree_view_scroll      = pctl.tree_view_scroll
		self.folder_tree_menu      = tauon.folder_tree_menu
		self.folder_tree_stem_menu = tauon.folder_tree_stem_menu
		self.trees = {}  # Per playlist tree
		self.rows = []  # For display (parsed from tree)
		self.rows_id = ""

		self.opens = {}  # Folders clicks to show per playlist

		self.scroll_positions = {}

		# Recursive gen_rows vars
		self.count = 0
		self.depth = 0

		self.background_processing = False
		self.d_click_timer = Timer(100)
		self.d_click_id = ""

		self.menu_selected = ""
		self.folder_colour_cache = {}
		self.dragging_name = ""

		self.force_opens = []
		self.click_drag_source = None

		self.tooltip_on = ""
		self.tooltip_timer = Timer(10)

		self.lock_pl: int | None = None

		# self.bold_colours = ColourGenCache(0.6, 0.7)

	def clear_all(self) -> None:
		self.rows_id = ""
		self.trees.clear()

	def collapse_all(self) -> None:
		pl_id = self.pctl.pl_to_id(self.pctl.active_playlist_viewing)

		if self.lock_pl:
			pl_id = self.lock_pl

		opens = self.opens.get(pl_id)
		if opens is None:
			opens = []
			self.opens[pl_id] = opens

		opens.clear()
		self.rows_id = ""

	def clear_target_pl(self, pl_number: int, pl_id=None) -> None:
		if pl_id is None:
			pl_id = self.pctl.pl_to_id(pl_number)

		if self.gui.lsp and self.prefs.left_panel_mode == "folder view":
			if pl_id in self.trees and not self.background_processing:
				self.background_processing = True
				shoot_dl = threading.Thread(target=self.gen_tree, args=[pl_id])
				shoot_dl.daemon = True
				shoot_dl.start()
		elif pl_id in self.trees:
			del self.trees[pl_id]

	def show_track(self, track: TrackClass) -> None:
		if track is None:
			return

		# Get tree and opened folder data for this playlist
		pl_id = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].uuid_int
		opens = self.opens.get(pl_id)
		if opens is None:
			opens = []
			self.opens[pl_id] = opens

		tree = self.trees.get(pl_id)
		if not tree:
			return

		scroll_position = self.scroll_positions.get(pl_id)
		if scroll_position is None:
			scroll_position = 0

		# Clear all opened folders
		opens.clear()

		# Set every folder in path as opened
		path = ""
		crumbs = track.parent_folder_path.split("/")[1:]
		for c in crumbs:
			path += "/" + c
			opens.append(path)

		# Regenerate row display
		self.gen_rows(tree, opens)

		# Locate and set scroll position to playing folder
		for i, row in enumerate(self.rows):
			if row[1] + "/" + row[0] == track.parent_folder_path:

				scroll_position = i - 5
				scroll_position = max(scroll_position, 0)
				break

		max_scroll = len(self.rows) - ((self.window_size[0] - (self.gui.panelY + self.gui.panelBY)) // round(22 * self.gui.scale))
		scroll_position = min(scroll_position, max_scroll)
		scroll_position = max(scroll_position, 0)

		self.scroll_positions[pl_id] = scroll_position

		self.gui.update_layout = True
		self.gui.update += 1

	def get_pl_id(self) -> int:
		if self.lock_pl is not None:
			# The locked playlist may have been deleted; unlock automatically.
			if self.pctl.id_to_pl(self.lock_pl) is not None:
				return self.lock_pl
			self.lock_pl = None
		return self.pctl.multi_playlist[self.pctl.active_playlist_viewing].uuid_int

	def render(self, x: int, y: int, w: int, h: int) -> None:
		pl_id = self.get_pl_id()
		tree = self.trees.get(pl_id)

		# Generate tree data if not done yet
		if tree is None:
			if not self.background_processing:
				self.background_processing = True
				shoot_dl = threading.Thread(target=self.gen_tree, args=[pl_id])
				shoot_dl.daemon = True
				shoot_dl.start()

			self.playlist_id_on = self.pctl.multi_playlist[self.pctl.active_playlist_viewing].uuid_int

		opens = self.opens.get(pl_id)
		if opens is None:
			opens = []
			self.opens[pl_id] = opens

		scroll_position = self.scroll_positions.get(pl_id)
		if scroll_position is None:
			scroll_position = 0

		area = (x, y, w, h)
		self.fields.add(area)
		self.ddt.rect(area, self.colours.side_panel_background)
		self.ddt.text_background_colour = self.colours.side_panel_background

		if self.background_processing and self.rows_id != pl_id:
			self.ddt.text(
				(x + w // 2, y + (h // 7), 2), _("Loading Folder Tree..."), alpha_mod(self.colours.side_bar_line2, 100),
				212, max_w=w - 17 * self.gui.scale)
			return

		# if not tree or not self.rows:
		#     self.ddt.text((x + w // 2, y + (h // 7), 2), _("Folder Tree"), alpha_mod(self.colours.side_bar_line2, 100),
		#              212, max_w=w - 17 * self.gui.scale)
		#     return
		if not tree:
			self.ddt.text(
				(x + w // 2, y + (h // 7), 2), _("Folder Tree"), alpha_mod(self.colours.side_bar_line2, 100),
				212, max_w=w - 17 * self.gui.scale)
			return

		if self.rows_id != pl_id:
			if not self.background_processing:
				self.gen_rows(tree, opens)
				self.rows_id = pl_id
				max_scroll = len(self.rows) - (h // round(22 * self.gui.scale))
				scroll_position = min(scroll_position, max_scroll)

			else:
				return

		if not self.rows:
			self.ddt.text(
				(x + w // 2, y + (h // 7), 2), _("Folder Tree"), alpha_mod(self.colours.side_bar_line2, 100),
				212, max_w=w - 17 * self.gui.scale)
			return

		spacing = round(21 * self.gui.scale)
		max_scroll = len(self.rows) - (h // round(22 * self.gui.scale))

		mouse_in = self.coll(area)
		touch_scroll = self.inp.touch_scroll_y != 0 and coll_point(self.inp.touch_position, area)
		use_smooth_scroll = (
			self.smooth_scroll.enabled()
			or touch_scroll
			or self.smooth_scroll.active("tree view")
		)

		# Mouse wheel scrolling
		if use_smooth_scroll:
			if mouse_in and self.inp.mouse_wheel:
				self.smooth_scroll.add_wheel_motion("tree view", -self.inp.mouse_wheel, spacing * 2)
			if self.inp.touch_released:
				self.smooth_scroll.release_touch("tree view")
			elif touch_scroll:
				self.smooth_scroll.apply_touch_drag("tree view", -self.inp.touch_scroll_y)
			scroll_position += self.smooth_scroll.step_motion("tree view") / max(spacing, 1)
			scroll_position = max(scroll_position, 0)
			scroll_position = min(scroll_position, max_scroll)
		elif mouse_in and self.inp.mouse_wheel:
			scroll_position -= self.smooth_scroll.scroll("tree view",2)
			scroll_position = max(scroll_position, 0)
			scroll_position = min(scroll_position, max_scroll)

		focused = self.tauon.is_level_zero()

		# Draw scroll bar
		if mouse_in or self.tree_view_scroll.held:
			scroll_position = self.tree_view_scroll.draw(
				x + w - round(12 * self.gui.scale), y + 1, round(11 * self.gui.scale), h,
				scroll_position,
				max_scroll, r_click=self.inp.right_click, jump_distance=40)

		self.scroll_positions[pl_id] = scroll_position
		scroll_start = int(scroll_position)
		scroll_offset = (scroll_position - scroll_start) * spacing
		yy = y + round(11 * self.gui.scale) - scroll_offset
		xx = x + round(22 * self.gui.scale)

		# Draw folder rows
		playing_track = self.pctl.playing_object()
		max_w = w - round(45 * self.gui.scale)

		light_mode = test_lumi(self.colours.side_panel_background) < 0.5
		semilight_mode = test_lumi(self.colours.side_panel_background) < 0.8

		for i, item in enumerate(self.rows):

			if i < scroll_start:
				continue

			if yy > y + h - spacing:
				break

			target = item[1] + "/" + item[0]

			inset = item[2] * round(10 * self.gui.scale)
			rect = (xx + inset - round(15 * self.gui.scale), yy, max_w - inset + round(15 * self.gui.scale), spacing - 1)
			self.fields.add(rect)

			# text_colour = ColourRGBA(255, 255, 255, 100)
			text_colour = rgb_add_hls(self.colours.side_panel_background, 0, 0.35, -0.15)

			box_colour = ColourRGBA(200, 100, 50, 255)

			if semilight_mode:
				text_colour = ColourRGBA(255, 255, 255, 180)

			if light_mode:
				text_colour = ColourRGBA(0, 0, 0, 200)

			full_folder_path = item[1] + "/" + item[0]

			# Hold highlight while menu open
			if (self.folder_tree_menu.active or self.folder_tree_stem_menu.active) and full_folder_path == self.menu_selected:
				text_colour = ColourRGBA(255, 255, 255, 170)
				if semilight_mode:
					text_colour = ColourRGBA(255, 255, 255, 255)
				if light_mode:
					text_colour = ColourRGBA(0, 0, 0, 255)

			# Hold highlight while dragging folder
			if self.inp.quick_drag and not point_proximity_test(self.gui.drag_source_position, self.inp.mouse_position, 15):
				if self.gui.shift_selection:
					if self.pctl.get_track(self.pctl.multi_playlist[self.pctl.id_to_pl(pl_id)].playlist_ids[self.gui.shift_selection[0]]).fullpath.startswith(
							full_folder_path + "/") and self.dragging_name and item[0].endswith(self.dragging_name):
						text_colour = ColourRGBA(255, 255, 255, 230)
						if semilight_mode:
							text_colour = ColourRGBA(255, 255, 255, 255)
						if light_mode:
							text_colour = ColourRGBA(0, 0, 0, 255)

			# Set highlight colours if folder is playing
			if (self.pctl.playing_state in (PlayingState.PLAYING, PlayingState.PAUSED)) and playing_track:
				if playing_track.parent_folder_path == full_folder_path or full_folder_path + "/" in playing_track.fullpath:
					text_colour = ColourRGBA(255, 255, 255, 225)
					box_colour  = ColourRGBA(140, 220, 20, 255)
					if semilight_mode:
						text_colour = ColourRGBA(255, 255, 255, 255)
					if light_mode:
						text_colour = ColourRGBA(0, 0, 0, 255)

			if self.inp.right_click:
				mouse_in = self.coll(rect) and self.tauon.is_level_zero(False)
			else:
				mouse_in = self.coll(rect) and focused and not (
					self.inp.quick_drag and not point_proximity_test(self.gui.drag_source_position, self.inp.mouse_position, 15))

			if mouse_in and not self.tree_view_scroll.held:
				if self.inp.middle_click:
					self.tauon.stem_to_new_playlist(full_folder_path)
				elif self.inp.right_click:
					if item[3]:
						for p, id in enumerate(self.pctl.multi_playlist[self.pctl.id_to_pl(pl_id)].playlist_ids):
							if self.windows:
								if self.pctl.get_track(id).fullpath.startswith(target.lstrip("/")):
									self.folder_tree_menu.activate(in_reference=MenuTrackRef(id, p, pl_id))
									self.menu_selected = full_folder_path
									break
							elif self.pctl.get_track(id).fullpath.startswith(target):
								self.folder_tree_menu.activate(in_reference=MenuTrackRef(id, p, pl_id))
								self.menu_selected = full_folder_path
								break
					elif self.windows:
						self.folder_tree_stem_menu.activate(in_reference=full_folder_path.lstrip("/"))
						self.menu_selected = full_folder_path.lstrip("/")
					else:
						self.folder_tree_stem_menu.activate(in_reference=full_folder_path)
						self.menu_selected = full_folder_path

				elif self.inp.mouse_click:
					# self.inp.quick_drag = True
					if not self.click_drag_source:
						self.click_drag_source = item
						self.gui.set_drag_source()

				elif self.inp.mouse_up and self.click_drag_source == item:
					# Click tree level folder to open/close branch

					if target not in opens:
						opens.append(target)
					else:
						for s in reversed(range(len(opens))):
							if opens[s].startswith(target):
								del opens[s]

					if item[3]:
						# Locate the first track of folder in playlist
						track_id = None
						for p, id in enumerate(self.pctl.default_playlist):
							if self.windows:
								if self.pctl.get_track(id).fullpath.startswith(target.lstrip("/")):
									track_id = id
									break
							elif self.pctl.get_track(id).fullpath.startswith(target):
								track_id = id
								break
						else:  # Fallback to folder name if full-path not found (hack for networked items)
							for p, id in enumerate(self.pctl.default_playlist):
								if self.pctl.get_track(id).parent_folder_name == item[0]:
									track_id = id
									break

						if track_id is not None:
							# Single click base folder to locate in playlist
							if self.d_click_timer.get() > 0.5 or self.d_click_id != target:
								self.pctl.show_current(select=True, index=track_id, no_switch=True, highlight=True, folder_list=False)
								self.d_click_timer.set()
								self.d_click_id = target

							# Double click base folder to play
							else:
								self.pctl.jump(track_id)

					# Regenerate display rows after clicking
					self.gen_rows(tree, opens)

			# Highlight folder text on mouse over
			if (mouse_in and not self.inp.mouse_down) or item == self.click_drag_source:
				text_colour = ColourRGBA(255, 255, 255, 235)
				if semilight_mode:
					text_colour = ColourRGBA(255, 255, 255, 255)
				if light_mode:
					text_colour = ColourRGBA(0, 0, 0, 255)

			# Render folder name text
			if item[4] > 50:
				font = 514
				text_label_colour = text_colour  # self.bold_colours.get(full_folder_path)
			else:
				font = 414
				text_label_colour = text_colour

			if mouse_in:
				tw = self.ddt.get_text_w(item[0], font)

				if self.tooltip_on != item:
					self.tooltip_on = item
					self.tooltip_timer.set()
					self.gui.frame_callback_list.append(TestTimer(0.6))

				if tw > max_w - inset and self.tooltip_on == item and self.tooltip_timer.get() >= 0.6:
					rect = (xx + inset, yy - 2 * self.gui.scale, tw + round(20 * self.gui.scale), 20 * self.gui.scale)
					self.ddt.rect(rect, self.ddt.text_background_colour)
					self.ddt.text((xx + inset, yy), item[0], text_label_colour, font)
				else:
					self.ddt.text((xx + inset, yy), item[0], text_label_colour, font, max_w=max_w - inset)
			else:
				self.ddt.text((xx + inset, yy), item[0], text_label_colour, font, max_w=max_w - inset)

			# # Draw inset bars
			# for m in range(item[2] + 1):
			#     if m == 0:
			#         continue
			#     colour = ColourRGBA(255, 255, 255, 20)
			#     if semilight_mode:
			#         colour = ColourRGBA(255, 255, 255, 30)
			#     if light_mode:
			#         colour = ColourRGBA(0, 0, 0, 60)
			#
			#     if i > 0 and self.rows[i - 1][2] == m - 1:  # the top one needs to be slightly lower lower
			#         self.ddt.rect((x + (12 * m) + 2, yy - round(1 * self.gui.scale), round(1 * self.gui.scale), round(17 * self.gui.scale)), colour, True)
			#     else:
			#         self.ddt.rect((x + (12 * m) + 2, yy - round(5 * self.gui.scale), round(1 * self.gui.scale), round(21 * self.gui.scale)), colour, True)

			if self.prefs.folder_tree_codec_colours:
				box_colour = self.folder_colour_cache.get(full_folder_path)
				if box_colour is None:
					box_colour = ColourRGBA(150, 150, 150, 255)

			# Draw indicator box and +/- icons next to folder name
			if item[3]:
				rect = (xx + inset - round(9 * self.gui.scale), yy + round(7 * self.gui.scale), round(4 * self.gui.scale),
						round(4 * self.gui.scale))
				if light_mode or semilight_mode:
					border = round(1 * self.gui.scale)
					self.ddt.rect((rect[0] - border, rect[1] - border, rect[2] + border * 2, rect[3] + border * 2), ColourRGBA(0, 0, 0, 150))
				self.ddt.rect(rect, box_colour)

			elif True:
				if not mouse_in or self.tree_view_scroll.held:
					# text_colour = ColourRGBA(255, 255, 255, 50)
					text_colour = rgb_add_hls(self.colours.side_panel_background, 0, 0.2, -0.10)
					if semilight_mode:
						text_colour = ColourRGBA(255, 255, 255, 70)
					if light_mode:
						text_colour = ColourRGBA(0, 0, 0, 70)
				if target in opens:
					self.ddt.text((xx + inset - round(7 * self.gui.scale), yy + round(1 * self.gui.scale), 2), "-", text_colour, 19)
				else:
					self.ddt.text((xx + inset - round(7 * self.gui.scale), yy + round(1 * self.gui.scale), 2), "+", text_colour, 19)

			yy += spacing

		if self.click_drag_source and not point_proximity_test(self.gui.drag_source_position, self.inp.mouse_position, 15) and \
			self.pctl.default_playlist is self.pctl.multi_playlist[self.pctl.id_to_pl(pl_id)].playlist_ids:
			self.inp.quick_drag = True
			self.gui.playlist_hold = True

			self.dragging_name = self.click_drag_source[0]
			logging.info(self.dragging_name)

			if "/" in self.dragging_name:
				self.dragging_name = os.path.basename(self.dragging_name)

			self.gui.shift_selection.clear()
			self.gui.set_drag_source()
			for p, id in enumerate(self.pctl.multi_playlist[self.pctl.id_to_pl(pl_id)].playlist_ids):
				if self.windows:
					if self.pctl.get_track(id).fullpath.startswith(
							self.click_drag_source[1].lstrip("/") + "/" + self.click_drag_source[0] + "/"):
						self.gui.shift_selection.append(p)
				elif self.pctl.get_track(id).fullpath.startswith(f"{self.click_drag_source[1]}/{self.click_drag_source[0]}/"):
					self.gui.shift_selection.append(p)
			self.click_drag_source = None

		if self.dragging_name and not self.inp.quick_drag:
			self.dragging_name = ""
		if not self.inp.mouse_down:
			self.click_drag_source = None

	def gen_row(self, tree_point, path, opens) -> None:

		for item in tree_point:
			p = path + "/" + item[1]
			self.count += 1
			enter_level = False
			if len(tree_point) > 1 or path in self.force_opens:  # Ignore levels that are only a single folder wide

				if path in opens or self.depth == 0 or path in self.force_opens:  # Only show if parent stem is open, but always show the root displayed folders

					# If there is a single base folder in subfolder, combine the path and show it in upper level
					if len(item[0]) == 1 and len(item[0][0][0]) == 1 and len(item[0][0][0][0][0]) == 0:
						self.rows.append(
							[item[1] + "/" + item[0][0][1] + "/" + item[0][0][0][0][1], path, self.depth, True, len(item[0])])
					elif len(item[0]) == 1 and len(item[0][0][0]) == 0:
						self.rows.append([item[1] + "/" + item[0][0][1], path, self.depth, True, len(item[0])])

					# Add normal base folder type
					else:
						self.rows.append([item[1], path, self.depth, len(item[0]) == 0, len(item[0])])  # Folder name, folder path, depth, is bottom

					# If folder is open and has only one subfolder, mark that subfolder as open
					if len(item[0]) == 1 and (p in opens or p in self.force_opens):
						self.force_opens.append(p + "/" + item[0][0][1])

				self.depth += 1
				enter_level = True

			self.gen_row(item[0], p, opens)

			if enter_level:
				self.depth -= 1

	def gen_rows(self, tree, opens) -> None:
		self.count = 0
		self.depth = 0
		self.rows.clear()
		self.force_opens.clear()

		self.gen_row(tree, "", opens)

		self.gui.update_layout = True
		self.gui.update += 1

	def gen_tree(self, pl_id: int) -> None:
		pl_no = self.pctl.id_to_pl(pl_id)
		if pl_no is None:
			self.background_processing = False
			self.gui.update += 1
			self.tauon.wake()
			return

		playlist = self.pctl.multi_playlist[pl_no].playlist_ids
		# Generate list of all unique folder paths
		paths = []
		z = 5000
		for p in playlist:
			z += 1
			if z > 1000:
				time.sleep(0.01)  # Throttle thread
				z = 0
			track = self.pctl.get_track(p)
			path = track.parent_folder_path
			if path not in paths:
				paths.append(path)
				self.folder_colour_cache[path] = self.formats.colours.get(track.file_ext)

		# Genterate tree from folder paths
		tree = []
		news = []
		for path in paths:
			z += 1
			if z > 5000:
				time.sleep(0.01)  # Throttle thread
				z = 0
			split_path = path.split("/")
			on = tree
			for level in split_path:
				if not level:
					continue
				# Find if level already exists
				for sub_level in on:
					if sub_level[1] == level:
						on = sub_level[0]
						break
				else:  # Create new level
					new = [[], level]
					news.append(new)
					on.append(new)
					on = new[0]

		self.trees[pl_id] = tree
		self.rows_id = ""
		self.background_processing = False
		self.gui.update += 1
		self.tauon.wake()

class QueueBox:

	def __init__(self, tauon: Tauon, pctl: PlayerCtl) -> None:
		self.tauon         = tauon
		self.pctl          = pctl
		self.ddt           = tauon.ddt
		self.gui           = tauon.gui
		self.inp           = tauon.inp
		self.coll          = tauon.coll
		self.prefs         = tauon.prefs
		self.colours       = tauon.colours
		self.window_size   = tauon.window_size
		self.queue_menu    = tauon.queue_menu
		self.smooth_scroll = tauon.smooth_scroll
		self.dragging = None
		self.fq = []
		self.drag_start_y = 0
		self.drag_start_top = 0
		self.tab_h = 0
		self.scroll_position: int = 0
		self.right_click_id = None
		self.d_click_ref = None
		self.recalc()

		self.queue_menu.add(MenuItem(_("Remove This"), self.right_remove_item, show_test=self.queue_remove_show))
		self.queue_menu.add(MenuItem(_("Play Now"), self.play_now, show_test=self.queue_remove_show))
		self.queue_menu.add(MenuItem(_("Auto-Stop Here"), self.toggle_auto_stop, self.toggle_auto_stop_deco, show_test=self.queue_remove_show))

		self.queue_menu.add(MenuItem(_("Pause Queue"), self.toggle_pause, tauon.queue_pause_deco))
		self.queue_menu.add(MenuItem(_("Clear Queue"), tauon.clear_queue, tauon.queue_deco, hint="Alt+Shift+Q"))

		self.queue_menu.add(MenuItem(_("↳ Except for This"), self.clear_queue_crop, show_test=self.except_for_this_show_test))

		self.queue_menu.add(MenuItem(_("Queue to New Playlist"), self.make_as_playlist, tauon.queue_deco))
		# self.queue_menu.add("Finish Playing Album", tauon.finish_current, tauon.finish_current_deco)

	def recalc(self) -> None:
		self.tab_h = 34 * self.gui.scale

	def except_for_this_show_test(self, _) -> bool:
		return self.queue_remove_show(_) and self.inp.test_shift(_)

	def make_as_playlist(self) -> None:
		if self.pctl.force_queue:
			playlist = []
			for item in self.pctl.force_queue:
				if item.type == QueueType.TRACK:
					playlist.append(item.track_id)
				else:

					pl = self.pctl.id_to_pl(item.playlist_id)
					if pl is None:
						logging.info("Lost the target playlist")
						continue

					pp = self.pctl.multi_playlist[pl].playlist_ids

					i = item.position  # = self.pctl.playlist_playing_position + 1

					parts = []
					album_parent_path = self.pctl.get_track(item.track_id).parent_folder_path

					while i < len(pp):
						if self.pctl.get_track(pp[i]).parent_folder_path != album_parent_path:
							break

						parts.append((pp[i], i))
						i += 1

					for part in parts:
						playlist.append(part[0])

			self.pctl.multi_playlist.append(
				self.tauon.pl_gen(
					title=_("Queued Tracks"),
					playlist_ids=copy.deepcopy(playlist),
					hide_title=False))

	def drop_tracks_insert(self, insert_position) -> None:
		if not self.gui.shift_selection:
			return

		# remove incomplete album from queue
		if insert_position == 0 and self.pctl.force_queue and self.pctl.force_queue[0].album_stage == 1:
			self.tauon.split_queue_album(self.pctl.force_queue[0].uuid_int)

		playlist_index = self.pctl.active_playlist_viewing
		playlist_id = self.pctl.pl_to_id(self.pctl.active_playlist_viewing)

		main_track_position = self.gui.shift_selection[0]
		main_track_id = self.pctl.default_playlist[main_track_position]
		self.inp.quick_drag = False

		if len(self.gui.shift_selection) > 1:
			# if shift selection contains only same folder
			for position in self.gui.shift_selection:
				if self.pctl.get_track(self.pctl.default_playlist[position]).parent_folder_path != self.pctl.get_track(
						main_track_id).parent_folder_path or self.inp.key_ctrl_down:
					break
			else:
				# Add as album type
				self.pctl.force_queue.insert(
					insert_position, queue_item_gen(main_track_id, main_track_position, playlist_id, QueueType.ALBUM))
				return

		if len(self.gui.shift_selection) == 1:
			self.pctl.force_queue.insert(insert_position, queue_item_gen(main_track_id, main_track_position, playlist_id))
		else:
			# Add each track
			for position in reversed(self.gui.shift_selection):
				self.pctl.force_queue.insert(
					insert_position, queue_item_gen(self.pctl.default_playlist[position], position, playlist_id))

	def clear_queue_crop(self) -> None:
		save = False
		for item in self.pctl.force_queue:
			if item.uuid_int == self.right_click_id:
				save = item
				break

		self.tauon.clear_queue()
		if save:
			self.pctl.force_queue.append(save)

	def play_now(self) -> None:
		queue_item = None
		queue_index = 0
		for i, item in enumerate(self.pctl.force_queue):
			if item.uuid_int == self.right_click_id:
				queue_item = item
				queue_index = i
				break
		else:
			return

		del self.pctl.force_queue[queue_index]
		# [trackid, position, pl_id, type, album_stage, uid_gen(), auto_stop]

		if self.pctl.force_queue and self.pctl.force_queue[0].album_stage == 1:
			self.tauon.split_queue_album(None)

		target_track_id = queue_item.track_id

		pl = self.pctl.id_to_pl(queue_item.playlist_id)
		if pl is not None:
			self.pctl.active_playlist_playing = pl

		if target_track_id not in self.pctl.playing_playlist():
			self.pctl.advance()
			return

		self.pctl.jump(target_track_id, queue_item.position)

		if queue_item.type == QueueType.ALBUM:
			queue_item.album_stage = 1  # set as partway playing
			self.pctl.force_queue.insert(0, queue_item)

	def toggle_auto_stop(self) -> None:
		for item in self.pctl.force_queue:
			if item.uuid_int == self.right_click_id:
				item.auto_stop ^= True
				break

	def toggle_auto_stop_deco(self) -> Decorator:
		enabled = False
		for item in self.pctl.force_queue:
			if item.uuid_int == self.right_click_id and item.auto_stop:
				enabled = True
				break

		if enabled:
			return Decorator(self.colours.menu_text, self.colours.menu_background, _("Cancel Auto-Stop"))
		return Decorator(self.colours.menu_text, self.colours.menu_background, _("Auto-Stop"))

	def queue_remove_show(self, _: int) -> bool:
		return self.right_click_id is not None

	def right_remove_item(self) -> None:
		if self.right_click_id is None:
			self.show_message(_("Eh?"))

		for u in reversed(range(len(self.pctl.force_queue))):
			if self.pctl.force_queue[u].uuid_int == self.right_click_id:
				del self.pctl.force_queue[u]
				self.gui.pl_update += 1
				break
		else:
			self.show_message(_("Looks like it's gone now anyway"))

	def toggle_pause(self) -> None:
		self.pctl.pause_queue ^= True

	def draw_card(
		self,
		x: int, y: int,
		w: int, h: int,
		yy: int,
		track: TrackClass, fqo: TauonQueueItem,
		draw_back: bool = False, draw_album_indicator: bool = True,
	) -> None:

		# text_colour = ColourRGBA(230, 230, 230, 255)
		bg = self.colours.queue_background

		# if fq[i].type == QueueType.TRACK:

		rect = (x + 13 * self.gui.scale, yy, w - 28 * self.gui.scale, self.tab_h)

		if draw_back:
			self.ddt.rect(rect, self.colours.queue_card_background)
			bg = self.colours.queue_card_background

		text_colour1 = rgb_add_hls(bg, 0, 0.28, -0.15)  # [255, 255, 255, 70]
		text_colour2 = ColourRGBA(255, 255, 255, 230)
		if test_lumi(bg) < 0.2:
			text_colour1 = ColourRGBA(0, 0, 0, 130)
			text_colour2 = ColourRGBA(0, 0, 0, 230)

		self.tauon.gall_ren.render(track, (rect[0] + 4 * self.gui.scale, rect[1] + 4 * self.gui.scale), round(28 * self.gui.scale))

		self.ddt.rect((rect[0] + 4 * self.gui.scale, rect[1] + 4 * self.gui.scale, 26, 26), ColourRGBA(0, 0, 0, 6))

		line = track.album
		if fqo.type == QueueType.TRACK:
			line = track.title

		if not line:
			line = clean_string(track.filename)

		line2y = yy + 14 * self.gui.scale

		artist_line = track.artist
		if fqo.type == QueueType.ALBUM and track.album_artist:
			artist_line = track.album_artist

		if fqo.type == QueueType.TRACK and not artist_line:
			line2y -= 7 * self.gui.scale

		self.ddt.text(
			(rect[0] + (40 * self.gui.scale), yy - 1 * self.gui.scale), artist_line, text_colour1, 210,
			max_w=rect[2] - 60 * self.gui.scale, bg=bg)

		self.ddt.text(
			(rect[0] + (40 * self.gui.scale), line2y), line, text_colour2, 211,
			max_w=rect[2] - 60 * self.gui.scale, bg=bg)

		if draw_album_indicator:
			if fqo.type == QueueType.ALBUM:
				if fqo.album_stage == 0:
					self.ddt.rect((rect[0] + rect[2] - 5 * self.gui.scale, rect[1], 5 * self.gui.scale, rect[3]), ColourRGBA(220, 130, 20, 255))
				else:
					self.ddt.rect((rect[0] + rect[2] - 5 * self.gui.scale, rect[1], 5 * self.gui.scale, rect[3]), ColourRGBA(140, 220, 20, 255))

			if fqo.auto_stop:
				xx = rect[0] + rect[2] - 9 * self.gui.scale
				if fqo.type == QueueType.ALBUM:
					xx -= 11 * self.gui.scale
				self.ddt.rect((xx, rect[1] + 5 * self.gui.scale, 7 * self.gui.scale, 7 * self.gui.scale), ColourRGBA(230, 190, 0, 255))

	def draw(self, x: int, y: int, w: int, h: int) -> None:
		yy = y
		yy += round(4 * self.gui.scale)

		sep_colour = alpha_blend(ColourRGBA(255, 255, 255, 11), self.colours.queue_background)

		if y > self.gui.panelY + 10 * self.gui.scale:  # Draw fancy light mode border
			self.gui.queue_frame_draw = y
		# else:
		# 	if not self.colours.lm:
		# 		self.ddt.rect((x, y, w, 3 * self.gui.scale),  self.colours.queue_background, True)

		yy += round(3 * self.gui.scale)

		box_rect = (x, yy - 6 * self.gui.scale, w, h)
		self.ddt.rect(box_rect, self.colours.queue_background)
		self.ddt.text_background_colour = self.colours.queue_background

		if self.coll(box_rect) and self.inp.quick_drag and not self.pctl.force_queue:
			self.ddt.rect(box_rect, ColourRGBA(255, 255, 255, 2))
			self.ddt.text_background_colour = alpha_blend(ColourRGBA(255, 255, 255, 2), self.ddt.text_background_colour)

		# if y < self.gui.panelY * 2:
		#     self.ddt.rect((x, y - 3 * self.gui.scale, w, 30 * self.gui.scale), self.colours.queue_background, True)

		if h > 40 * self.gui.scale:
			if not self.pctl.force_queue:
				text = _("Add to Queue") if self.inp.quick_drag else _("Queue")
				self.ddt.text((x + (w // 2), y + 15 * self.gui.scale, 2), text, alpha_mod(self.colours.index_text, 200), 212)

		qb_right_click = 0

		if self.coll(box_rect):
			# Update scroll position
			scroll_distance = self.smooth_scroll.scroll("queue")
			self.scroll_position -= scroll_distance
			self.scroll_position = max(self.scroll_position, 0)

			if self.inp.right_click:
				qb_right_click = 1

		# text_colour = ColourRGBA(255, 255, 255, 91)
		text_colour = rgb_add_hls(self.colours.queue_background, 0, 0.3, -0.15)
		if test_lumi(self.colours.queue_background) < 0.2:
			text_colour = ColourRGBA(0, 0, 0, 200)

		line = _("Up Next:")
		if self.pctl.force_queue:
			# line = "Queue"
			self.ddt.text((x + (10 * self.gui.scale), yy + 2 * self.gui.scale), line, text_colour, 211)

		yy += 7 * self.gui.scale

		if len(self.pctl.force_queue) < 3:
			self.scroll_position = 0

		# Draw square dots to indicate view has been scrolled down
		if self.scroll_position > 0:
			ds = 3 * self.gui.scale
			gp = 4 * self.gui.scale

			self.ddt.rect((x + int(w / 2), yy, ds, ds), ColourRGBA(230, 190, 0, 255))
			self.ddt.rect((x + int(w / 2), yy + gp, ds, ds), ColourRGBA(230, 190, 0, 255))
			self.ddt.rect((x + int(w / 2), yy + gp + gp, ds, ds), ColourRGBA(230, 190, 0, 255))

		# Draw pause icon
		if self.pctl.pause_queue:
			self.ddt.rect((x + w - 24 * self.gui.scale, yy + 2 * self.gui.scale, 3 * self.gui.scale, 9 * self.gui.scale), ColourRGBA(230, 190, 0, 255))
			self.ddt.rect((x + w - 19 * self.gui.scale, yy + 2 * self.gui.scale, 3 * self.gui.scale, 9 * self.gui.scale), ColourRGBA(230, 190, 0, 255))

		yy += 6 * self.gui.scale

		yy += 10 * self.gui.scale

		i = 0

		# Get new copy of queue if not dragging
		if not self.dragging:
			self.fq = copy.deepcopy(self.pctl.force_queue)
		else:
			# self.gui.update += 1
			self.gui.update_on_drag = True

		# End drag if mouse not in correct state for it
		if not self.inp.mouse_down and not self.inp.mouse_up:
			self.dragging = None

		if not self.queue_menu.active:
			self.right_click_id = None

		fq = self.fq

		list_top = yy

		i: int = round(self.scroll_position)

		# Limit scroll distance
		if i > len(fq):
			self.scroll_position = len(fq)
			i = self.scroll_position

		showed_indicator = False
		list_extends = False
		x1 = x + 13 * self.gui.scale  # highlight position
		w1 = w - 28 * self.gui.scale - 10 * self.gui.scale

		while i < len(fq) + 1:
			# Stop drawing if past window
			if yy > self.window_size[1] - self.gui.panelBY - self.gui.panelY - (50 * self.gui.scale):
				list_extends = True
				break

			# Calculate drag collision box. Special case for first and last which extend out in y direction
			h_rect = (x + 13 * self.gui.scale, yy, w - 28 * self.gui.scale, self.tab_h + 3 * self.gui.scale)
			if i == len(fq):
				h_rect = (x + 13 * self.gui.scale, yy, w - 28 * self.gui.scale, self.tab_h + 3 * self.gui.scale + 1000 * self.gui.scale)
			if i == 0:
				h_rect = (
				0, yy - 1000 * self.gui.scale, w - 28 * self.gui.scale + 10000, self.tab_h + 3 * self.gui.scale + 1000 * self.gui.scale)

			if self.dragging is not None and self.coll(h_rect) and self.inp.mouse_up:
				ob = None
				for u in reversed(range(len(self.pctl.force_queue))):

					if self.pctl.force_queue[u].uuid_int == self.dragging:
						ob = self.pctl.force_queue[u]
						self.pctl.force_queue[u] = None
						break
				else:
					self.dragging = None

				if self.dragging:
					self.pctl.force_queue.insert(i, ob)
					self.dragging = None

				for u in reversed(range(len(self.pctl.force_queue))):
					if self.pctl.force_queue[u] is None:
						del self.pctl.force_queue[u]
						self.gui.pl_update += 1
						continue

					# Reset album in flag if not first item
					if self.pctl.force_queue[u].album_stage == 1:
						if u != 0:
							self.pctl.force_queue[u].album_stage = 0

				self.inp.mouse_click = False
				self.draw(x, y, w, h)
				return

			if i > len(fq) - 1:
				break

			track = self.pctl.get_track(fq[i].track_id)
			rect = (x + 13 * self.gui.scale, yy, w - 28 * self.gui.scale, self.tab_h)

			if self.inp.mouse_click and self.coll(rect):
				self.dragging = fq[i].uuid_int
				self.drag_start_y = self.inp.mouse_position[1]
				self.drag_start_top = yy

				if self.tauon.d_click_timer.get() < 1:
					if self.d_click_ref == fq[i].uuid_int:
						pl = self.pctl.id_to_pl(fq[i].playlist_id)
						if pl is not None:
							self.pctl.switch_playlist(pl)

						self.pctl.show_current(playing=False, highlight=True, index=fq[i].track_id)
						self.d_click_ref = None
				# else:
				self.d_click_ref = fq[i].uuid_int

				self.tauon.d_click_timer.set()

			if self.dragging and self.coll(h_rect):
				yy += self.tab_h
				yy += 4 * self.gui.scale

			if qb_right_click and self.coll(rect):
				self.right_click_id = fq[i].uuid_int
				qb_right_click = 2

			if self.inp.middle_click and self.coll(rect):
				self.pctl.force_queue.remove(fq[i])
				self.gui.pl_update += 1

			if fq[i].uuid_int == self.dragging:
				# self.ddt.rect_r(rect, [22, 22, 22, 255], True)
				pass
			else:

				db = False
				if fq[i].uuid_int == self.right_click_id:
					db = True

				self.draw_card(x, y, w, h, yy, track, fq[i], db)

				# Drag tracks from main playlist and insert ------------
				if self.inp.quick_drag:
					if x < self.inp.mouse_position[0] < x + w:
						y1 = yy - 4 * self.gui.scale
						y2 = y1
						h1 = self.tab_h // 2
						if i == 0:
							# Extend up if first element
							y1 -= 5 * self.gui.scale
							h1 += 10 * self.gui.scale

						insert_position = None

						if y1 < self.inp.mouse_position[1] < y1 + h1:
							self.ddt.rect((x1, yy - 2 * self.gui.scale, w1, 2 * self.gui.scale), self.colours.queue_drag_indicator_colour)
							showed_indicator = True

							if self.inp.mouse_up:
								insert_position = i
						elif y2 < self.inp.mouse_position[1] < y2 + self.tab_h + 5 * self.gui.scale:
							self.ddt.rect(
								(x1, yy + self.tab_h + 2 * self.gui.scale, w1, 2 * self.gui.scale),
								self.colours.queue_drag_indicator_colour)
							showed_indicator = True

							if self.inp.mouse_up:
								insert_position = i + 1

						if insert_position is not None:
							self.drop_tracks_insert(insert_position)

				# -----------------------------------------
				yy += self.tab_h
				yy += 4 * self.gui.scale

			i += 1

		# Show drag marker if mouse holding below list
		if self.inp.quick_drag and not list_extends and not showed_indicator and fq and self.inp.mouse_position[
			1] > yy - 4 * self.gui.scale and self.coll(box_rect):
			yy -= self.tab_h
			yy -= 4 * self.gui.scale
			self.ddt.rect((x1, yy + self.tab_h + 2 * self.gui.scale, w1, 2 * self.gui.scale), self.colours.queue_drag_indicator_colour)
			yy += self.tab_h
			yy += 4 * self.gui.scale

		yy += 15 * self.gui.scale
		if fq:
			self.ddt.rect((x, yy, w, 3 * self.gui.scale), sep_colour)
		yy += 11 * self.gui.scale

		# Calculate total queue duration
		duration = 0
		tracks = 0

		for item in fq:
			if item.type == QueueType.TRACK:
				duration += self.pctl.get_track(item.track_id).length
				tracks += 1
			else:
				pl = self.pctl.id_to_pl(item.playlist_id)
				if pl is not None:
					playlist = self.pctl.multi_playlist[pl].playlist_ids
					i = item.position

					album_parent_path = self.pctl.get_track(item.track_id).parent_folder_path

					playing_track = self.pctl.playing_object()

					if pl == self.pctl.active_playlist_playing \
					and item.album_stage \
					and playing_track and playing_track.parent_folder_path == album_parent_path:
						i = self.pctl.playlist_playing_position + 1

					if item.track_id not in playlist:
						continue
					if i > len(playlist) - 1:
						continue
					if playlist[i] != item.track_id:
						i = playlist.index(item.track_id)

					while i < len(playlist):
						if self.pctl.get_track(playlist[i]).parent_folder_path != album_parent_path:
							break

						duration += self.pctl.get_track(playlist[i]).length
						tracks += 1
						i += 1

		# Show total duration text "n Tracks [0:00:00]"
		if tracks and fq:
			if tracks < 2:
				line = _("{N} Track").format(N=str(tracks)) + " [" + get_hms_time(duration) + "]"
				self.ddt.text((x + 12 * self.gui.scale, yy), line, text_colour, 11.5, bg=self.colours.queue_background)
			else:
				line = _("{N} Tracks").format(N=str(tracks)) + " [" + get_hms_time(duration) + "]"
				self.ddt.text((x + 12 * self.gui.scale, yy), line, text_colour, 11.5, bg=self.colours.queue_background)

		if self.dragging:
			fqo = None
			for item in fq:
				if item.uuid_int == self.dragging:
					fqo = item
					break
			else:
				self.dragging = False

			if self.dragging:
				yyy = self.drag_start_top + (self.inp.mouse_position[1] - self.drag_start_y)
				yyy = max(yyy, list_top)
				track = self.pctl.get_track(fqo.track_id)
				self.draw_card(x, y, w, h, yyy, track, fqo, draw_back=True)

		# Drag and drop tracks from main playlist into queue
		if self.inp.quick_drag and self.inp.mouse_up and self.coll(box_rect) and self.gui.shift_selection:
			self.drop_tracks_insert(len(fq))

		# Right click context menu in blank space
		if qb_right_click:
			if qb_right_click == 1:
				self.right_click_id = None
			self.queue_menu.activate(position=self.inp.mouse_position)

class MetaBox:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon: Tauon = tauon
		self.ddt: TDraw = tauon.ddt
		self.gui: GuiVar = tauon.gui
		self.inp: Input = tauon.inp
		self.coll            = tauon.coll
		self.pctl: PlayerCtl = tauon.pctl
		self.fonts: Fonts = tauon.fonts
		self.prefs: Prefs = tauon.prefs
		self.fields: Fields = tauon.fields
		self.colours: ColoursClass = tauon.colours
		self.showcase_menu: Menu = tauon.showcase_menu
		self.lyrics_ren_mini: LyricsRenMini = tauon.lyrics_ren_mini

	def l_panel(self, x: int, y: int, w: int, h: int, track: TrackClass, top_border: bool = True) -> None:
		colours = self.colours
		ddt = self.ddt

		if not track:
			return

		border_colour = ColourRGBA(255, 255, 255, 30)
		line1_colour = ColourRGBA(255, 255, 255, 235)
		line2_colour = ColourRGBA(255, 255, 255, 200)
		if test_lumi(colours.gallery_background) < 0.55:
			border_colour = ColourRGBA(0, 0, 0, 30)
			line1_colour = ColourRGBA(0, 0, 0, 200)
			line2_colour = ColourRGBA(0, 0, 0, 230)

		rect = (x, y, w, h)

		ddt.rect(rect, colours.gallery_background)
		if top_border:
			ddt.rect((x, y, w, round(1 * self.gui.scale)), border_colour)
		else:
			ddt.rect((x, y + h - round(1 * self.gui.scale), w, round(1 * self.gui.scale)), border_colour)

		ddt.text_background_colour = colours.gallery_background

		insert = round(9 * self.gui.scale)
		border = round(2 * self.gui.scale)

		compact_mode = False
		if w < h * 1.9:
			compact_mode = True

		art_rect: list[float] = [
			x + insert - 2 * self.gui.scale, y + insert, h - insert * 2 + 1 * self.gui.scale, h - insert * 2 + 1 * self.gui.scale]

		if compact_mode:
			art_rect[0] = x + round(w / 2 - art_rect[2] / 2) - round(1 * self.gui.scale)  # - border

		border_rect = (
			art_rect[0] - border, art_rect[1] - border, art_rect[2] + (border * 2), art_rect[3] + (border * 2))

		self.gui.main_art_box = art_rect


		if (self.inp.mouse_click or self.inp.right_click) and self.tauon.is_level_zero(False):
			if self.coll(border_rect):
				if self.inp.mouse_click:
					self.tauon.milky.projectm.load_next = "random"
				else:
					self.tauon.album_art_gen.cycle_offset(track)
				if self.inp.right_click:
					if self.tauon.prefs.milk and not self.gui.milkdrop_in_widget:
						self.tauon.milky_menu.activate(in_reference=track)
					else:
						self.tauon.picture_menu.activate(in_reference=track)
			elif self.coll(rect):
				if self.inp.mouse_click:
					self.pctl.show_current()
				if self.inp.right_click:
					self.showcase_menu.activate(track)

		ddt.rect(border_rect, border_colour)
		ddt.rect(art_rect, colours.gallery_background)



		self.tauon.album_art_gen.display(track, (art_rect[0], art_rect[1]), (art_rect[2], art_rect[3]))

		self.fields.add(border_rect)

		if self.tauon.prefs.milk and not self.gui.milkdrop_in_widget and self.tauon.pctl.playing_state in (
				PlayingState.PLAYING, PlayingState.URL_STREAM, PlayingState.PAUSED):
			if self.pctl.a_time < 1.3:
				if 1 < self.pctl.a_time < 1.3:
					self.tauon.milky.render(discard=True)
					self.tauon.milky.burn(track)
			else:
				self.tauon.milky.render()
			if self.tauon.pctl.playing_state != PlayingState.PAUSED:
				# gui.update += 1
				self.gui.delay_frame(self.tauon.frame_pace())

		elif self.coll(border_rect) and self.tauon.is_level_zero(True):
			showc = self.tauon.album_art_gen.get_info(track)
			self.tauon.art_metadata_overlay(
				art_rect[0] + art_rect[2] + 2 * self.gui.scale, art_rect[1] + art_rect[3] + 12 * self.gui.scale, showc)

		if not compact_mode:
			text_x = border_rect[0] + border_rect[2] + round(10 * self.gui.scale)
			max_w = w - (border_rect[2] + 28 * self.gui.scale)
			yy = y + round(15 * self.gui.scale)

			ddt.text((text_x, yy), track.title, line1_colour, 316, max_w=max_w)
			yy += round(20 * self.gui.scale)
			ddt.text((text_x, yy), track.artist, line2_colour, 14, max_w=max_w)
			yy += round(30 * self.gui.scale)
			ddt.text((text_x, yy), track.album, line2_colour, 14, max_w=max_w)
			yy += round(20 * self.gui.scale)
			ddt.text((text_x, yy), track.date, line2_colour, 14, max_w=max_w)

			self.gui.showed_title = True

	def lyrics(self, x: int, y: int, w: int, h: int, track: TrackClass) -> None:
		bg = self.colours.lyrics_panel_background
		self.ddt.rect((x, y, w, h), bg)
		self.ddt.text_background_colour = bg

		if not track:
			return

		# Test for show lyric menu on right ckick
		if self.coll((x + 10, y, w - 10, h)):
			if self.inp.right_click:  # and (self.pctl.playing_state in (PlayingState.PLAYING, PlayingState.PAUSED)):
				self.gui.force_showcase_index = -1
				self.showcase_menu.activate(track)

		# Test for scroll wheel input
		if self.inp.mouse_wheel != 0 and self.coll((x + 10, y, w - 10, h)):
			self.lyrics_ren_mini.lyrics_position += self.inp.mouse_wheel * 30 * self.gui.scale
			if self.lyrics_ren_mini.lyrics_position > 0:
				self.lyrics_ren_mini.lyrics_position = 0
				self.tauon.lyric_side_top_pulse.pulse()

			self.gui.update += 1

		tw, th = self.ddt.get_text_wh(track.lyrics + "\n", 15, w - 50 * self.gui.scale, True)

		oth = th

		th -= h
		th += 25 * self.gui.scale  # Empty space buffer at end

		if self.lyrics_ren_mini.lyrics_position * -1 > th:
			self.lyrics_ren_mini.lyrics_position = th * -1
			if oth > h:
				self.tauon.lyric_side_bottom_pulse.pulse()

		scroll_w = 15 * self.gui.scale
		if self.gui.maximized:
			scroll_w = 17 * self.gui.scale

		self.lyrics_ren_mini.lyrics_position = self.tauon.mini_lyrics_scroll.draw(
			x + w - 17 * self.gui.scale, y, scroll_w, h,
			self.lyrics_ren_mini.lyrics_position * -1, th,
			jump_distance=160 * self.gui.scale) * -1

		margin = 10 * self.gui.scale
		if self.colours.lm:
			margin += 1 * self.gui.scale

		self.lyrics_ren_mini.render(
			self.pctl.track_queue[self.pctl.queue_step], x + margin,
			y + self.lyrics_ren_mini.lyrics_position + 13 * self.gui.scale,
			w - 50 * self.gui.scale,
			None, 0)

		self.ddt.rect((x, y + h - 1, w, 1), self.colours.lyrics_panel_background)

		self.tauon.lyric_side_top_pulse.render(x, y, w - round(17 * self.gui.scale), 16 * self.gui.scale)
		self.tauon.lyric_side_bottom_pulse.render(x, y + h, w - round(17 * self.gui.scale), 15 * self.gui.scale, bottom=True)

	def draw(self, x: int, y: int, w: int, h: int, track: TrackClass | None=None) -> None:
		bg = self.colours.side_panel_background
		self.ddt.text_background_colour = bg
		self.ddt.clear_rect((x, y, w, h))
		self.ddt.rect((x, y, w, h), bg)


		if not track:
			return

		# Test for show lyric menu on right ckick
		if self.coll((x + 10, y, w - 10, h)):
			if self.inp.right_click:  # and (self.pctl.playing_state in (PlayingState.PLAYING, PlayingState.PAUSED)):
				self.gui.force_showcase_index = -1
				self.showcase_menu.activate(track)

		if self.pctl.playing_state == PlayingState.STOPPED:
			if not self.prefs.meta_persists_stop and not self.prefs.meta_shows_selected and not self.prefs.meta_shows_selected_always:
				return

		if h < 15:
			return

		# Check for lyrics if auto setting
		self.tauon.test_auto_lyrics(track)


		# # Draw lyrics if available
		# if prefs.show_lyrics_side and pctl.track_queue \
		# and track.lyrics and h > 45 * gui.scale and w > 200 * gui.scale:
		#
		# 	self.lyrics(x, y, w, h, track)

		# Draw standard metadata

		if len(self.pctl.track_queue) > 0:
			if self.pctl.playing_state == PlayingState.STOPPED:
				if not self.prefs.meta_persists_stop and not self.prefs.meta_shows_selected and not self.prefs.meta_shows_selected_always:
					return

			self.ddt.text_background_colour = self.colours.side_panel_background

			if self.coll((x + 10, y, w - 10, h)):
				# Click area to jump to current track
				if self.inp.mouse_click:
					self.pctl.show_current()
					self.gui.update += 1

			title = ""
			album = ""
			artist = ""
			ext = ""
			date = ""
			genre = ""

			margin = x + 10 * self.gui.scale
			if self.colours.lm:
				margin += 2 * self.gui.scale

			text_width = w - 25 * self.gui.scale
			tr = None

			# if pctl.playing_state != PlayingState.URL_STREAM:

			if self.pctl.playing_state == PlayingState.STOPPED and self.prefs.meta_persists_stop:
				tr = self.pctl.master_library[self.pctl.track_queue[self.pctl.queue_step]]
			if self.pctl.playing_state == PlayingState.STOPPED and self.prefs.meta_shows_selected:
				if -1 < self.pctl.selected_in_playlist < len(self.pctl.multi_playlist[self.pctl.active_playlist_viewing].playlist_ids):
					tr = self.pctl.get_track(self.pctl.multi_playlist[self.pctl.active_playlist_viewing].playlist_ids[self.pctl.selected_in_playlist])

			if self.prefs.meta_shows_selected_always and self.pctl.playing_state != PlayingState.URL_STREAM:
				if -1 < self.pctl.selected_in_playlist < len(self.pctl.multi_playlist[self.pctl.active_playlist_viewing].playlist_ids):
					tr = self.pctl.get_track(self.pctl.multi_playlist[self.pctl.active_playlist_viewing].playlist_ids[self.pctl.selected_in_playlist])

			if tr is None:
				tr = self.pctl.playing_object()
			if tr is None:
				return

			title = tr.title
			album = tr.album
			artist = tr.artist
			ext = tr.file_ext
			if ext == "JELY":
				ext = "Jellyfin"
				if tr.container is not None:
					ext = (tr.container if tr.container is not None else "") + " | Jellyfin"
			if tr.lyrics:
				ext += ","
			date = tr.date
			genre = tr.genre

			if not title and not artist:
				title = self.pctl.tag_meta

			if h > 58 * self.gui.scale:
				block_y = y + 7 * self.gui.scale

				if not self.prefs.show_side_art:
					block_y += 3 * self.gui.scale

				if title:
					self.ddt.text(
						(margin, block_y + 2 * self.gui.scale), title, self.colours.side_bar_line1, self.fonts.side_panel_line1,
						max_w=text_width)
				if artist:
					self.ddt.text(
						(margin, block_y + 23 * self.gui.scale), artist, self.colours.side_bar_line2, self.fonts.side_panel_line2,
						max_w=text_width)

				self.gui.showed_title = True

				if h > 140 * self.gui.scale:
					block_y = y + 80 * self.gui.scale
					if artist:
						self.ddt.text(
							(margin, block_y), album, self.colours.side_bar_line2,
							self.fonts.side_panel_line2, max_w=text_width)

					if not genre == date == "":
						line = date
						if genre:
							if line:
								line += " | "
							line += genre

						self.ddt.text(
							(margin, block_y + 20 * self.gui.scale), line, self.colours.side_bar_line2,
							self.fonts.side_panel_line2, max_w=text_width)

					if ext:
						if ext == "RADIO":
							ext = self.tauon.radiobox.playing_title
						sp = self.ddt.text(
							(margin, block_y + 40 * self.gui.scale), ext, self.colours.side_bar_line2,
							self.fonts.side_panel_line2, max_w=text_width)

						if tr and tr.lyrics:
							if self.tauon.draw_internal_link(
								margin + sp + 6 * self.gui.scale, block_y + 40 * self.gui.scale, "Lyrics", self.colours.side_bar_line2, self.fonts.side_panel_line2):
								self.prefs.show_lyrics_showcase = True
								self.tauon.enter_showcase_view(track_id=tr.index)

	def centered(self, x: int, y: int, w: int, h: int, track: TrackClass | None) -> None:
		"""Centered track text layout used by the custom-layout "Track: Centered"
		widget: artist/title/album text centred in the box, no album art. Based on
		the centered side-panel layout (prefs.side_panel_layout == 1)."""
		ddt = self.ddt
		colours = self.colours
		tauon = self.tauon
		prefs = self.prefs
		gui = self.gui
		inp = self.inp
		pctl = self.pctl
		window_size = self.tauon.window_size
		center_info_menu = self.tauon.center_info_menu
		radiobox = self.tauon.radiobox
		target_track = track

		ddt.clear_rect((x, y, w, h))
		ddt.rect((x, y, w, h), colours.side_panel_background)
		tauon.test_auto_lyrics(target_track)
		if prefs.show_lyrics_side and target_track and target_track.lyrics:
			if inp.right_click and tauon.coll((x, y, w, h)) and target_track:
				center_info_menu.activate(target_track)
		else:
			small_mode = window_size[1] < 550 * gui.scale
			text_y = y + round(h * 0.40)
			text_x = x + w // 2
			if inp.right_click and tauon.coll((x, y, w, h)) and target_track:
				center_info_menu.activate(target_track)
			ww = w - 25 * gui.scale
			gui.showed_title = True
			if target_track:
				ddt.text_background_colour = colours.side_panel_background
				if pctl.playing_state == PlayingState.URL_STREAM and not radiobox.dummy_track.title:
					title = pctl.tag_meta
				else:
					title = target_track.title
					if not title:
						title = clean_string(target_track.filename)
				if small_mode:
					ddt.text((text_x, text_y - 15 * gui.scale, 2), target_track.artist, colours.side_bar_line1, 315, max_w=ww)
					ddt.text((text_x, text_y + 12 * gui.scale, 2), title, colours.side_bar_line1, 216, max_w=ww)
					line = " | ".join(filter(None, (target_track.album, target_track.date, target_track.genre)))
					ddt.text((text_x, text_y + 35 * gui.scale, 2), line, colours.side_bar_line2, 313, max_w=ww)
				else:
					ddt.text((text_x, text_y - 15 * gui.scale, 2), target_track.artist, colours.side_bar_line1, 317, max_w=ww)
					ddt.text((text_x, text_y + 17 * gui.scale, 2), title, colours.side_bar_line1, 218, max_w=ww)
					line = " | ".join(filter(None, (target_track.album, target_track.date, target_track.genre)))
					ddt.text((text_x, text_y + 45 * gui.scale, 2), line, colours.side_bar_line2, 314, max_w=ww)


class PictureRender:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon    = tauon
		self.ddt      = tauon.ddt
		self.renderer = tauon.renderer
		self.show = False
		self.path = ""

		self.image_data = None
		self.texture = None
		self.srect = None
		self.size = (0, 0)

	def load(self, path: str, box_size: tuple[int, int] | None = None) -> None:
		if not os.path.isfile(path):
			logging.warning("NO PICTURE FILE TO LOAD")
			return

		g = io.BytesIO()
		g.seek(0)

		im = Image.open(path)
		if box_size is not None:
			im.thumbnail(box_size, Image.Resampling.LANCZOS)

		im.save(g, "BMP")
		g.seek(0)
		self.image_data = g
		logging.info("Save BMP to memory")
		self.size = im.size[0], im.size[1]

	def draw(self, x: int, y: int, w: int | None = None, h: int | None = None) -> None:
		# w/h optionally scale the rendered image (keeping the loaded texture);
		# when omitted the image draws at its loaded (thumbnailed) resolution.
		if self.show is False:
			return

		if self.image_data is not None:
			if self.texture is not None:
				sdl3.SDL_DestroyTexture(self.texture)

			# Convert raw image to sdl texture
			#logging.info("Create Texture")
			s_image = self.ddt.load_image(self.image_data)
			self.texture = sdl3.SDL_CreateTextureFromSurface(self.renderer, s_image)
			sdl3.SDL_DestroySurface(s_image)
			tex_w = pointer(c_float(0))
			tex_h = pointer(c_float(0))
			sdl3.SDL_GetTextureSize(self.texture, tex_w, tex_h)
			self.srect = sdl3.SDL_FRect(round(x), round(y))
			self.srect.w = int(tex_w.contents.value)
			self.srect.h = int(tex_h.contents.value)
			self.image_data = None

		if self.texture is not None:
			self.srect.x = round(x)
			self.srect.y = round(y)
			if w is not None:
				self.srect.w = round(w)
			if h is not None:
				self.srect.h = round(h)
			sdl3.SDL_RenderTexture(self.renderer, self.texture, None, self.srect)
			self.tauon.style_overlay.hole_punches.append(self.srect)

class ArtistInfoBox:

	def __init__(self, tauon: Tauon, pctl: PlayerCtl) -> None:
		self.pctl                  = pctl
		self.tauon                 = tauon
		self.gui                   = tauon.gui
		self.ddt                   = tauon.ddt
		self.inp                   = tauon.inp
		self.coll                  = tauon.coll
		self.prefs                 = tauon.prefs
		self.fields                = tauon.fields
		self.colours               = tauon.colours
		self.smooth_scroll         = tauon.smooth_scroll
		self.user_directory        = tauon.user_directory
		self.a_cache_directory     = tauon.a_cache_directory
		self.artist_info_menu      = tauon.artist_info_menu
		self.artist_picture_render = tauon.artist_picture_render
		self.artist_on = None
		self.min_rq_timer = Timer()
		self.min_rq_timer.force_set(10)

		self.urls: list[tuple[str, ColourRGBA, str]] = []
		self.text = ""
		self.status = ""
		self.scroll_y = 0

		self.process_text_artist = ""
		self.processed_text = ""
		self.th = 0
		self.w = 0
		self.lock = False

		self.mini_box = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "mini-box.png", True)

	def manual_dl(self) -> None:
		track = self.pctl.playing_object()
		if track is None or not track.artist:
			self.show_message(_("No artist name found"), mode="warning")
			return

		# Check if the artist has changed
		self.artist_on = get_first_artist(track.artist)

		if not self.lock and self.artist_on:
			self.lock = True
			# self.min_rq_timer.set()

			self.scroll_y = 0
			self.status = _("Looking up...")
			self.process_text_artist = ""

			shoot_dl = threading.Thread(target=self.get_data, args=([track.artist, False, True]))
			shoot_dl.daemon = True
			shoot_dl.start()

	def draw(self, x: int, y: int, w: int, h: int, panel_mode: bool = True) -> None:
		# panel_mode covers the standard artist-info panel's self-management
		# (auto-shrink the bio pref, auto-close when too narrow); the custom
		# layout widget passes False — its segment size is user-controlled.
		if panel_mode:
			if self.gui.artist_panel_height > 300 * self.gui.scale and w < 500 * self.gui.scale:
				self.tauon.bio_set_small()

			if w < 300 * self.gui.scale:
				self.gui.artist_info_panel = False
				self.gui.update_layout = True
				return

		track = self.pctl.playing_object()
		if track is None:
			return

		# Check if the artist has changed
		artist = get_first_artist(track.artist)
		wait = False

		# Activate menu
		if self.inp.right_click and self.coll((x, y, w, h)):
			self.artist_info_menu.activate(in_reference=artist)

		background = self.colours.artist_bio_background
		text_colour = self.colours.artist_bio_text
		self.ddt.rect((x + 10, y + 5, w - 15, h - 5), background)

		if artist != self.artist_on:
			if artist == "":
				return

			if self.min_rq_timer.get() < 10:  # Limit rate
				if os.path.isfile(os.path.join(self.a_cache_directory, artist + "-lfm.txt")):
					pass
				else:
					self.status = _("Cooldown...")
					wait = True

			if self.pctl.playing_time < 2:
				if os.path.isfile(os.path.join(self.a_cache_directory, artist + "-lfm.txt")):
					pass
				else:
					self.status = "..."
					wait = True

			if not wait and not self.lock:
				self.lock = True
				# self.min_rq_timer.set()

				self.scroll_y = 0
				self.status = _("Loading...")

				shoot_dl = threading.Thread(target=self.get_data, args=([track.artist]))
				shoot_dl.daemon = True
				shoot_dl.start()

		if self.process_text_artist != self.artist_on:
			self.process_text_artist = self.artist_on

			text = self.text
			#lic = ""
			link = ""

			if "<a" in text:
				text, ex = text.split('<a href="', 1)
				link, ex = ex.split('">', 1)
				#lic = ex.split("</a>. ", 1)[1]

			text += "\n"
			self.urls = [(link, ColourRGBA(200, 60, 60, 255), "L")]
			for word in text.replace("\n", " ").split(" "):
				if word.strip()[:4] == "http" or word.strip()[:4] == "www.":
					word = word.rstrip(".")
					if word.strip()[:4] == "www.":
						word = "http://" + word
					if "bandcamp" in word:
						self.urls.append((word.strip(), ColourRGBA(200, 150, 70, 255), "B"))
					elif "soundcloud" in word:
						self.urls.append((word.strip(), ColourRGBA(220, 220, 70, 255), "S"))
					elif "twitter" in word:
						self.urls.append((word.strip(), ColourRGBA(80, 110, 230, 255), "T"))
					elif "facebook" in word:
						self.urls.append((word.strip(), ColourRGBA(60, 60, 230, 255), "F"))
					elif "youtube" in word:
						self.urls.append((word.strip(), ColourRGBA(210, 50, 50, 255), "Y"))
					else:
						self.urls.append((word.strip(), ColourRGBA(120, 200, 60, 255), "W"))

			self.processed_text = text
			self.w = -1  # trigger text recalc

		if self.status == "Ready":
			scale = self.gui.scale
			pad = round(10 * scale)
			pic = self.artist_picture_render
			has_pic = bool(pic.show and pic.size and pic.size[0] and pic.size[1])

			# Stack the text under the image when the widget is taller than wide.
			stacked = has_pic and h > w

			img_x = img_y = 0
			img_w = img_h = 0.0
			if has_pic:
				img_ar = pic.size[0] / pic.size[1]
				if stacked:
					# Image on top; cap its height to half the widget height.
					img_w = w - 2 * pad
					img_h = img_w / img_ar
					max_h = h * 0.5 - pad
					if img_h > max_h:
						img_h = max_h
						img_w = img_h * img_ar
				else:
					# Image on the left; cap its width to half the widget width.
					img_h = h - 2 * pad
					img_w = img_h * img_ar
					max_w = w * 0.5 - pad
					if img_w > max_w:
						img_w = max_w
						img_h = img_w / img_ar
				# Never upscale beyond the loaded resolution.
				if img_w > pic.size[0]:
					img_w = pic.size[0]
					img_h = img_w / img_ar
				img_x = round(x + pad)
				img_y = round(y + pad)
				img_w = round(img_w)
				img_h = round(img_h)

			# Text region: below the image when stacked, else to its right.
			# Room for the link-pin column (pins sit at ~25px from the right, and
			# shift left another 15px when the scrollbar shows) plus a small gap.
			right_margin = round(45 * scale)
			if stacked:
				text_x = round(x + pad)
				text_top = img_y + img_h + pad
				text_area_h = (y + h) - text_top - pad
			else:
				text_x = (img_x + img_w + pad) if has_pic else round(x + pad)
				text_top = round(y + 14 * scale)
				text_area_h = h - round(22 * scale)
			text_w = (x + w - right_margin) - text_x
			width = int(text_w - (text_w % 20))

			if self.w != width:
				tw, th = self.ddt.get_text_wh(self.processed_text, 14.5, max(width, 20), True)
				self.th = th
				self.w = width

			scroll_max = max(self.th - text_area_h, 0)

			touch_scroll = self.inp.touch_scroll_y != 0 and coll_point(self.inp.touch_position, (x, y, w, h))
			use_smooth_scroll = (
				self.smooth_scroll.enabled()
				or touch_scroll
				or self.smooth_scroll.active("artistinfo")
			)
			artistinfo_scroll_step = round(10 * self.gui.scale)
			if use_smooth_scroll:
				if self.coll((x, y, w, h)) and self.inp.mouse_wheel:
					self.smooth_scroll.add_wheel_motion("artistinfo", -self.inp.mouse_wheel, artistinfo_scroll_step)
				if self.inp.touch_released:
					self.smooth_scroll.release_touch("artistinfo")
				elif touch_scroll:
					self.smooth_scroll.apply_touch_drag("artistinfo", -self.inp.touch_scroll_y)
				self.scroll_y += self.smooth_scroll.step_motion("artistinfo")
			elif self.coll((x, y, w, h)):
				scroll_distance = self.smooth_scroll.scroll("artistinfo", artistinfo_scroll_step)
				self.scroll_y -= scroll_distance
			self.scroll_y = max(self.scroll_y, 0)
			self.scroll_y = min(self.scroll_y, scroll_max)

			right = x + w - 25 * self.gui.scale

			if self.th > text_area_h:
				self.scroll_y = self.tauon.artist_info_scroll.draw(
					x + w - 20, y + 5, 15, h - 5,
					self.scroll_y, scroll_max, True, jump_distance=250 * self.gui.scale)
				right -= 15

			if has_pic:
				pic.draw(img_x, img_y, img_w, img_h)

			if width > 20 * scale:
				self.ddt.text(
					(text_x, text_top, 4, width, 14000), self.processed_text,
					text_colour, 14.5, bg=background, range_height=text_area_h, range_top=self.scroll_y)

			# Pin the link column to the top of the text region (below the image
			# when stacked) so it never sits on top of the artwork.
			yy = (text_top - round(2 * scale)) if stacked else (y + 12)
			for item in self.urls:
				rect = (right - 2, yy - 2, 16, 16)

				self.fields.add(rect)
				self.mini_box.render(right, yy, alpha_mod(item[1], 100))
				if self.coll(rect):
					if not self.inp.mouse_click:
						self.gui.cursor_want = 3
					if self.inp.mouse_click:
						webbrowser.open(item[0], new=2, autoraise=True)
					self.gui.pl_update += 1
					w = self.ddt.get_text_w(item[0], 13)
					xx = (right - w) - 17 * self.gui.scale
					self.ddt.rect(
						(xx - 10 * self.gui.scale, yy - 4 * self.gui.scale, w + 20 * self.gui.scale, 24 * self.gui.scale),
						ColourRGBA(15, 15, 15, 255))
					self.ddt.rect(
						(xx - 10 * self.gui.scale, yy - 4 * self.gui.scale, w + 20 * self.gui.scale, 24 * self.gui.scale),
						ColourRGBA(50, 50, 50, 255))

					self.ddt.text((xx, yy), item[0], ColourRGBA(250, 250, 250, 255), 13, bg=ColourRGBA(15, 15, 15, 255))
					self.mini_box.render(right, yy, ColourRGBA(item[1].r + 20, item[1].g + 20, item[1].b + 20, 255))
				# self.ddt.rect_r(rect, [210, 80, 80, 255], True)

				yy += 19 * self.gui.scale
		else:
			self.ddt.text((x + w // 2, y + h // 2 - 7 * self.gui.scale, 2), self.status, ColourRGBA(255, 255, 255, 60), 313, bg=background)

	def get_data(self, artist: str, get_img_path: bool = False, force_dl: bool = False, silent: bool = False) -> str | None:
		if not get_img_path:
			logging.info("Load Bio Data")

		if not silent and artist is None and not get_img_path:
			self.artist_on = None
			self.lock = False
			return ""

		f_artist = filename_safe(artist)

		img_filename = f_artist + "-ftv-full.jpg"
		text_filename = f_artist + "-lfm.txt"
		img_filepath_dcg = os.path.join(self.a_cache_directory, f_artist + "-dcg.jpg")
		img_filepath = os.path.join(self.a_cache_directory, img_filename)
		text_filepath = os.path.join(self.a_cache_directory, text_filename)

		standard_path = os.path.join(self.a_cache_directory, f_artist + "-lfm.webp")
		image_paths = [
			str(self.user_directory / "artist-pictures" / (f_artist + ".png")),
			str(self.user_directory / "artist-pictures" / (f_artist + ".jpg")),
			str(self.user_directory / "artist-pictures" / (f_artist + ".webp")),
			str(self.a_cache_directory / (f_artist + "-ftv-full.jpg")),
			str(self.a_cache_directory / (f_artist + "-lfm.png")),
			str(self.a_cache_directory / (f_artist + "-lfm.jpg")),
			str(self.a_cache_directory / (f_artist + "-lfm.webp")),
			str(self.a_cache_directory / (f_artist + "-dcg.jpg")),
		]

		if get_img_path:
			for path in image_paths:
				if os.path.isfile(path):
					return path
			return ""

		# Check for cache
		box_size = (round(self.gui.artist_panel_height - 20 * self.gui.scale) * 2, round(self.gui.artist_panel_height - 20 * self.gui.scale))
		try:
			if not silent and os.path.isfile(text_filepath):
				logging.info("Load cached bio and image")

				self.artist_picture_render.show = False

				for path in image_paths:
					if os.path.isfile(path):
						filepath = path
						self.artist_picture_render.load(filepath, box_size)
						self.artist_picture_render.show = True
						break

				with open(text_filepath, encoding="utf-8") as f:
					self.text = f.read()
				self.status = "Ready"
				self.gui.update = 2
				self.artist_on = get_first_artist(artist)
				self.lock = False

				return ""

			if not silent and not force_dl and not self.prefs.auto_dl_artist_data:
				# . Alt: No artist data has been downloaded (try imply this needs to be manually triggered)
				self.status = _("No artist data downloaded")
				self.artist_on = get_first_artist(artist)
				self.artist_picture_render.show = False
				self.lock = False
				return None

			if silent and not force_dl and not self.prefs.auto_dl_artist_data:
				return None
			# Get new from last.fm
			# . Alt: Looking up artist data
			if not silent:
				self.status = _("Looking up...")
				self.gui.update += 1
				self.text = ""

			data = self.tauon.lastfm.artist_info(artist)
			text = ""

			if data[0] is False:
				if not silent:
					self.artist_picture_render.show = False
					self.status = _("No artist bio found")
					self.artist_on = get_first_artist(artist)
					self.lock = False
				return None
			if data[1]:
				text = data[1]
				if not silent:
					self.text = text
			# cover_link = data[2]
			# Save text as file
			f = open(text_filepath, "w", encoding="utf-8")
			f.write(text)
			f.close()
			logging.info("Save bio text")

			if not silent:
				self.artist_picture_render.show = False

			got_image_path = ""
			if data[3] and self.prefs.enable_fanart_artist:
				try:
					self.tauon.save_fanart_artist_thumb(data[3], img_filepath)
					got_image_path = img_filepath
				except Exception:
					logging.exception("Failed to find image from fanart.tv")

			if not got_image_path and self.tauon.verify_discogs():
				try:
					self.tauon.save_discogs_artist_thumb(artist, img_filepath_dcg)
					got_image_path = img_filepath_dcg
				except Exception:
					logging.exception("Failed to find image from discogs")

			if not got_image_path and data[4]:
				try:
					r = requests.get(data[4], timeout=10)
					html = BeautifulSoup(r.text, "html.parser")
					tag = html.find("meta", property="og:image")
					if tag and tag.get("content"):
						url = tag["content"]
						r = requests.get(url, timeout=10)
						assert len(r.content) > 1000
						with open(standard_path, "wb") as f:
							f.write(r.content)
						got_image_path = standard_path

				except Exception:
					logging.exception("Failed to scrape art")
				else:
					if not got_image_path:
						logging.info(f"No artist image found for '{artist}'")

			if not silent and got_image_path:
				self.artist_picture_render.load(got_image_path, box_size)
				self.artist_picture_render.show = True
			if silent:
				return None

			# Trigger reload of thumbnail in artist list box
			for key, value in list(self.tauon.artist_list_box.thumb_cache.items()):
				if key == artist:
					del self.tauon.artist_list_box.thumb_cache[artist]
					break

			self.status = "Ready"
			self.gui.update = 2

			# if cover_link and 'http' in cover_link:
			#     # Fetch cover_link
			#     try:
			#         #logging.info("Fetching artist image...")
			#         response = urllib.request.urlopen(cover_link)
			#         info = response.info()
			#         #logging.info("got response")
			#         if info.get_content_maintype() == 'image':
			#
			#             f = open(filepath, 'wb')
			#             f.write(response.read())
			#             f.close()
			#
			#             #logging.info("written file, now loading...")
			#
			#             self.artist_picture_render.load(filepath, round(self.gui.artist_panel_height - 20 * self.gui.scale))
			#             self.artist_picture_render.show = True
			#
			#             self.status = "Ready"
			#             self.gui.update = 2
			#     # except HTTPError as e:
			#     #     self.status = e
			#     #     logging.exception("request failed")
			#     except Exception:
			#         logging.exception("request failed")
			#         self.status = "Request Failed"


		except Exception:
			logging.exception("Failed to load bio")
			self.status = _("Load Failed")

		self.artist_on = get_first_artist(artist)
		self.processed_text = ""
		self.process_text_artist = ""
		self.min_rq_timer.set()
		self.lock = False
		self.gui.update = 2
		return ""

class RadioThumbGen:
	def __init__(self, tauon: Tauon) -> None:
		self.gui               = tauon.gui
		self.ddt               = tauon.ddt
		self.prefs             = tauon.prefs
		self.t_agent           = tauon.t_agent
		self.renderer          = tauon.renderer
		self.r_cache_directory = tauon.r_cache_directory
		self.thread_manager    = tauon.thread_manager
		self.cache = {}
		self.requests: list[tuple[RadioStation, int]] = []
		self.size = 100

	def loader(self) -> None:
		while self.requests:
			item = self.requests[0]
			del self.requests[0]
			station = item[0]
			size = item[1]
			key = (station.title, size)
			src = None
			filename = filename_safe(station.title)

			cache_path = os.path.join(self.r_cache_directory, filename + ".jpg")
			if os.path.isfile(cache_path):
				src = open(cache_path, "rb")
			else:
				cache_path = os.path.join(self.r_cache_directory, filename + ".png")
				if os.path.isfile(cache_path):
					src = open(cache_path, "rb")
				else:
					cache_path = os.path.join(self.r_cache_directory, filename)
					if os.path.isfile(cache_path):
						src = open(cache_path, "rb")

			if src:
				pass
				#logging.info("found cached")
			elif station.icon and station.icon not in self.prefs.radio_thumb_bans:
				try:
					r = requests.get(station.icon, headers={"User-Agent": self.t_agent}, timeout=5, stream=True)
					if r.status_code != 200 or int(r.headers.get("Content-Length", 0)) > 2000000:
						raise Exception("Error get radio thumb")
				except Exception:
					logging.exception("error get radio thumb")
					self.cache[key] = [0]
					if station.icon and station.icon not in self.prefs.radio_thumb_bans:
						self.prefs.radio_thumb_bans.append(station.icon)
					continue
				src = io.BytesIO()
				length = 0
				for chunk in r.iter_content(1024):
					src.write(chunk)
					length += len(chunk)
					if length > 2000000:
						src = None
				if src is None:
					self.cache[key] = [0]
					if station.icon and station.icon not in self.prefs.radio_thumb_bans:
						self.prefs.radio_thumb_bans.append(station.icon)
					continue
				src.seek(0)
				with open(cache_path, "wb") as f:
					f.write(src.read())
				src.seek(0)
			else:
				# logging.info("no icon")
				self.cache[key] = [0]
				continue

			try:
				im = Image.open(src)
				if im.mode != "RGBA":
					im = im.convert("RGBA")
			except Exception:
				logging.exception("malform get radio thumb")
				self.cache[key] = [0]
				if station.icon and station.icon not in self.prefs.radio_thumb_bans:
					self.prefs.radio_thumb_bans.append(station.icon)
				continue

			im = im.resize((size, size), Image.Resampling.LANCZOS)
			g = io.BytesIO()
			g.seek(0)
			im.save(g, "PNG")
			g.seek(0)
			s_image = self.ddt.load_image(g)
			self.cache[key] = [2, None, None, s_image]
			self.gui.update += 1

			if src is not None:
				src.close()

	def draw(self, station: RadioStation, x: int, y: int, w: int) -> int:
		if not station.title:
			return 0
		key = (station.title, w)

		r = self.cache.get(key)
		if r is None:
			if len(self.requests) < 3:
				self.requests.append((station, w))
				self.thread_manager.ready("radio-thumb")
			return 0
		if r[0] == 2:
			texture = sdl3.SDL_CreateTextureFromSurface(self.renderer, r[3])
			sdl3.SDL_DestroySurface(r[3])
			tex_w = pointer(c_float(0))
			tex_h = pointer(c_float(0))
			sdl3.SDL_GetTextureSize(texture, tex_w, tex_h)
			rect = sdl3.SDL_FRect(0, 0)
			rect.w = int(tex_w.contents.value)
			rect.h = int(tex_h.contents.value)
			r[2] = texture
			r[1] = rect
			r[0] = 1
		if r[0] == 1:
			r[1].x = round(x)
			r[1].y = round(y)
			sdl3.SDL_RenderTexture(self.renderer, r[2], None, r[1])
			return 1
		return 0

class RadioView:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon         = tauon
		self.ddt           = tauon.ddt
		self.inp           = tauon.inp
		self.gui           = tauon.gui
		self.coll          = tauon.coll
		self.pctl          = tauon.pctl
		self.fields        = tauon.fields
		self.colours       = tauon.colours
		self.prefs         = tauon.prefs
		self.radiobox      = tauon.radiobox
		self.window_size   = tauon.window_size
		self.smooth_scroll = tauon.smooth_scroll
		bag = tauon.bag
		self.add_icon    = asset_loader(bag, bag.loaded_asset_dc, "add-station.png", True)
		self.search_icon = asset_loader(bag, bag.loaded_asset_dc, "station-search.png", True)
		self.save_icon   = asset_loader(bag, bag.loaded_asset_dc, "save-station.png", True)
		self.menu_icon   = asset_loader(bag, bag.loaded_asset_dc, "radio-menu.png", True)
		self.drag = None
		self.click_point = (0, 0)

	def render(self) -> None:
		pctl        = self.pctl
		gui         = self.gui
		window_size = self.window_size
		radiobox    = self.radiobox
		# box = int(window_size[1] * 0.4 + 120 * gui.scale)
		# box = min(window_size[0] // 2, box)
		bg = self.colours.playlist_panel_background
		self.ddt.rect((0, gui.panelY, window_size[0], window_size[1] - gui.panelY), bg)
		#logging.info(prefs.radio_urls)

		# Add station button
		x = window_size[0] - round(60 * gui.scale)
		y = gui.panelY + round(30 * gui.scale)
		rect = (x, y, round(25 * gui.scale), round(25 * gui.scale))
		self.fields.add(rect)

		# right buttions colours
		a_colour = rgb_add_hls(bg, l=0.2, s=-0.3) #colours.box_button_text_highlight
		b_colour = rgb_add_hls(bg, l=0.4, s=-0.3) #colours.box_button_text_highlight
		if test_lumi(bg) < 0.38:
			a_colour = ColourRGBA(20, 20, 20, 200)
			b_colour = ColourRGBA(60, 60, 60, 200)

		if self.coll(rect):
			colour = b_colour
			if self.inp.mouse_click:
				self.tauon.add_station()
		else:
			colour = a_colour

		self.add_icon.render(rect[0] + round(4 * gui.scale), rect[1] + round(4 * gui.scale), colour)

		y += round(33 * gui.scale)
		rect = (x, y, round(25 * gui.scale), round(25 * gui.scale))
		self.fields.add(rect)

		if not self.coll(rect):
			colour = a_colour
		else:
			colour = b_colour
			if self.inp.mouse_click:
				self.tauon.station_browse()
		self.search_icon.render(rect[0] + round(4 * gui.scale), rect[1] + round(4 * gui.scale), colour)

		if pctl.radio_playlist_viewing > len(pctl.radio_playlists) - 1:
			pctl.radio_playlist_viewing = 0
		if not pctl.radio_playlists:
			return
		radios = pctl.radio_playlists[pctl.radio_playlist_viewing].stations

		y += round(32 * gui.scale)
		if pctl.playing_state == PlayingState.URL_STREAM and radiobox.loaded_station not in radios:
			rect = (x, y, round(25 * gui.scale), round(25 * gui.scale))
			self.fields.add(rect)

			if not self.coll(rect):
				colour = a_colour
			else:
				colour = b_colour
				if self.inp.mouse_click:
					radios.append(radiobox.loaded_station)
					self.tauon.toast(_("Added station to: ") + pctl.radio_playlists[pctl.radio_playlist_viewing].name)

			self.save_icon.render(rect[0] + round(3 * gui.scale), rect[1] + round(4 * gui.scale), colour)

		x = round(30 * gui.scale)
		y = gui.panelY + round(30 * gui.scale)
		yy = y

		rbg = rgb_add_hls(self.colours.playlist_panel_background, 0, 0.03, -0.03)
		tbg = rgb_add_hls(self.colours.playlist_panel_background, 0, 0.07, -0.05)
		if contrast_ratio(bg, rbg) < 1.05:
			rbg = ColourRGBA(30, 30, 30, 255)
			tbg = ColourRGBA(60, 60, 60, 255)

		w = round(400 * gui.scale)
		h = round(55 * gui.scale)
		gap = round(7 * gui.scale)

		mm = (window_size[1] - (gui.panelBY + yy + h + round(15 * gui.scale))) // (h + gap) + 1

		count = 0
		scroll = pctl.radio_playlists[pctl.radio_playlist_viewing].scroll
		scroll_area = (0, gui.panelY, w + round(70 * gui.scale), window_size[1] - gui.panelBY - gui.panelY)
		touch_scroll = self.inp.touch_scroll_y != 0 and coll_point(self.inp.touch_position, scroll_area)
		use_smooth_scroll = (
			self.smooth_scroll.enabled()
			or touch_scroll
			or self.smooth_scroll.active("radios")
		)
		if not radiobox.active or (radiobox.active and not self.coll((radiobox.x, radiobox.y, radiobox.w, radiobox.h))):
			if use_smooth_scroll:
				if gui.panelY < self.inp.mouse_position[1] < window_size[1] - gui.panelBY \
				and self.inp.mouse_position[0] < w + round(70 * gui.scale) and self.inp.mouse_wheel:
					self.smooth_scroll.add_wheel_motion("radios", -self.inp.mouse_wheel, h + gap)
				if self.inp.touch_released:
					self.smooth_scroll.release_touch("radios")
				elif touch_scroll:
					self.smooth_scroll.apply_touch_drag("radios", -self.inp.touch_scroll_y)
				scroll += self.smooth_scroll.step_motion("radios") / max(h + gap, 1)
			elif gui.panelY < self.inp.mouse_position[1] < window_size[1] - gui.panelBY \
			and self.inp.mouse_position[0] < w + round(70 * gui.scale):
				scroll_distance = self.smooth_scroll.scroll("radios")
				scroll -= scroll_distance

		scroll = min(scroll, len(radios) - mm + 1)
		scroll = max(scroll, 0)
		if len(radios) > mm:
			scroll = self.tauon.radio_view_scroll.draw(
				round(7 * gui.scale), yy, round(15 * gui.scale), (mm * (h + gap)) - gap, scroll, len(radios) - mm + 1)
		else:
			scroll = 0

		pctl.radio_playlists[pctl.radio_playlist_viewing].scroll = scroll
		insert = None
		scroll_start = int(scroll)
		scroll_offset = (scroll - scroll_start) * max(h + gap, 1)
		yy = y - scroll_offset

		for i, radio in enumerate(radios):
			if count == mm:
				break
			if i < scroll_start:
				continue
			count += 1
			rect = (x, yy, w, h)
			self.ddt.rect(rect, rbg)
			yyy = yy
			pic_rect = (
			x + round(5 * gui.scale), yy + round(5 * gui.scale), h - round(10 * gui.scale), h - round(10 * gui.scale))
			self.ddt.rect(pic_rect, tbg)
			self.tauon.radio_thumb_gen.draw(radio, pic_rect[0], pic_rect[1], pic_rect[2])

			l1_colour = ColourRGBA(10, 10, 10, 210)
			if test_lumi(rbg) > 0.45:
				l1_colour = ColourRGBA(255, 255, 255, 220)
			l2_colour = ColourRGBA(30, 30, 30, 200)
			if test_lumi(rbg) > 0.45:
				l2_colour = ColourRGBA(245, 245, 245, 200)

			toff = h + round(2 * gui.scale)
			yyy += round(9 * gui.scale)
			self.ddt.text(
				(x + toff, yyy), radio.title, l1_colour, 212,
				max_w=w - (toff + round(90 * gui.scale)), bg=rbg)
			yyy += round(19 * gui.scale)
			self.ddt.text(
				(x + toff, yyy), radio.country, l2_colour, 312,
				max_w=w - (toff + round(90 * gui.scale)), bg=rbg)

			hit = False
			start_rect = (
				x + (w - round(40 * gui.scale)), yy + round(8 * gui.scale), h - round(15 * gui.scale),
				round(42 * gui.scale))
			# self.ddt.rect(hit_rect, [255, 255, 255, 3])
			self.fields.add(start_rect)
			colour = rgb_add_hls(tbg, l=0.05)
			if self.coll(start_rect):
				if self.inp.mouse_click:
					radiobox.start(radio)
					hit = True
				colour = rgb_add_hls(colour, l=0.3)

			self.tauon.bottom_bar1.play_button.render(x + (w - round(30 * gui.scale)), yy + round(23 * gui.scale), colour)

			extra_rect = (
				x + (w - round(82 * gui.scale)), yy + round(8 * gui.scale), h - round(15 * gui.scale),
				round(35 * gui.scale))
			# self.ddt.rect(extra_rect, [255, 255, 255, 2])
			self.fields.add(extra_rect)
			colour = rgb_add_hls(tbg, l=0.05)
			if self.coll(extra_rect):
				colour = rgb_add_hls(colour, l=0.3) #alpha_mod(colours.side_bar_line1, 47)
				if self.inp.mouse_click:
					hit = True
					radiobox.x = extra_rect[0] + extra_rect[2]
					radiobox.y = extra_rect[1]
					self.tauon.radio_context_menu.activate((i, radio), position=(radiobox.x, yy + round(20 * gui.scale)))

			self.menu_icon.render(x + (w - round(75 * gui.scale)), yy + round(26 * gui.scale), colour)

			# self.tauon.bottom_bar1.play_button.render(x + (w - round(30 * gui.scale)), yy + round(23 * gui.scale), colour)
			if self.inp.mouse_up and self.drag and self.coll(rect):
				if radiobox.active and self.coll((radiobox.x, radiobox.y, radiobox.w, radiobox.h)):
					pass
				else:
					insert = i
				if not radiobox.active and self.drag in radios and radios.index(self.drag) < i:
					insert += 1
			elif self.coll(rect) and not hit and self.inp.mouse_click:
				self.drag = radio
				self.click_point = copy.copy(self.inp.mouse_position)

			yy += round(h + gap)

		if self.inp.mouse_up and self.drag and not insert and self.drag not in radios:
			if not (radiobox.active and self.coll((radiobox.x, radiobox.y, radiobox.w, radiobox.h))):
				if self.inp.mouse_position[1] > gui.panelY:
					insert = len(radios)

		count = ((window_size[0] - w) / 2) + w
		boxx = round(200 * gui.scale)
		art_rect = (count - boxx / 2, window_size[1] / 3 - boxx / 2, boxx, boxx)

		if window_size[0] > round(700 * gui.scale):
			if pctl.playing_state == PlayingState.URL_STREAM and radiobox.loaded_station:
				r = self.tauon.album_art_gen.display(radiobox.dummy_track, (art_rect[0], art_rect[1]), (art_rect[2], art_rect[3]))
				if r:
					r = self.tauon.radio_thumb_gen.draw(radiobox.loaded_station, art_rect[0], art_rect[1], art_rect[2])
					# if not r:
					# 	self.ddt.rect(art_rect, colours.b)
			# else:
			# 	self.ddt.rect(art_rect, [40, 40, 40, 255])

			yy = window_size[1] / 3 - boxx / 2
			yy += boxx + round(30 * gui.scale)

			if radiobox.loaded_station and pctl.playing_state == PlayingState.URL_STREAM:
				space = window_size[0] - round(500 * gui.scale)
				self.ddt.text(
					(count, yy, 2), radiobox.loaded_station.title, ColourRGBA(230, 230, 230, 255), 213, max_w=space)
				yy += round(25 * gui.scale)
				self.ddt.text((count, yy, 2), radiobox.song_key, ColourRGBA(230, 230, 230, 255), 313, max_w=space)
				if radiobox.dummy_track.album:
					yy += round(21 * gui.scale)
					self.ddt.text((count, yy, 2), radiobox.dummy_track.album, ColourRGBA(230, 230, 230, 255), 313, max_w=space)

		if self.drag:
			gui.update_on_drag = True

		if insert is not None:
			radios.insert(insert, "New")
			if self.drag in radios:
				radios.remove(self.drag)
			else:
				self.tauon.toast(_("Added station to: ") + pctl.radio_playlists[pctl.radio_playlist_viewing].name)

			radios[radios.index("New")] = self.drag
			self.drag = None
			gui.update += 1


def get_renderer_name(renderer: sdl3.LP_SDL_Renderer) -> str | None:
	renderer_name = sdl3.SDL_GetRendererName(renderer)
	if not renderer_name:
		logging.warning(f"SDL_GetRendererName failed: {sdl3.SDL_GetError()}")
		return None

	if isinstance(renderer_name, bytes):
		return renderer_name.decode("utf-8", errors="replace")
	return renderer_name


def renderer_name_supports_milkdrop(renderer_name: str | None) -> bool:
	if renderer_name is None:
		return True
	return renderer_name.casefold() == "opengl"


milky_ready = True
try:
	import OpenGL
	# Disable error checking as SDL can generate errors we do not otherwise catch, crashing PyOpenGL
	OpenGL.ERROR_CHECKING = False
	from OpenGL.GL import (
		GL_ACTIVE_TEXTURE,
		GL_BLEND,
		GL_CLAMP_TO_EDGE,
		GL_COLOR_ATTACHMENT0,
		GL_COLOR_BUFFER_BIT,
		GL_COMPILE_STATUS,
		GL_CURRENT_PROGRAM,
		GL_DEPTH_BUFFER_BIT,
		GL_FRAGMENT_SHADER,
		GL_FRAMEBUFFER,
		GL_FRAMEBUFFER_BINDING,
		GL_FRAMEBUFFER_COMPLETE,
		GL_LINEAR,
		GL_LINK_STATUS,
		GL_PACK_ALIGNMENT,
		GL_RGBA,
		GL_TEXTURE0,
		GL_TEXTURE_2D,
		GL_TEXTURE_BINDING_2D,
		GL_TEXTURE_MAG_FILTER,
		GL_TEXTURE_MIN_FILTER,
		GL_TEXTURE_WRAP_S,
		GL_TEXTURE_WRAP_T,
		GL_TRIANGLES,
		GL_UNSIGNED_BYTE,
		GL_VERSION,
		GL_VERTEX_ARRAY_BINDING,
		GL_VERTEX_SHADER,
		GL_VIEWPORT,
		glActiveTexture,
		glAttachShader,
		glBindFramebuffer,
		glBindTexture,
		glBindVertexArray,
		glCheckFramebufferStatus,
		glClear,
		glClearColor,
		glCompileShader,
		glCopyTexSubImage2D,
		glCreateProgram,
		glCreateShader,
		glDeleteFramebuffers,
		glDeleteShader,
		glDeleteTextures,
		glDisable,
		glDrawArrays,
		glEnable,
		glFinish,
		glFlush,
		glFramebufferTexture2D,
		glGenFramebuffers,
		glGenTextures,
		glGenVertexArrays,
		glGetIntegerv,
		glGetProgramInfoLog,
		glGetProgramiv,
		glGetShaderInfoLog,
		glGetShaderiv,
		glGetString,
		glGetUniformLocation,
		glIsEnabled,
		glLinkProgram,
		glPixelStorei,
		glReadPixels,
		glShaderSource,
		glTexImage2D,
		glTexParameteri,
		glUniform1i,
		glUseProgram,
		glViewport,
	)
except ModuleNotFoundError:
	logging.warning("PyOpenGL not found, Milkdrop visualizer will be disabled")
	milky_ready = False
except Exception:
	logging.exception("Unknown error importing PyOpenGL, Milkdrop visualizer will be disabled")
	milky_ready = False


def find_projectm_library() -> str | None:
	if sys.platform == "win32":
		module_dir = Path(__file__).resolve().parents[2]
		for base_dir in (module_dir, Path(sys.executable).parent, Path.cwd()):
			for dll_name in ("libprojectM-4-4.dll", "libprojectM-4.dll", "projectM-4.dll"):
				path = base_dir / dll_name
				if path.is_file():
					return str(path)
	if sys.platform == "darwin":
		base_dirs = [Path(sys.executable).parent]
		if hasattr(sys, "_MEIPASS"):
			# PyInstaller bundle: dylibs land in Contents/Frameworks
			base_dirs.append(Path(sys._MEIPASS))
		for base_dir in base_dirs:
			for dylib_name in ("libprojectM-4.4.dylib", "libprojectM-4.dylib"):
				path = base_dir / dylib_name
				if path.is_file():
					return str(path)
	for lib_name in ("projectM-4", "libprojectM-4-4", "libprojectM-4"):
		path = ctypes.util.find_library(lib_name)
		if path:
			return path
	return None


if not find_projectm_library():
	milky_ready = False

class ProjectM:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon: Tauon = tauon
		self.lib = None
		self.pm_instance = None
		self.presets: list[Path] = []
		self.loaded_preset: Path | None = None
		self.load_next = None
		self.auto_frames = 0
		self.dirs: list[Path] = [
			Path("/usr/share/projectM/presets"),
			self.tauon.pctl.install_directory / "presets",
			self.tauon.user_directory / "presets"
		]
		self.timer: Timer = Timer()
		self.frame_timer: Timer = Timer()
		self.first_frame: bool = True
		self.lib_error: bool = False
		self.render_frame_fbo_available: bool = False
		self.burn_texture_available: bool = False
		self.set_frame_time_available: bool = False
		self.lib_path: Path | None = None
		self.glew = None
		# macOS: the SDL renderer's GL context is a 2.1 compatibility profile,
		# but projectM needs 3.3+ core, so it renders in its own context and
		# frames are read back to the renderer in Milky.render_readback.
		self.own_gl_context = None
		self.renderer_gl_context = None
		# Max frames projectM accepts per pcm_add_float call; refined from the
		# library in define_function_signatures. Feeding more crashes it.
		self.pcm_max_samples: int = 2048

	def load_library(self) -> None:
		"""Load projectM library using ctypes"""
		renderer_name = get_renderer_name(self.tauon.renderer)
		if renderer_name is not None and renderer_name.casefold() != "opengl":
			logging.warning(f"Not loading ProjectM because SDL renderer is {renderer_name!r}, not 'opengl'")
			self.lib_error = True
			return

		lib_name = find_projectm_library()
		if not lib_name:
			logging.warning("Could not find libprojectM-4")
			self.tauon.show_message("Package ProjectM-4 not found", "Milkdrop feature will be unavailable", mode="error")
			self.lib_error = True
			return
		try:
			self.lib = ctypes.CDLL(lib_name)
			if self.lib:
				if Path(lib_name).is_file():
					self.lib_path = Path(lib_name)
				logging.info(f"Successfully loaded: {lib_name}")
			else:
				logging.warning("Could not load libprojectM-4")
				self.lib_error = True
		except OSError:
			logging.exception("Could not load libprojectM-4")
			self.lib_error = True
		except Exception:
			logging.exception("Unkown error loading libprojectM-4")
			self.lib_error = True

	def setup_function_signatures(self) -> None:
		"""Define ctypes function signatures for basic projectM functions"""
		if not self.lib:
			return

		try:
			# projectm_create - Create projectM instance
			self.lib.projectm_create.argtypes = None
			self.lib.projectm_create.restype = c_void_p

			# # projectm_destroy - Destroy projectM instance
			self.lib.projectm_destroy.argtypes = [c_void_p]
			self.lib.projectm_destroy.restype = None

			# projectm_pcm_add_float - Add audio data
			self.lib.projectm_pcm_add_float.argtypes = [c_void_p, POINTER(c_float), c_uint, c_uint]
			self.lib.projectm_pcm_add_float.restype = None

			# projectm_pcm_get_max_samples - Max frames accepted per add call.
			# Feeding more than this overruns projectM's internal PCM buffer and
			# segfaults, which happens at track start (the vis buffer has filled
			# up) and is reached sooner at high sample rates.
			self.pcm_max_samples = 2048
			try:
				self.lib.projectm_pcm_get_max_samples.argtypes = []
				self.lib.projectm_pcm_get_max_samples.restype = ctypes.c_size_t
				max_samples = int(self.lib.projectm_pcm_get_max_samples())
				if max_samples > 0:
					self.pcm_max_samples = max_samples
			except AttributeError:
				logging.warning("projectm_pcm_get_max_samples not found, using default of 2048")

			# projectm_opengl_render_frame - Render frame
			self.lib.projectm_opengl_render_frame.argtypes = [c_void_p]
			self.lib.projectm_opengl_render_frame.restype = None

			try:
				self.lib.projectm_opengl_render_frame_fbo.argtypes = [c_void_p, c_uint32]
				self.lib.projectm_opengl_render_frame_fbo.restype = None
				self.render_frame_fbo_available = True
			except AttributeError:
				self.render_frame_fbo_available = False
				logging.warning("projectm_opengl_render_frame_fbo not found, using default framebuffer copy fallback")

			# projectm_set_window_size - Render frame
			self.lib.projectm_set_window_size.argtypes = [c_void_p, c_uint, c_uint]
			self.lib.projectm_set_window_size.restype = None

			# projectm_load_preset_file - Load specific preset file
			self.lib.projectm_load_preset_file.argtypes = [c_void_p, c_char_p, c_int]
			self.lib.projectm_load_preset_file.restype = c_int

			self.lib.projectm_set_fps.argtypes = [c_void_p, ctypes.c_int32]
			self.lib.projectm_set_fps.restype = None

			try:
				self.lib.projectm_set_frame_time.argtypes = [c_void_p, ctypes.c_double]
				self.lib.projectm_set_frame_time.restype = None
				self.set_frame_time_available = True
			except AttributeError:
				self.set_frame_time_available = False
				logging.warning("projectm_set_frame_time not found, frame timing override will be unavailable")

			self.lib.projectm_set_texture_search_paths.argtypes = [
				c_void_p,  # instance
				ctypes.POINTER(ctypes.c_char_p),  # texture_search_paths (array of strings)
				ctypes.c_size_t  # count
			]
			self.lib.projectm_set_texture_search_paths.restype = None

			try:
				self.lib.projectm_opengl_burn_texture.argtypes = [c_void_p, c_uint32, c_int, c_int, c_int, c_int]
				self.lib.projectm_opengl_burn_texture.restype = None
				self.burn_texture_available = True
			except AttributeError:
				self.burn_texture_available = False
				logging.warning("projectm_opengl_burn_texture not found, album art burn-in will be skipped")

			logging.debug("Function signatures set up successfully")

		except AttributeError as e:
			logging.warning(f"Error setting up function signatures: {e}")
			raise
		except Exception as e:
			logging.warning(f"Error setting up function signatures: {e}")
			raise

	def init_glew(self) -> bool:
		if sys.platform != "win32":
			return True

		glew_path: str | Path = "glew32.dll"
		if self.lib_path:
			candidate = self.lib_path.with_name("glew32.dll")
			if candidate.is_file():
				glew_path = candidate

		try:
			self.glew = ctypes.CDLL(str(glew_path))
			self.glew.glewInit.argtypes = []
			self.glew.glewInit.restype = c_uint
			result = self.glew.glewInit()
			if result != 0:
				logging.warning(f"GLEW initialization failed with error code {result}")
				return False
			logging.info("GLEW initialized successfully")
			return True
		except Exception:
			logging.exception("Failed to initialize GLEW")
			return False

	def set_texture_paths(self) -> None:
		path_bytes = []
		for path in self.dirs:
			if (path / "textures").is_dir():
				path_bytes.append(str(path / "textures").encode("utf-8"))
		if path_bytes:
			path_array = (ctypes.c_char_p * len(path_bytes))(*path_bytes)
			self.lib.projectm_set_texture_search_paths(self.pm_instance, path_array, len(path_bytes))
			logging.info(f"Set projectm texture paths: {path_bytes}")

	def create_own_context(self) -> bool:
		self.renderer_gl_context = sdl3.SDL_GL_GetCurrentContext()
		sdl3.SDL_GL_SetAttribute(sdl3.SDL_GL_CONTEXT_MAJOR_VERSION, 3)
		sdl3.SDL_GL_SetAttribute(sdl3.SDL_GL_CONTEXT_MINOR_VERSION, 3)
		sdl3.SDL_GL_SetAttribute(sdl3.SDL_GL_CONTEXT_PROFILE_MASK, sdl3.SDL_GL_CONTEXT_PROFILE_CORE)
		self.own_gl_context = sdl3.SDL_GL_CreateContext(self.tauon.t_window)
		if not self.own_gl_context:
			logging.error(f"Failed to create core profile GL context for projectM: {sdl3.SDL_GetError()}")
			return False
		sdl3.SDL_GL_MakeCurrent(self.tauon.t_window, self.own_gl_context)
		return True

	def make_own_context_current(self) -> None:
		sdl3.SDL_GL_MakeCurrent(self.tauon.t_window, self.own_gl_context)

	def restore_renderer_context(self) -> None:
		if self.renderer_gl_context:
			sdl3.SDL_GL_MakeCurrent(self.tauon.t_window, self.renderer_gl_context)

	def init(self, width: int = 800, height: int = 600, preset_path=None) -> bool:
		"""Initialize projectM with basic settings"""
		if not self.lib:
			return False

		# Git version doesn't bump version :(
		# major = c_int()
		# minor = c_int()
		# patch = c_int()
		#
		# self.lib.projectm_get_version_components(byref(major), byref(minor), byref(patch))
		# print(major.value, minor.value, patch.value)
		# if major.value == 4 and minor.value < 2:
		# 	logging.warning(f"Detected libprojectm version {major.value}.{minor.value} but at least 4.2 is required")
		# 	logging.warning("Milkdrop visualiser will be unavailable")
		# 	self.lib_error = True
		# 	self.lib = None
		# 	return False

		try:
			self.setup_function_signatures()
		except Exception:
			logging.warning("Failed to bind projectm functions, milkdrop visualiser will be unavailable")
			self.lib_error = True
			self.lib = None
			return False

		if not self.init_glew():
			self.lib_error = True
			self.lib = None
			return False

		if not (self.tauon.user_directory / "presets").exists():
			(self.tauon.user_directory / "presets").mkdir()

		if sys.platform == "darwin" and not self.create_own_context():
			self.lib_error = True
			self.lib = None
			return False

		# Create projectM instance
		try:
			logging.info("init project m...")
			self.pm_instance = self.lib.projectm_create()
			if self.pm_instance:
				logging.info("ProjectM initialized successfully")
				logging.info(f"Preset path: {preset_path}")

				aud = self.tauon.aud
				if not aud:
					logging.error("Phazor not init for vis")
					return False

				aud.get_vis_side_buffer.argtypes = []
				aud.get_vis_side_buffer.restype = ctypes.POINTER(c_float)

				aud.get_vis_side_buffer_fill.argtypes = []
				aud.get_vis_side_buffer_fill.restype = ctypes.c_int

				self.rescan_presets()
				self.set_texture_paths()

				if self.tauon.prefs.loaded_preset:
					self.load_preset(self.tauon.prefs.loaded_preset)
				else:
					self.random_preset()

				return True
			logging.error("Failed to create projectM instance")
			self.lib_error = True
			self.lib = None
			return False

		except Exception as e:
			logging.exception(f"Error initializing projectM: {e}")
			self.lib_error = True
			self.lib = None
			return False
		finally:
			if self.own_gl_context:
				self.restore_renderer_context()

	def rescan_presets(self) -> None:
		def scan_folder(dir: Path) -> None:
			for item in dir.iterdir():
				if item.is_dir():
					scan_folder(item)
				elif item.suffix.lower() == ".milk":
					#logging.info(f"Found milkdrop {item.stem}")
					self.presets.append(item)

		self.presets.clear()
		for dir in self.dirs:
			if dir.is_dir():
				scan_folder(dir)

	def random_preset(self, fade: bool = False) -> None:
		#self.rescan_presets()
		if not self.presets:
			return
		choice = random.choice(self.presets)
		self.load_preset(choice, fade)

	def get_current_name(self) -> str:
		if self.loaded_preset:
			return self.loaded_preset.stem
		return "Default"

	def log_preset_load_event(self, event: str, preset: Path, fade: bool) -> None:
		try:
			log_path = self.tauon.user_directory / "milkdrop-preset-load.log"
			timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
			previous = str(self.loaded_preset) if self.loaded_preset else ""
			exists = preset.is_file()
			size = preset.stat().st_size if exists else -1
			with log_path.open("a", encoding="utf-8") as file:
				file.write(
					f"{timestamp}\t{event}\tfade={fade}\texists={exists}\tsize={size}"
					f"\tprevious={previous}\tpreset={preset}\n"
				)
				file.flush()
				os.fsync(file.fileno())
		except Exception:
			logging.exception("Failed to write Milkdrop preset load log")

	def load_preset(self, preset: Path, fade: bool = False) -> None:
		self.log_preset_load_event("LOADING", preset, fade)
		self.loaded_preset = preset
		self.tauon.prefs.loaded_preset = preset
		logging.info(f"Loading preset: {preset.stem}")
		self.lib.projectm_load_preset_file(self.pm_instance, str(preset).encode("utf-8"), fade)
		self.log_preset_load_event("LOADED_OK", preset, fade)
		self.auto_frames = 0
		self.timer.set()

	def render_frame(self, framebuffer=None) -> bool:
		"""Render a projectM frame"""
		if not self.pm_instance:
			return False

		if self.load_next:
			if self.load_next == "random":
				self.random_preset()
			else:
				self.load_preset(self.load_next)
			self.load_next = None

		just_faded = False
		fps = int(self.tauon.milky.fps.get())
		if fps and self.tauon.prefs.auto_milk and self.auto_frames > 30 * self.tauon.milky.fps.get():
			if self.timer.get() > 30:
				self.random_preset(fade=True)
				just_faded = True

		# if self.first_frame:
		# 	self.frame_timer.set()
		# 	self.lib.projectm_set_frame_time(self.pm_instance, 0.0)
		# 	self.first_frame = False
		# else:
		# 	t = self.frame_timer.get()
		# 	t = t * 1
		# 	self.lib.projectm_set_frame_time(self.pm_instance, t)

		# feed audio
		aud = self.tauon.aud
		f = aud.get_vis_side_buffer_fill()
		if f > 200:
			buffer_p = aud.get_vis_side_buffer()
			frames = f // 2
			if frames > self.pcm_max_samples:
				# Feed only the most recent samples; passing more than projectM's
				# internal PCM buffer holds overruns it and crashes. This is hit
				# at track start (buffer has filled) and sooner at high rates.
				offset = (frames - self.pcm_max_samples) * 2
				buffer_p = ctypes.cast(
					ctypes.addressof(buffer_p.contents) + offset * ctypes.sizeof(c_float),
					POINTER(c_float))
				frames = self.pcm_max_samples
			self.lib.projectm_pcm_add_float(self.pm_instance, buffer_p, frames, 2)
			aud.reset_vis_side_buffer()

		self.lib.projectm_set_window_size(self.pm_instance, int(self.tauon.gui.main_art_box[2]), int(self.tauon.gui.main_art_box[3]))
		#self.tauon.gui.delay_frame(0.016)

		if self.tauon.pctl.playing_state in (PlayingState.PLAYING, PlayingState.URL_STREAM) or just_faded:
			try:
				if not fps or fps < 1:
					fps = 1

				self.lib.projectm_set_fps(self.pm_instance, fps)
				if self.render_frame_fbo_available:
					self.lib.projectm_opengl_render_frame_fbo(self.pm_instance, framebuffer)
				else:
					saved_fbo = glGetIntegerv(GL_FRAMEBUFFER_BINDING)
					saved_viewport = glGetIntegerv(GL_VIEWPORT)
					try:
						if framebuffer is not None:
							glBindFramebuffer(GL_FRAMEBUFFER, framebuffer)
						glViewport(0, 0, int(self.tauon.gui.main_art_box[2]), int(self.tauon.gui.main_art_box[3]))
						self.lib.projectm_opengl_render_frame(self.pm_instance)
					finally:
						glBindFramebuffer(GL_FRAMEBUFFER, saved_fbo)
						glViewport(
							int(saved_viewport[0]),
							int(saved_viewport[1]),
							int(saved_viewport[2]),
							int(saved_viewport[3]),
						)
			except Exception as e:
				logging.warning(f"Error rendering frame: {e}")

			self.auto_frames += 1
		return True

class Milky:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon:    Tauon = tauon
		self.pctl: PlayerCtl = tauon.pctl
		self.gui:     GuiVar = tauon.gui
		self.ddt:      TDraw = tauon.ddt
		self.coll          = tauon.coll
		self.inp:      Input = tauon.inp
		self.renderer      = tauon.renderer
		self.ready: bool = False
		self.render_texture = None
		self.gl_texture_id = None
		self.framebuffer = None
		self.loaded_size = None
		self.fps = FPSCounter(window_size=10, min_update_interval=0.1, max_frame_time=0.5)
		self.cut_out_blend_mode = None  # composed lazily on first use (shader fallback)
		self._last_keyed = False  # whether the last real frame was blitted keyed (Cut Out shader)

		# Cut Out key pass: a GL shader copies the visualiser frame into a
		# second texture with alpha keyed from brightness, leaving the projectM
		# texture untouched (it feeds back into subsequent frames).
		self.key_texture_id = None
		self.key_framebuffer = None
		self.key_render_texture = None
		self.key_program = None
		self.key_vao = None
		self.key_failed: bool = False  # shader unavailable, use blend-mode fallback

		self.readback_buffer = None  # CPU frame buffer for the dedicated-context path

		self.projectm = ProjectM(tauon)

	def burn(self, target_track: TrackClass) -> None:
		if not self.ready:
			return
		if not self.projectm.burn_texture_available:
			return
		if self.projectm.own_gl_context:
			# Burn needs the SDL renderer to draw into a GL-wrapped texture,
			# which can't be shared with projectM's dedicated context
			return

		w = int(self.tauon.gui.main_art_box[2])
		h = int(self.tauon.gui.main_art_box[3])

		sdl3.SDL_SetRenderTarget(self.renderer, self.render_texture)

		self.tauon.album_art_gen.display(target_track, (0, 0), (w, h), fast=False)
		sdl3.SDL_SetRenderTarget(self.renderer, self.gui.main_texture)

		sdl3.SDL_FlushRenderer(self.renderer)
		context = sdl3.SDL_GL_GetCurrentContext()
		sdl3.SDL_GL_MakeCurrent(self.tauon.t_window, context)
		saved_fbo = glGetIntegerv(GL_FRAMEBUFFER_BINDING)

		glBindFramebuffer(GL_FRAMEBUFFER, self.framebuffer)
		self.projectm.lib.projectm_opengl_burn_texture(self.projectm.pm_instance, self.gl_texture_id, 0, 0, w, h)

		glBindFramebuffer(GL_FRAMEBUFFER, 0)
		glBindTexture(GL_TEXTURE_2D, 0)
		glBindFramebuffer(GL_FRAMEBUFFER, saved_fbo)
		glFlush()
		glFinish()

	def render(self, discard: bool = False) -> None:
		if self.projectm.lib_error is True:
			return

		ddt = self.ddt
		x = self.tauon.gui.main_art_box[0]
		y = self.tauon.gui.main_art_box[1]
		w = self.tauon.gui.main_art_box[2]
		h = self.tauon.gui.main_art_box[3]

		srect = sdl3.SDL_FRect(x, y, w, h)

		#print(f"OpenGL Version: {glGetString(GL_VERSION).decode()}")

		if not self.ready:
			sdl3.SDL_FlushRenderer(self.renderer)
			context = sdl3.SDL_GL_GetCurrentContext()
			if context:
				sdl3.SDL_GL_MakeCurrent(self.tauon.t_window, context)
			self.projectm.load_library()
			self.projectm.init()
			if self.projectm.lib:
				self.ready = True
		if self.projectm.lib_error is True:
			return

		# Paused: re-blit the last rendered frame instead of asking projectM for a
		# fresh one. With playback paused there's no audio buffer driving it, so
		# projectM hands back an empty (transparent) frame — blitted opaque, that
		# punches a transparent hole through the segment. Re-showing the cached
		# texture keeps the visualiser frozen on a still frame instead.
		if not discard and self.tauon.pctl.playing_state == PlayingState.PAUSED \
				and self.render_texture is not None and self.loaded_size == (w, h):
			self._blit_still(srect)
			return

		if self.projectm.own_gl_context:
			self.render_readback(w, h, srect, discard)
			return

		sdl3.SDL_FlushRenderer(self.renderer)
		saved_fbo = glGetIntegerv(GL_FRAMEBUFFER_BINDING)

		if (w, h) != self.loaded_size:

			self.loaded_size = (w, h)
			sdl3.SDL_ClearError()
			context = sdl3.SDL_GL_GetCurrentContext()
			sdl3.SDL_GL_MakeCurrent(self.tauon.t_window, context)

			if self.render_texture:
				sdl3.SDL_DestroyTexture(self.render_texture)
				glDeleteTextures(1, [self.gl_texture_id])
				glDeleteFramebuffers(1, [self.framebuffer])
			if self.key_render_texture:
				sdl3.SDL_DestroyTexture(self.key_render_texture)
				glDeleteTextures(1, [self.key_texture_id])
				glDeleteFramebuffers(1, [self.key_framebuffer])
				self.key_render_texture = None
				self.key_texture_id = None
				self.key_framebuffer = None

			gl_texture_id = glGenTextures(1)
			glBindTexture(GL_TEXTURE_2D, gl_texture_id)
			self.gl_texture_id = gl_texture_id

			glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
			glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
			glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
			glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)

			glTexImage2D(
				GL_TEXTURE_2D, 0, GL_RGBA, int(w), int(h), 0, GL_RGBA, GL_UNSIGNED_BYTE, None)

			# Step 3: Create framebuffer for rendering
			self.framebuffer = glGenFramebuffers(1)
			glBindFramebuffer(GL_FRAMEBUFFER, self.framebuffer)
			glFramebufferTexture2D(
				GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, gl_texture_id, 0)

			# Check framebuffer completeness
			if glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE:
				print("Framebuffer not complete!")

			glBindFramebuffer(GL_FRAMEBUFFER, 0)
			glBindTexture(GL_TEXTURE_2D, 0)

			props = sdl3.SDL_CreateProperties()
			# Set properties to wrap the OpenGL texture
			sdl3.SDL_SetNumberProperty(props, sdl3.SDL_PROP_TEXTURE_CREATE_OPENGL_TEXTURE_NUMBER, gl_texture_id)
			sdl3.SDL_SetNumberProperty(props, sdl3.SDL_PROP_TEXTURE_CREATE_WIDTH_NUMBER, int(w))
			sdl3.SDL_SetNumberProperty(props, sdl3.SDL_PROP_TEXTURE_CREATE_HEIGHT_NUMBER, int(h))
			#sdl3.SDL_SetNumberProperty(props, sdl3.SDL_PROP_TEXTURE_CREATE_FORMAT_NUMBER, sdl3.SDL_PIXELFORMAT_RGBA8888)
			sdl3.SDL_SetNumberProperty(props, sdl3.SDL_PROP_TEXTURE_CREATE_ACCESS_NUMBER, sdl3.SDL_TEXTUREACCESS_TARGET)

			# Create SDL texture from the OpenGL texture
			self.render_texture = sdl3.SDL_CreateTextureWithProperties(self.renderer, props)

		if self.projectm.render_frame_fbo_available:
			sdl3.SDL_FlushRenderer(self.renderer)
			glBindFramebuffer(GL_FRAMEBUFFER, self.framebuffer)
			self.projectm.render_frame(self.framebuffer)
		else:
			current_target = sdl3.SDL_GetRenderTarget(self.renderer)
			sdl3.SDL_SetRenderTarget(self.renderer, None)
			sdl3.SDL_FlushRenderer(self.renderer)
			glBindFramebuffer(GL_FRAMEBUFFER, 0)
			self.projectm.render_frame()
			glBindTexture(GL_TEXTURE_2D, self.gl_texture_id)
			glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, int(w), int(h))
			glBindTexture(GL_TEXTURE_2D, 0)
			glClearColor(0.0, 0.0, 0.0, 0.0)
			glClear(GL_COLOR_BUFFER_BIT)
			sdl3.SDL_SetRenderTarget(self.renderer, current_target)

		# Cut Out: run the key shader over the fresh frame. It samples the
		# projectM texture (read-only — projectM feeds it back into subsequent
		# frames) and writes a copy with alpha keyed from brightness into
		# key_texture_id, which is blitted below instead.
		keyed = False
		if not discard and self.tauon.prefs.milk_cut_out and not self.key_failed:
			keyed = self._run_key_pass(w, h)
		if not discard:
			self._last_keyed = keyed  # remembered so a paused re-blit picks the right texture

		glBindFramebuffer(GL_FRAMEBUFFER, saved_fbo)
		glFlush()
		glFinish()
		if not discard:
			if keyed:
				# The shader writes straight (unmultiplied) colour with a
				# luminance alpha; premultiplied "over" keeps the visualiser's
				# own brightness while black fades to the album art beneath.
				sdl3.SDL_SetTextureBlendMode(self.key_render_texture, sdl3.SDL_BLENDMODE_BLEND_PREMULTIPLIED)
				sdl3.SDL_RenderTexture(self.renderer, self.key_render_texture, None, srect)
			else:
				if self.tauon.prefs.milk_cut_out:
					# Shader unavailable: approximate the key in fixed function.
					sdl3.SDL_SetTextureBlendMode(self.render_texture, self._ensure_cut_out_blend())
				else:
					sdl3.SDL_SetTextureBlendMode(self.render_texture, sdl3.SDL_BLENDMODE_NONE)
				sdl3.SDL_RenderTexture(self.renderer, self.render_texture, None, srect)
		self.fps.tick()

	def _ensure_cut_out_blend(self):
		"""Lazily compose the fixed-function Cut Out blend (shader fallback):
		out = src * src + dst * (1 - src) — the source colour acts as its own
		per-channel matte."""
		if self.cut_out_blend_mode is None:
			self.cut_out_blend_mode = sdl3.SDL_ComposeCustomBlendMode(
				sdl3.SDL_BLENDFACTOR_SRC_COLOR,
				sdl3.SDL_BLENDFACTOR_ONE_MINUS_SRC_COLOR,
				sdl3.SDL_BLENDOPERATION_ADD,
				sdl3.SDL_BLENDFACTOR_ZERO,
				sdl3.SDL_BLENDFACTOR_ONE,
				sdl3.SDL_BLENDOPERATION_ADD)
		return self.cut_out_blend_mode

	def _blit_still(self, srect) -> None:
		"""Re-blit the last rendered visualiser frame (used while paused) without
		asking projectM for a new one. Mirrors the blend selection of the two
		render paths' blits so a paused frame looks identical to a live one."""
		if self.projectm.own_gl_context:
			# Readback path always blits render_texture (no shader key), flipped.
			if self.tauon.prefs.milk_cut_out:
				sdl3.SDL_SetTextureBlendMode(self.render_texture, self._ensure_cut_out_blend())
			else:
				sdl3.SDL_SetTextureBlendMode(self.render_texture, sdl3.SDL_BLENDMODE_NONE)
			sdl3.SDL_RenderTextureRotated(
				self.renderer, self.render_texture, None, srect, 0.0, None, sdl3.SDL_FLIP_VERTICAL)
			return
		if self._last_keyed and self.key_render_texture is not None:
			sdl3.SDL_SetTextureBlendMode(self.key_render_texture, sdl3.SDL_BLENDMODE_BLEND_PREMULTIPLIED)
			sdl3.SDL_RenderTexture(self.renderer, self.key_render_texture, None, srect)
		else:
			if self.tauon.prefs.milk_cut_out:
				sdl3.SDL_SetTextureBlendMode(self.render_texture, self._ensure_cut_out_blend())
			else:
				sdl3.SDL_SetTextureBlendMode(self.render_texture, sdl3.SDL_BLENDMODE_NONE)
			sdl3.SDL_RenderTexture(self.renderer, self.render_texture, None, srect)

	def render_readback(self, w, h, srect, discard: bool = False) -> None:
		"""Dedicated-context path (macOS): projectM renders into an FBO in its
		own core profile GL context, and the frame is read back to CPU and
		uploaded to a streaming SDL texture, since GL textures can't be shared
		between a core context and the renderer's 2.1 compatibility context."""
		pm = self.projectm
		sdl3.SDL_FlushRenderer(self.renderer)

		if (w, h) != self.loaded_size:
			self.loaded_size = (w, h)

			if self.render_texture:
				sdl3.SDL_DestroyTexture(self.render_texture)
			self.render_texture = sdl3.SDL_CreateTexture(
				self.renderer, sdl3.SDL_PIXELFORMAT_RGBA32,
				sdl3.SDL_TEXTUREACCESS_STREAMING, int(w), int(h))
			self.readback_buffer = (ctypes.c_ubyte * (int(w) * int(h) * 4))()

			pm.make_own_context_current()
			if self.gl_texture_id:
				glDeleteTextures(1, [self.gl_texture_id])
				glDeleteFramebuffers(1, [self.framebuffer])

			self.gl_texture_id = glGenTextures(1)
			glBindTexture(GL_TEXTURE_2D, self.gl_texture_id)
			glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
			glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
			glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
			glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
			glTexImage2D(
				GL_TEXTURE_2D, 0, GL_RGBA, int(w), int(h), 0, GL_RGBA, GL_UNSIGNED_BYTE, None)

			self.framebuffer = glGenFramebuffers(1)
			glBindFramebuffer(GL_FRAMEBUFFER, self.framebuffer)
			glFramebufferTexture2D(
				GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, self.gl_texture_id, 0)
			if glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE:
				logging.error("projectM framebuffer not complete")
			glBindFramebuffer(GL_FRAMEBUFFER, 0)
			glBindTexture(GL_TEXTURE_2D, 0)
		else:
			pm.make_own_context_current()

		try:
			pm.render_frame(self.framebuffer)

			glBindFramebuffer(GL_FRAMEBUFFER, self.framebuffer)
			glPixelStorei(GL_PACK_ALIGNMENT, 1)
			glReadPixels(0, 0, int(w), int(h), GL_RGBA, GL_UNSIGNED_BYTE, self.readback_buffer)
			glBindFramebuffer(GL_FRAMEBUFFER, 0)
			glFlush()
		finally:
			pm.restore_renderer_context()

		if not discard:
			sdl3.SDL_UpdateTexture(
				self.render_texture, None,
				ctypes.cast(self.readback_buffer, ctypes.c_void_p), int(w) * 4)
			if self.tauon.prefs.milk_cut_out:
				sdl3.SDL_SetTextureBlendMode(self.render_texture, self._ensure_cut_out_blend())
			else:
				sdl3.SDL_SetTextureBlendMode(self.render_texture, sdl3.SDL_BLENDMODE_NONE)
			# GL frames are bottom-up relative to SDL
			sdl3.SDL_RenderTextureRotated(
				self.renderer, self.render_texture, None, srect, 0.0, None, sdl3.SDL_FLIP_VERTICAL)
		self.fps.tick()

	# ------------------------------------------------- Cut Out key pass

	# Fullscreen-triangle vertex stage (no vertex buffer needed) + a fragment
	# stage that keys out blacks and greys only: neutral pixels fade by
	# brightness, but any hint of chroma (max - min channel) makes the pixel
	# opaque even at low luminance, so dim coloured content still covers the
	# art. Narrow smoothstep ramps keep the key edges from shimmering.
	KEY_VERTEX_SRC = """
	#version 330 core
	out vec2 uv;
	void main() {
		vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
		uv = p;
		gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
	}
	"""

	KEY_FRAGMENT_SRC = """
	#version 330 core
	in vec2 uv;
	out vec4 frag;
	uniform sampler2D tex;
	void main() {
		vec3 c = texture(tex, uv).rgb;
		float hi = max(c.r, max(c.g, c.b));
		float chroma = hi - min(c.r, min(c.g, c.b));
		float a = max(smoothstep(0.01, 0.20, hi), smoothstep(0.02, 0.06, chroma));
		frag = vec4(c, a);
	}
	"""

	def _init_key_program(self) -> bool:
		"""Compile the key shader once. On any failure mark key_failed so render
		falls back to the fixed-function blend approximation."""
		if self.key_program is not None:
			return True
		try:
			program = glCreateProgram()
			for source, kind in ((self.KEY_VERTEX_SRC, GL_VERTEX_SHADER), (self.KEY_FRAGMENT_SRC, GL_FRAGMENT_SHADER)):
				shader = glCreateShader(kind)
				glShaderSource(shader, source)
				glCompileShader(shader)
				if not glGetShaderiv(shader, GL_COMPILE_STATUS):
					raise RuntimeError(f"Shader compile failed: {glGetShaderInfoLog(shader)}")
				glAttachShader(program, shader)
				glDeleteShader(shader)
			glLinkProgram(program)
			if not glGetProgramiv(program, GL_LINK_STATUS):
				raise RuntimeError(f"Shader link failed: {glGetProgramInfoLog(program)}")
			# Core profiles require a bound VAO to draw, even with no attributes
			self.key_vao = glGenVertexArrays(1)
			glUseProgram(program)
			glUniform1i(glGetUniformLocation(program, "tex"), 0)
			glUseProgram(0)
			self.key_program = program
			return True
		except Exception:
			logging.exception("Failed to build Milkdrop key shader, using blend-mode fallback")
			self.key_failed = True
			return False

	def _ensure_key_target(self, w: int | float, h: int | float) -> bool:
		"""Create the keyed copy's texture + framebuffer + SDL wrapper at the
		current size (torn down with the main target on resize)."""
		if self.key_render_texture:
			return True
		try:
			self.key_texture_id = glGenTextures(1)
			glBindTexture(GL_TEXTURE_2D, self.key_texture_id)
			glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
			glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
			glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
			glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
			glTexImage2D(
				GL_TEXTURE_2D, 0, GL_RGBA, int(w), int(h), 0, GL_RGBA, GL_UNSIGNED_BYTE, None)
			self.key_framebuffer = glGenFramebuffers(1)
			glBindFramebuffer(GL_FRAMEBUFFER, self.key_framebuffer)
			glFramebufferTexture2D(
				GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, self.key_texture_id, 0)
			complete = glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE
			glBindFramebuffer(GL_FRAMEBUFFER, 0)
			glBindTexture(GL_TEXTURE_2D, 0)
			if not complete:
				raise RuntimeError("Key framebuffer not complete")

			props = sdl3.SDL_CreateProperties()
			sdl3.SDL_SetNumberProperty(props, sdl3.SDL_PROP_TEXTURE_CREATE_OPENGL_TEXTURE_NUMBER, self.key_texture_id)
			sdl3.SDL_SetNumberProperty(props, sdl3.SDL_PROP_TEXTURE_CREATE_WIDTH_NUMBER, int(w))
			sdl3.SDL_SetNumberProperty(props, sdl3.SDL_PROP_TEXTURE_CREATE_HEIGHT_NUMBER, int(h))
			sdl3.SDL_SetNumberProperty(props, sdl3.SDL_PROP_TEXTURE_CREATE_ACCESS_NUMBER, sdl3.SDL_TEXTUREACCESS_TARGET)
			self.key_render_texture = sdl3.SDL_CreateTextureWithProperties(self.renderer, props)
			if not self.key_render_texture:
				raise RuntimeError(f"SDL_CreateTextureWithProperties failed: {sdl3.SDL_GetError()}")
			return True
		except Exception:
			logging.exception("Failed to create Milkdrop key target, using blend-mode fallback")
			self.key_failed = True
			return False

	def _run_key_pass(self, w: int | float, h: int | float) -> bool:
		"""Render the keyed copy. Caller restores the framebuffer binding; all
		other touched GL state is saved and restored here."""
		if not self._init_key_program() or not self._ensure_key_target(w, h):
			return False
		saved_viewport = glGetIntegerv(GL_VIEWPORT)
		saved_program = glGetIntegerv(GL_CURRENT_PROGRAM)
		saved_vao = glGetIntegerv(GL_VERTEX_ARRAY_BINDING)
		saved_active = glGetIntegerv(GL_ACTIVE_TEXTURE)
		blend_was_on = glIsEnabled(GL_BLEND)
		try:
			glBindFramebuffer(GL_FRAMEBUFFER, self.key_framebuffer)
			glViewport(0, 0, int(w), int(h))
			glDisable(GL_BLEND)
			glUseProgram(self.key_program)
			glBindVertexArray(self.key_vao)
			glActiveTexture(GL_TEXTURE0)
			glBindTexture(GL_TEXTURE_2D, self.gl_texture_id)
			glDrawArrays(GL_TRIANGLES, 0, 3)
			glBindTexture(GL_TEXTURE_2D, 0)
			return True
		except Exception:
			logging.exception("Milkdrop key pass failed, using blend-mode fallback")
			self.key_failed = True
			return False
		finally:
			glBindVertexArray(int(saved_vao))
			glUseProgram(int(saved_program))
			glActiveTexture(int(saved_active))
			if blend_was_on:
				glEnable(GL_BLEND)
			glViewport(
				int(saved_viewport[0]),
				int(saved_viewport[1]),
				int(saved_viewport[2]),
				int(saved_viewport[3]),
			)


class MilkPresetChooser:
	"""Full-screen Milkdrop preset picker (the MilkDrop menu's "Choose Preset").

	Lists every scanned preset as compact labels in top-to-bottom columns over
	a translucent backdrop (same look as the search overlay). Clicking a label
	loads that preset; clicking the backdrop, Escape or right-click closes.
	Favorited presets get a gold star in the label gutter. The mouse wheel
	scrolls whole columns when there are more than fit the window.

	Input never leaks to the UI underneath: handle_input runs early in the
	frame (dream-room style), captures the pointer state for the overlay and
	then mutes it, so every other component sees no clicks and an off-screen
	cursor. It can run more than once per frame (event + motion passes), so it
	only latches state; render() consumes the latched click/wheel.
	"""

	def __init__(self, tauon: Tauon) -> None:
		self.tauon: Tauon = tauon
		self.gui:  GuiVar = tauon.gui
		self.inp:   Input = tauon.inp
		self.ddt:   TDraw = tauon.ddt
		self.active: bool = False
		self.scroll_cols: int = 0
		self._presets: list[Path] = []  # alphabetical snapshot taken on activate
		self._mouse: tuple[float, float] = (-1.0, -1.0)
		self._click: bool = False
		self._wheel: float = 0.0

	def activate(self) -> None:
		pm = self.tauon.milky.projectm
		if not pm.presets:
			pm.rescan_presets()
		if not pm.presets:
			self.tauon.show_message(_("No Milkdrop presets found"))
			return
		self._presets = sorted(pm.presets, key=lambda p: p.stem.casefold())
		self.active = True
		self.scroll_cols = 0
		self._click = False
		self._wheel = 0.0
		self._mouse = (self.inp.mouse_position[0], self.inp.mouse_position[1])
		self.gui.update = 2

	def close(self) -> None:
		self.active = False
		self._click = False
		self._wheel = 0.0
		self.gui.update = 2

	def handle_input(self) -> None:
		if not self.active:
			return
		inp = self.inp
		if inp.key_esc_press:
			inp.key_esc_press = False
			self.close()
			return
		if inp.right_click:
			self.close()
		if inp.mouse_position[0] > -2000:
			self._mouse = (inp.mouse_position[0], inp.mouse_position[1])
		if inp.mouse_click:
			self._click = True
		self._wheel += inp.mouse_wheel
		inp.mouse_click = False
		inp.d_mouse_click = False
		inp.right_click = False
		inp.middle_click = False
		inp.mouse_wheel = 0
		inp.input_text = ""
		inp.mouse_position[0] = -3000.0
		inp.mouse_position[1] = -3000.0

	def render(self) -> None:
		if not self.active:
			return
		gui = self.gui
		ddt = self.ddt
		pm = self.tauon.milky.projectm
		presets = self._presets
		if not presets:
			self.close()
			return

		w = self.tauon.window_size[0]
		h = self.tauon.window_size[1]
		ddt.rect((0, 0, w, h), ColourRGBA(3, 3, 3, 235))
		ddt.text_background_colour = ColourRGBA(12, 12, 12, 255)

		pad = round(12 * gui.scale)
		row_h = round(13 * gui.scale)
		col_w = round(150 * gui.scale)
		star_w = ddt.get_text_w("★", 10) + round(4 * gui.scale)  # label gutter, keeps columns aligned
		rows = max(1, (h - pad * 2) // row_h)
		n_cols = -(-len(presets) // rows)  # ceil
		vis_cols = max(1, (w - pad) // col_w)

		if self._wheel:
			self.scroll_cols -= int(self._wheel)
			self._wheel = 0.0
		self.scroll_cols = max(0, min(self.scroll_cols, max(0, n_cols - vis_cols)))

		mx, my = self._mouse
		click = self._click
		self._click = False
		on_label = False

		favorites = self.tauon.prefs.milk_favorite_presets
		text_colour = ColourRGBA(200, 200, 200, 255)
		hover_colour = ColourRGBA(255, 255, 255, 255)
		gold = ColourRGBA(244, 209, 66, 255)

		for col in range(vis_cols):
			start = (self.scroll_cols + col) * rows
			if start >= len(presets):
				break
			x = pad + col * col_w
			for row, preset in enumerate(presets[start:start + rows]):
				y = pad + row * row_h
				rect = (x, y, col_w - round(6 * gui.scale), row_h)
				hover = rect[0] <= mx < rect[0] + rect[2] and rect[1] <= my < rect[1] + rect[3]
				# Hover highlight: just brighten the label text
				if preset == pm.loaded_preset:
					colour = ColourRGBA(255, 230, 120, 255) if hover else gold
				else:
					colour = hover_colour if hover else text_colour
				if str(preset) in favorites:
					ddt.text((x + 1, y - round(1 * gui.scale)), "★", gold, 10)
				ddt.text(
					(x + star_w, y - round(1 * gui.scale)), preset.stem, colour, 10,
					max_w=rect[2] - star_w - round(4 * gui.scale))
				if click and hover:
					pm.load_next = preset
					on_label = True
					self.close()
		if click and not on_label:
			self.close()


def draw_showcase_art_box(
	tauon: Tauon,
	track: TrackClass,
	x: int | float,
	y: int | float,
	box_w: int | float,
	box_h: int | float | None = None,
) -> None:
	gui = tauon.gui
	inp = tauon.inp
	ddt = tauon.ddt
	if box_h is None:
		box_h = box_w
	rect = (x, y, box_w, box_h)
	gui.main_art_box = rect

	ddt.rect(
		(
			x - round(2 * gui.scale),
			y - round(2 * gui.scale),
			box_w + round(4 * gui.scale),
			box_h + round(4 * gui.scale),
		),
		ColourRGBA(60, 60, 60, 135),
	)
	ddt.rect(rect, tauon.colours.playlist_panel_background)
	tauon.style_overlay.hole_punches.append(sdl3.SDL_FRect(round(x), round(y), round(box_w), round(box_h)))

	tauon.album_art_gen.display(track, (x, y), (box_w, box_h))
	show_vis = False

	if tauon.prefs.milk and tauon.pctl.playing_state in (PlayingState.PLAYING, PlayingState.URL_STREAM, PlayingState.PAUSED):
		if tauon.pctl.a_time < 1.3:
			if 1 < tauon.pctl.a_time < 1.3:
				tauon.milky.render(discard=True)
				tauon.milky.burn(track)
		else:
			tauon.milky.render()
			show_vis = True
		if tauon.pctl.playing_state != PlayingState.PAUSED:
			gui.delay_frame(0.007)

	tauon.fields.add(rect)
	if tauon.coll(rect) and tauon.is_level_zero(False):
		if inp.mouse_click and inp.key_focused == 0:
			if show_vis:
				tauon.milky.projectm.load_next = "random"
			else:
				tauon.album_art_gen.cycle_offset(track)
				if tauon.pctl.mpris:
					tauon.pctl.mpris.update(force=True)

		if inp.right_click:
			if tauon.prefs.milk:
				tauon.milky_menu.activate(in_reference=track)
			else:
				tauon.picture_menu.activate(in_reference=track)
			inp.right_click = False


class Showcase:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon:                       Tauon = tauon
		self.inp:                         Input = tauon.inp
		self.gui:                        GuiVar = tauon.gui
		self.ddt:                         TDraw = tauon.ddt
		self.coll                               = tauon.coll
		self.pctl:                    PlayerCtl = tauon.pctl
		self.prefs:                       Prefs = tauon.prefs
		self.colours:              ColoursClass = tauon.colours
		self.renderer                           = tauon.renderer
		self.lyrics_ren:              LyricsRen = tauon.lyrics_ren
		self.window_size:             list[int] = tauon.window_size
		self.guitar_chords:        GuitarChords = tauon.guitar_chords
		self.showcase_view_menu:           Menu = tauon.showcase_view_menu
		self.smooth_scroll:        SmoothScroll = tauon.smooth_scroll
		self.timed_lyrics_edit: TimedLyricsEdit = TimedLyricsEdit(tauon=tauon)
		#self.lastfm_artist = None
		self.artist_mode: bool = False

	def render(self) -> None:
		if self.gui.timed_lyrics_edit_view:
			self.timed_lyrics_edit.render()
			return

		self.gui.timed_lyrics_editing_now = False
		self.timed_lyrics_edit.continuous = False
		box = int(self.window_size[1] * 0.4 + 120 * self.gui.scale)
		box = min(self.window_size[0] // 2, box)

		wide_art = self.prefs.showcase_wide_art and self.window_size[0] >= 500 * self.gui.scale
		hide_art = False
		if self.window_size[0] < 900 * self.gui.scale and not wide_art:
			hide_art = True

		x = int(self.window_size[0] * 0.15)
		y = int((self.window_size[1] / 2) - (box / 2)) - 10 * self.gui.scale

		if hide_art:
			box = 45 * self.gui.scale
		elif self.window_size[1] / self.window_size[0] > 0.7:
			x = int(self.window_size[0] * 0.07)

		bbg = rgb_add_hls(self.colours.lyrics_panel_background, 0, 0.05, 0)  # [255, 255, 255, 18]
		bfg = rgb_add_hls(self.colours.lyrics_panel_background, 0, 0.09, 0)  # [255, 255, 255, 30]
		bft = self.colours.grey(235)
		bbt = self.colours.grey(200)

		t1 = self.colours.grey(250)

		self.gui.vis_4_colour = None
		self.gui.draw_vis4_top = False
		light_mode = False
		if self.colours.lm:
			bbg = self.colours.vis_colour
			bfg = alpha_blend(ColourRGBA(255, 255, 255, 60), self.colours.vis_colour)
			bft = self.colours.grey(250)
			bbt = self.colours.grey(245)

		if test_lumi(self.colours.lyrics_panel_background) < 0.7:
			light_mode = True
			t1 = self.colours.grey(30)
			self.gui.vis_4_colour = ColourRGBA(40, 40, 40, 255)

		self.ddt.rect((0, self.gui.panelY, self.window_size[0], self.window_size[1] - self.gui.panelY), self.colours.lyrics_panel_background)

		# if not self.prefs.shuffle_lock:
		# 	if draw.button(_("Return"), 25 * self.gui.scale, self.window_size[1] - self.gui.panelBY - 40 * self.gui.scale,
		# 			text_highlight_colour=bft, text_colour=bbt, backgound_colour=bbg,
		# 			background_highlight_colour=bfg):
		# 		self.gui.switch_showcase_off = True
		# 		self.gui.update += 1
		# 		self.gui.update_layout = True

		# self.ddt.force_gray = True

		if self.pctl.playing_state == PlayingState.URL_STREAM and not self.tauon.radiobox.dummy_track.title:
			if not self.pctl.tag_meta:
				y = int(self.window_size[1] / 2) - 60 - self.gui.scale
				self.ddt.text((self.window_size[0] // 2, y, 2), self.pctl.url, self.colours.side_bar_line2, 317)
			else:
				w = self.window_size[0] - (x + box) - 30 * self.gui.scale
				x = int((self.window_size[0]) / 2)

				y = int(self.window_size[1] / 2) - 60 - self.gui.scale
				self.ddt.text((x, y, 2), self.pctl.tag_meta, self.colours.side_bar_line1, 216, w)
		else:
			if len(self.pctl.track_queue) < 1:
				self.ddt.alpha_bg = False
				return

			# if self.pctl.draw.button("Return", 20, self.gui.panelY + 5, bg=colours.grey(30)):
			# 	pass

			if self.gui.force_showcase_index >= 0:
				if self.pctl.draw.button(
					_("Playing"), 25 * self.gui.scale, self.gui.panelY + 20 * self.gui.scale, text_highlight_colour=bft,
					text_colour=bbt, background_colour=bbg, background_highlight_colour=bfg):
					self.gui.force_showcase_index = -1
					self.ddt.force_gray = False

			if self.gui.force_showcase_index >= 0:
				index = self.gui.force_showcase_index
				track = self.pctl.master_library[index]
			elif self.pctl.playing_state == PlayingState.URL_STREAM:
				track = self.tauon.radiobox.dummy_track
			else:
				index = self.pctl.track_queue[self.pctl.queue_step]
				track = self.pctl.master_library[index]

			if wide_art:
				available_h = self.window_size[1] - self.gui.panelY - self.gui.panelBY
				box_w = min(round(self.window_size[0] * 0.756), round(954 * self.gui.scale))
				box_w = max(round(280 * self.gui.scale), box_w)
				box_h = round(box_w * 0.45)
				max_box_h = max(round(160 * self.gui.scale), available_h - round(150 * self.gui.scale))
				if box_h > max_box_h:
					box_h = max_box_h
					box_w = round(box_h / 0.45)
				x = round((self.window_size[0] - box_w) / 2)
				y = round(self.gui.panelY + max(round(22 * self.gui.scale), (available_h - box_h - round(108 * self.gui.scale)) / 2))
				draw_showcase_art_box(self.tauon, track, x, y, box_w, box_h)

				meta_gap = round(34 * self.gui.scale)
				meta_block_h = round(78 * self.gui.scale)
				meta_area_top = y + box_h + meta_gap
				meta_area_bottom = self.window_size[1] - self.gui.panelBY - round(14 * self.gui.scale)
				meta_y = meta_area_top
				if meta_area_bottom > meta_area_top + meta_block_h:
					meta_y = round(meta_area_top + ((meta_area_bottom - meta_area_top - meta_block_h) / 2))
				meta_w = min(box_w, self.window_size[0] - round(60 * self.gui.scale))
				meta_x = round(self.window_size[0] / 2)
				title = clean_string(track.filename) if track.title == "" else track.title
				self.ddt.text((meta_x, meta_y, 2), title, t1, 219, meta_w)
				meta_y += round(32 * self.gui.scale)
				if track.artist:
					self.ddt.text((meta_x, meta_y, 2), track.artist, alpha_mod(t1, 210), 316, meta_w)
					meta_y += round(23 * self.gui.scale)
				if track.album:
					album_line = track.album
					if track.date:
						album_line = f"{album_line} - {track.date}"
					self.ddt.text((meta_x, meta_y, 2), album_line, alpha_mod(t1, 160), 14, meta_w)
				elif track.date:
					self.ddt.text((meta_x, meta_y, 2), track.date, alpha_mod(t1, 160), 14, meta_w)
				self.gui.showed_title = True
				self.ddt.alpha_bg = False
				self.ddt.force_gray = False
				return

			if not hide_art:
				draw_showcase_art_box(self.tauon, track, x, y, box)

			# Check for lyrics if auto setting
			self.tauon.test_auto_lyrics(track)

			self.gui.draw_vis4_top = False

			if self.gui.panelY < self.inp.mouse_position[1] < self.window_size[1] - self.gui.panelBY:
				if self.inp.mouse_wheel != 0:
					scroll_distance = self.smooth_scroll.scroll("showcase", 35*self.gui.scale)
					self.lyrics_ren.lyrics_position += scroll_distance
				if self.inp.right_click:
					# track = self.pctl.playing_object()
					if track is not None:
						self.showcase_view_menu.activate(track)

			gcx = x + box + int(self.window_size[0] * 0.15) + 10 * self.gui.scale
			gcx -= 100 * self.gui.scale
			# TODO (Flynn): work out the logic for full size static lyrics generating
			timed_ready = False
			if True and self.prefs.show_lyrics_showcase:
				timed_ready = self.tauon.timed_lyrics_ren.generate(track)

			if timed_ready and track.lyrics:
				# if not self.prefs.guitar_chords or self.guitar_chords.test_ready_status(track) != 1:
				# 	line = _("Prefer synced")
				# 	if self.prefs.prefer_synced_lyrics:
				# 		line = _("Prefer static")
				# 	if self.pctl.draw.button(line, 25 * self.gui.scale, self.window_size[1] - self.gui.panelBY - 70 * self.gui.scale,
				# 			text_highlight_colour=bft, text_colour=bbt, background_colour=bbg,
				# 			background_highlight_colour=bfg):
				# 		self.prefs.prefer_synced_lyrics ^= True

				timed_ready = self.prefs.prefer_synced_lyrics

			if self.prefs.guitar_chords and track.title and self.prefs.show_lyrics_showcase and self.guitar_chords.render(track, gcx, y):
				if not self.guitar_chords.auto_scroll:
					if self.pctl.draw.button(
						_("Auto-Scroll"), 25 * self.gui.scale, self.window_size[1] - self.gui.panelBY - 70 * self.gui.scale,
						text_highlight_colour=bft, text_colour=bbt, background_colour=bbg,
						background_highlight_colour=bfg):
						self.guitar_chords.auto_scroll = True
			elif True and self.prefs.show_lyrics_showcase and timed_ready:
				w = self.window_size[0] - (x + box) - round(30 * self.gui.scale)
				self.tauon.timed_lyrics_ren.render(track.index, gcx, y, w=w)
			elif track.lyrics == "" or not self.prefs.show_lyrics_showcase:
				w = self.window_size[0] - (x + box) - round(30 * self.gui.scale)
				x = int(x + box + (self.window_size[0] - x - box) / 2)

				if hide_art:
					x = self.window_size[0] // 2

				# x = int((self.window_size[0]) / 2)
				y = int(self.window_size[1] / 2) - round(60 * self.gui.scale)

				if track.artist == "" and track.title == "":
					self.ddt.text((x, y, 2), clean_string(track.filename), t1, 216, w)
				else:
					self.ddt.text((x, y, 2), track.artist, t1, 20, w)
					y += round(48 * self.gui.scale)

					if self.window_size[0] < 700 * self.gui.scale:
						if len(track.title) < 30:
							self.ddt.text((x, y, 2), track.title, t1, 220, w)
						elif len(track.title) < 40:
							self.ddt.text((x, y, 2), track.title, t1, 217, w)
						else:
							self.ddt.text((x, y, 2), track.title, t1, 213, w)

					elif len(track.title) < 35:
						self.ddt.text((x, y, 2), track.title, t1, 220, w)
					elif len(track.title) < 50:
						self.ddt.text((x, y, 2), track.title, t1, 219, w)
					else:
						self.ddt.text((x, y, 2), track.title, t1, 216, w)

				self.gui.spec4_rec.x = x - (self.gui.spec4_rec.w // 2)
				self.gui.spec4_rec.y = y + round(50 * self.gui.scale)

				if self.prefs.showcase_vis and self.window_size[1] > 369 and not self.tauon.search_over.active \
				and self.pctl.playing_state != PlayingState.URL_STREAM:
					if self.gui.message_box or not self.tauon.is_level_zero(include_menus=True):
						self.render_vis()
					else:
						self.gui.draw_vis4_top = True
			else:
				x += box + int(self.window_size[0] * 0.15) + 10 * self.gui.scale
				x -= 100 * self.gui.scale
				w = self.window_size[0] - x - 30 * self.gui.scale

				if self.inp.key_up_press and not (self.inp.key_ctrl_down or self.inp.key_shift_down or self.inp.key_shiftr_down):
					self.lyrics_ren.lyrics_position += 35 * self.gui.scale
				if self.inp.key_down_press and not (self.inp.key_ctrl_down or self.inp.key_shift_down or self.inp.key_shiftr_down):
					self.lyrics_ren.lyrics_position -= 35 * self.gui.scale

				self.lyrics_ren.test_update(track)
				tw, th = self.ddt.get_text_wh(self.lyrics_ren.text + "\n", 17, w, True)

				self.lyrics_ren.lyrics_position = max(self.lyrics_ren.lyrics_position, th * -1 + 100 * self.gui.scale)
				self.lyrics_ren.lyrics_position = min(self.lyrics_ren.lyrics_position, 70 * self.gui.scale)

				self.lyrics_ren.render(
					x,
					y + self.lyrics_ren.lyrics_position,
					w,
					int(self.window_size[1] - 100 * self.gui.scale),
					0)

		self.ddt.alpha_bg = False
		self.ddt.force_gray = False

	def render_vis(self, top: bool = False) -> None:
		sdl3.SDL_SetRenderTarget(self.renderer, self.gui.spec4_tex)
		sdl3.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 0)
		sdl3.SDL_RenderClear(self.renderer)

		bx = 0
		by = 50 * self.gui.scale

		if self.gui.vis_4_colour is not None:
			sdl3.SDL_SetRenderDrawColor(
				self.renderer, self.gui.vis_4_colour.r, self.gui.vis_4_colour.g, self.gui.vis_4_colour.b, self.gui.vis_4_colour.a)

		if (self.pctl.playing_time < 0.5 and (self.pctl.playing_state in (PlayingState.PLAYING, PlayingState.URL_STREAM))) or (
				self.pctl.playing_state == PlayingState.STOPPED and self.gui.spec4_array.count(0) != len(self.gui.spec4_array)):
			self.gui.update = 2
			self.gui.level_update = True

			for i in range(len(self.gui.spec4_array)):
				self.gui.spec4_array[i] -= 0.1
				self.gui.spec4_array[i] = max(self.gui.spec4_array[i], 0)

		if not top and (self.pctl.playing_state in (PlayingState.PLAYING, PlayingState.URL_STREAM)):
			self.gui.update = 2

		slide = 0.7
		for i, bar in enumerate(self.gui.spec4_array):

			# We won't draw higher bars that may not move
			if i > 40:
				break

			# Scale input amplitude to pixel distance (Applying a slight exponentional)
			dis = (2 + math.pow(bar / (2 + slide), 1.5))
			slide -= 0.03  # Set a slight bias for higher bars

			# Define colour for bar
			if self.gui.vis_4_colour is None:
				self.tauon.set_colour(
					hsl_to_rgb(
						0.7 + min(0.15, (bar / 150)) + self.pctl.total_playtime / 300, min(0.9, 0.7 + (dis / 300)),
						min(0.9, 0.7 + (dis / 600))))

			# Define bar size and draw
			self.gui.bar4.x = int(bx)
			self.gui.bar4.y = round(by - dis * self.gui.scale)
			self.gui.bar4.w = round(2 * self.gui.scale)
			self.gui.bar4.h = round(dis * 2 * self.gui.scale)

			sdl3.SDL_RenderFillRect(self.renderer, self.gui.bar4)

			# Set distance between bars
			bx += 8 * self.gui.scale

		if top:
			sdl3.SDL_SetRenderTarget(self.renderer, None)
		else:
			sdl3.SDL_SetRenderTarget(self.renderer, self.gui.main_texture)

		# sdl3.SDL_SetRenderDrawBlendMode(self.renderer, sdl3.SDL_BLENDMODE_BLEND)
		sdl3.SDL_RenderTexture(self.renderer, self.gui.spec4_tex, None, self.gui.spec4_rec)

class ColourPulse2:
	"""Animates colour between two colours"""

	def __init__(self, tauon: Tauon) -> None:
		self.gui = tauon.gui
		self.timer = Timer()
		self.in_timer = Timer()
		self.out_timer = Timer()
		self.out_timer.start = 0
		self.active: bool = False

	def get(self, hit: bool, on: bool, off: bool, low_hls: ColourRGBA, high_hls: ColourRGBA) -> ColourRGBA:
		if on:
			return high_hls
			# rgb = colorsys.hls_to_rgb(high_hls[0], high_hls[1], high_hls[2])
			# return [int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255), 255]
		if off:
			return low_hls
			# rgb = colorsys.hls_to_rgb(low_hls[0], low_hls[1], low_hls[2])
			# return [int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255), 70]

		ani_time = 0.15

		if hit is True and self.active is False:
			self.active = True
			self.in_timer.set()

			out_time = self.out_timer.get()
			if out_time < ani_time:
				self.in_timer.force_set(ani_time - out_time)

		elif hit is False and self.active is True:
			self.active = False
			self.out_timer.set()

			in_time = self.in_timer.get()
			if in_time < ani_time:
				self.out_timer.force_set(ani_time - in_time)

		pro = 0.5
		if self.active:
			time = self.in_timer.get()
			if time <= 0:
				pro = 0
			elif time >= ani_time:
				pro = 1
			else:
				pro = time / ani_time
				self.gui.update = 2
		else:
			time = self.out_timer.get()
			if time <= 0:
				pro = 1
			elif time >= ani_time:
				pro = 0
			else:
				pro = 1 - (time / ani_time)
				self.gui.update = 2

		return colour_slide(low_hls, high_hls, pro, 1)

def _draw_custom_layout_icon(tauon: Tauon, x: float, y: float, w: float, h: float, colour: ColourRGBA) -> None:
	"""Draw the Custom Layout glyph for the View Switcher (shared with the corner
	edit button)."""
	draw_layout_glyph(tauon.ddt, tauon.gui.scale, x, y, w, h, colour)


class DrawnIcon:
	"""A programmatically-drawn icon (no SVG asset) usable with ViewBox.button.
	Exposes scale-aware .w/.h and a render(x, y, colour) like an image asset."""

	def __init__(self, tauon: Tauon, base_w: int, base_h: int, draw) -> None:
		self.tauon = tauon
		self.base_w = base_w
		self.base_h = base_h
		self._draw = draw

	@property
	def w(self) -> int:
		return round(self.base_w * self.tauon.gui.scale)

	@property
	def h(self) -> int:
		return round(self.base_h * self.tauon.gui.scale)

	def render(self, x: float, y: float, colour: ColourRGBA, renderer=None) -> None:
		self._draw(self.tauon, x, y, self.w, self.h, colour)


class ViewBox:

	def __init__(self, tauon: Tauon, reload: bool = False) -> None:
		self.tauon   = tauon
		self.gui     = tauon.gui
		self.ddt     = tauon.ddt
		self.coll    = tauon.coll
		self.prefs   = tauon.prefs
		self.x_menu  = tauon.x_menu
		self.fields  = tauon.fields
		self.colours = tauon.colours
		self.x: int = 0
		self.y = tauon.gui.panelY
		self.w = 52 * tauon.gui.scale
		self.h = 260 * tauon.gui.scale  # sized for the option list (incl. Custom Layout)
		self.active: bool = False

		self.border = 3 * tauon.gui.scale

		self.tracks_img   = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "tracks.png", True)
		self.side_img     = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "tracks+side.png", True)
		self.gallery1_img = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "gallery1.png", True)
		self.gallery2_img = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "gallery2.png", True)
		self.combo_img    = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "combo.png", True)
		self.lyrics_img   = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "lyrics.png", True)
		#self.editor_img   = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "lyrics-editor.png", True)
		self.gallery2_img = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "gallery2.png", True)
		self.radio_img    = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "radio.png", True)
		self.custom_img   = DrawnIcon(tauon, 30, 19, _draw_custom_layout_icon)
		# self.artist_img = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "artist.png", True)

		# _ .15 0
		self.tracks_colour     = ColourPulse2(tauon=tauon)  # (0.5) # .5 .6 .75
		self.side_colour       = ColourPulse2(tauon=tauon)  # (0.55) # .55 .6 .75
		self.gallery1_colour   = ColourPulse2(tauon=tauon)  # (0.6) # .6 .6 .75
		self.radio_colour      = ColourPulse2(tauon=tauon)  # (0.6) # .6 .6 .75
		# self.combo_colour    = ColourPulse(0.75)
		self.lyrics_colour     = ColourPulse2(tauon=tauon)  # (0.7)
		self.editor_colour     = ColourPulse2(tauon=tauon)  # (0.7)
		# self.gallery2_colour = ColourPulse(0.65)
		self.custom_colour     = ColourPulse2(tauon=tauon)
		self.artist_colour     = ColourPulse2(tauon=tauon)  # (0.2)

		self.on_colour = ColourRGBA(255, 190, 50, 255)
		self.over_colour = ColourRGBA(255, 190, 50, 255)
		self.off_colour = self.colours.grey(40)

		self.spring_loading_timer: Timer = Timer()
		self.can_be_spring_clicked: bool = False
		self.springing: bool             = False

		if not reload:
			tauon.gui.combo_was_album = False

	def activate(self, x: int) -> None:
		self.x = x
		self.active = True
		self.clicked = False

		self.tracks_colour.out_timer.force_set(10)
		self.side_colour.out_timer.force_set(10)
		self.gallery1_colour.out_timer.force_set(10)
		self.radio_colour.out_timer.force_set(10)
		# self.combo_colour.out_timer.force_set(10)
		self.lyrics_colour.out_timer.force_set(10)
		# self.gallery2_colour.out_timer.force_set(10)
		self.artist_colour.out_timer.force_set(10)

		self.tracks_colour.active = False
		self.side_colour.active = False
		self.gallery1_colour.active = False
		self.radio_colour.active = False
		# self.combo_colour.active = False
		self.lyrics_colour.active = False
		# self.gallery2_colour.active = False
		self.artist_colour.active = False

		self.col_force_off = False

		# self.gui.level_2_click = False
		self.gui.update = 2

		self.spring_loading_timer.set()
		self.can_be_spring_clicked = True

	def button(
		self, x: float, y: float, asset: WhiteModImageAsset | LoadImageAsset, test, colour_get: ColourPulse2 | None = None, name: str = "Unknown", animate: bool = True, low: ColourRGBA = ColourRGBA(0,0,0,255), high: ColourRGBA = ColourRGBA(0,0,0,255)):
		"""PSA for anyone making a new button function: use fields.add(rect) to make the gui
		refresh when you pan the mouse over it
		"""
		on = test()
		# In custom mode the Custom Layout button is the active "view"; the other
		# layout icons shouldn't stay highlighted.
		if self.gui.custom_mode and test is not self.custom_layout:
			on = False
		rect = [
			x - 8 * self.gui.scale,
			y - 8 * self.gui.scale,
			asset.w + 16 * self.gui.scale,
			asset.h + 16 * self.gui.scale]
		self.fields.add(rect)

		colour = self.on_colour if on else self.off_colour

		fun = None
		col = False
		if self.coll(rect):
			self.tauon.tool_tip.test(x + asset.w + 10 * self.gui.scale, y - 15 * self.gui.scale, name)

			col = True
			if self.gui.level_2_click or (self.springing and not self.tauon.inp.mouse_down):
				fun = test
				self.x_menu.active = False
			if colour_get is None:
				colour = self.over_colour

		colour = colour_get.get(col, on, not on and not animate, low, high)

		# if "+" in name:
		# 	colour = cctest.get(col, on, [0, 0.2, 0.0], [0, 0.8, 0.8])

		# if not on and not animate:
		# 	colour = self.off_colour

		asset.render(x, y, colour)

		return fun

	def tracks(self, hit: bool = False) -> bool | None:
		if hit is False:
			return self.prefs.album_mode is False and \
				self.gui.combo_mode is False and \
				self.gui.rsp is False

		if not (self.prefs.album_mode is False and \
			self.gui.combo_mode is False and \
			self.gui.rsp is False):
			if self.x_menu.active:
				self.x_menu.close_next_frame = True

		self.tauon.view_tracks()
		return None

	def side(self, hit: bool = False) -> bool | None:
		if hit is False:
			return self.prefs.album_mode is False and \
				self.gui.combo_mode is False and \
				self.gui.rsp is True
		if not (self.prefs.album_mode is False and \
			self.gui.combo_mode is False and \
			self.gui.rsp is True):
			if self.x_menu.active:
				self.x_menu.close_next_frame = True

		self.tauon.view_standard_meta()
		return None

	def _set_side_panel_left(self, left: bool) -> None:
		# Flip the metadata side panel to the requested side, refreshing layout
		# only when the value actually changes.
		if self.prefs.side_panel_left != left:
			self.prefs.side_panel_left = left
			self.gui.update_layout = True
			self.gui.update += 1

	def side_normal(self, hit: bool = False) -> bool | None:
		# "Tracks + Art" with the metadata side panel on its default (right) side.
		if hit is False:
			return self.side(False) and not self.prefs.side_panel_left
		self._set_side_panel_left(False)
		self.side(True)
		return None

	def side_reversed(self, hit: bool = False) -> bool | None:
		# "Tracks + Art" with the metadata side panel mirrored to the left side.
		if hit is False:
			return self.side(False) and self.prefs.side_panel_left
		self._set_side_panel_left(True)
		self.side(True)
		return None

	def gallery1(self, hit: bool = False) -> bool | None:
		if hit is False:
			return self.prefs.album_mode is True  # and self.gui.show_playlist is True

		if self.prefs.album_mode and not self.gui.combo_mode:
			self.gui.hide_tracklist_in_gallery ^= True
			self.gui.rspw = self.gui.pref_gallery_w
			self.gui.update_layout = True
			# self.x_menu.active = False
			self.x_menu.close_next_frame = True
			# Menu.active = False
			return None

		if self.x_menu.active:
			self.x_menu.close_next_frame = True

		self.tauon.force_album_view()
		return None

	def radio(self, hit: bool = False) -> bool | None:
		if hit is False:
			return self.gui.radio_view

		if not self.gui.radio_view:
			self.tauon.enter_radio_view()
		else:
			self.tauon.exit_combo(restore=True)

		if self.x_menu.active:
			self.x_menu.close_next_frame = True
		return None

	def lyrics(self, hit: bool = False) -> bool | None:
		if hit is False:
			return self.gui.showcase_mode and not self.gui.timed_lyrics_edit_view

		if not self.gui.showcase_mode:
			if self.gui.radio_view:
				self.gui.was_radio = True
			self.tauon.enter_showcase_view()

		elif self.gui.was_radio:
			self.tauon.enter_radio_view()
		elif self.gui.timed_lyrics_edit_view:
			self.gui.timed_lyrics_edit_view = False
		else:
			self.tauon.exit_combo(restore=True)
		if self.x_menu.active:
			self.x_menu.close_next_frame = True
		return None

	def activate_synced_lyric_editor(self) -> None:
		self.editor(hit = True)

	def editor(self, hit: bool = False) -> bool | None:
		if hit is False:
			return self.gui.showcase_mode and self.gui.timed_lyrics_edit_view

		if not self.gui.showcase_mode:
			if self.gui.radio_view:
				self.gui.was_radio = True
			self.tauon.enter_showcase_view(timed_lyrics_edit=True)

		elif self.gui.was_radio:
			self.tauon.enter_radio_view()
		elif not self.gui.timed_lyrics_edit_view:
			self.gui.timed_lyrics_edit_view = True
		else:
			self.tauon.exit_combo(restore=True)
		if self.x_menu.active:
			self.x_menu.close_next_frame = True
		return None

	def col(self, hit: bool = False) -> bool | None:
		if hit is False:
			return self.gui.set_mode

		if not self.gui.set_mode and self.gui.combo_mode:
			self.tauon.exit_combo()

		if self.prefs.album_mode and self.gui.plw < 550 * self.gui.scale:
			self.tauon.toggle_album_mode()

		self.tauon.toggle_library_mode()
		return None

	def artist_info(self, hit: bool = False) -> bool | None:
		if hit is False:
			return self.gui.artist_info_panel

		self.gui.artist_info_panel ^= True
		self.gui.update_layout = True
		return None

	def custom_layout(self, hit: bool = False) -> bool | None:
		if hit is False:
			return self.gui.custom_mode  # active indicator
		# Toggle: clicking again exits custom mode, revealing the previous view
		# (custom mode is an overlay, so the underlying layout is unchanged).
		if self.gui.custom_mode:
			self.tauon.custom.exit_mode()
		else:
			self.tauon.custom.enter()
		if self.x_menu.active:
			self.x_menu.close_next_frame = True
		return None

	def _custom_cycle_slots(self) -> list[int]:
		"""Slot indexes worth cycling through: the non-blank custom layouts."""
		custom = self.tauon.custom
		if not custom._loaded:
			custom.load_slots()
		return [i for i, s in enumerate(custom.slots) if not custom._is_blank_tree(s)]

	def cycle(self, reverse: bool = False) -> None:
		"""Step to the next/previous layout: Tracks → Tracks + Art → Gallery →
		Showcase → each (non-blank) custom layout slot → back to Tracks."""
		custom = self.tauon.custom
		slots = self._custom_cycle_slots()

		if self.gui.custom_mode:
			try:
				idx = slots.index(custom.active_slot)
			except ValueError:
				idx = -1  # active slot is blank: step out of custom either way
			if not reverse:
				if idx != -1 and idx + 1 < len(slots):
					custom.enter(slots[idx + 1])
					return
				custom.exit_mode()
				self.tracks(True)
			else:
				if idx > 0:
					custom.enter(slots[idx - 1])
					return
				custom.exit_mode()
				# The underlying preset may already be showcase (custom mode is
				# an overlay); lyrics(True) would toggle it back off.
				if not self.lyrics():
					self.lyrics(True)
			return

		if not reverse:
			if self.tracks():
				self.side(True)
			elif self.side():
				self.gallery1(True)
			elif self.gallery1():
				self.lyrics(True)
			elif self.lyrics() and slots:
				custom.enter(slots[0])
			else:
				self.tracks(True)
		elif self.tracks():
			if slots:
				custom.enter(slots[-1])
			else:
				self.lyrics(True)
		elif self.lyrics():
			self.gallery1(True)
		elif self.gallery1():
			self.side(True)
		else:
			self.tracks(True)

	def render(self) -> None:
		gui     = self.gui
		ddt     = self.ddt
		colours = self.colours
		if self.prefs.shuffle_lock:
			self.active = False
			self.clicked = False
			return

		if not self.active:
			return

		# rect = [self.x, self.y, self.w, self.h]
		# if x_menu.clicked or inp.mouse_click:
		if self.clicked:
			gui.level_2_click = True
		self.clicked = False

		self.springing = self.can_be_spring_clicked and self.spring_loading_timer.get() > 0.3

		x = self.x - 40 * gui.scale

		vr = [x, gui.panelY, self.w, self.h]
		# vr = [x, gui.panelY, 52 * gui.scale, 220 * gui.scale]

		border_colour = colours.menu_tab  # colours.grey(30)
		if colours.lm:
			ddt.rect((vr[0], vr[1], vr[2] + round(4 * gui.scale), vr[3]), border_colour)
		else:
			ddt.rect(
				(vr[0] - round(4 * gui.scale), vr[1], vr[2] + round(8 * gui.scale),
				vr[3] + round(4 * gui.scale)), border_colour)
		ddt.rect(vr, colours.menu_background)

		x += 7 * gui.scale
		y = gui.panelY + 14 * gui.scale

		func = None

		# low = (0, .15, 0)
		# low = (0, .40, 0)
		# low = rgb_to_hls(*alpha_blend(colours.menu_icons, colours.menu_background)[:3])  # fix me
		low = alpha_blend(colours.menu_icons, colours.menu_background)

		# if colours.lm:
		#     low = (0, 0.5, 0)

		# ----
		#logging.info(hls_to_rgb(.55, .6, .75))
		high = ColourRGBA(76, 183, 229, 255)  # (.55, .6, .75)
		if colours.lm:
			# high = (.55, .75, .75)
			high = ColourRGBA(63, 63, 63, 255)

		test = self.button(x, y, self.side_img, self.side, self.side_colour, _("Tracks + Art"), low=low, high=high)
		if test is not None:
			func = test

		# ----

		y += 40 * gui.scale

		high = ColourRGBA(76, 137, 229, 255)  # (.6, .6, .75)
		if colours.lm:
			# high = (.6, .80, .85)
			high = ColourRGBA(63, 63, 63, 255)

		if gui.hide_tracklist_in_gallery:
			test = self.button(
				x - round(1 * gui.scale), y, self.gallery2_img, self.gallery1, self.gallery1_colour,
				_("Gallery"), low=low, high=high)
		else:
			test = self.button(
				x, y, self.gallery1_img, self.gallery1, self.gallery1_colour, _("Gallery"), low=low, high=high)
		if test is not None:
			func = test

		# ---

		y += 40 * gui.scale

		high = ColourRGBA(76, 229, 229, 255)
		if colours.lm:
			# high = (.5, .7, .65)
			high = ColourRGBA(63, 63, 63, 255)

		test = self.button(
			x + 3 * gui.scale, y, self.tracks_img, self.tracks, self.tracks_colour, _("Tracks only"),
			low=low, high=high)
		if test is not None:
			func = test

		# ---

		y += 45 * gui.scale

		high = ColourRGBA(107, 76, 229, 255)
		if colours.lm:
			# high = (.7, .75, .75)
			high = ColourRGBA(63, 63, 63, 255)

		test = self.button(
			x + 4 * gui.scale, y, self.lyrics_img, self.lyrics, self.lyrics_colour,
			_("Showcase + Lyrics"), low=low, high=high)
		if test is not None:
			func = test

		# --

		# y += 45 * gui.scale
		#
		# high = ColourRGBA(81, 231, 0, 255)
		# if colours.lm:
		# 	# high = (.7, .75, .75)
		# 	high = ColourRGBA(63, 63, 63, 255)
		#
		# test = self.button(
		# 	x + 4 * gui.scale, y, self.editor_img, self.editor, self.editor_colour,
		# 	_("Lyrics Editor"), low=low, high=high)
		# if test is not None:
		# 	func = test

		# --

		y += 40 * gui.scale

		high = ColourRGBA(92, 86, 255, 255)
		if colours.lm:
			# high = (.7, .75, .75)
			high = ColourRGBA(63, 63, 63, 255)

		test = self.button(
			x + 3 * gui.scale, y, self.radio_img, self.radio, self.radio_colour, _("Radio"), low=low, high=high)
		if test is not None:
			func = test

		# --

		# -- Custom Layout --

		y += 45 * gui.scale

		high = ColourRGBA(170, 225, 90, 255)  # lime accent for the Custom Layout option
		if colours.lm:
			high = ColourRGBA(63, 63, 63, 255)

		test = self.button(
			x + 4 * gui.scale, y, self.custom_img, self.custom_layout, self.custom_colour,
			_("Custom Layout"), low=low, high=high)
		if test is not None:
			func = test

		if func is not None:
			# Switching to any other layout exits custom mode (the custom button
			# itself toggles it).
			if func is not self.custom_layout and gui.custom_mode:
				self.tauon.custom.exit_mode()
			func(True)

		if gui.level_2_click and self.coll(vr):
			self.x_menu.clicked = False

		gui.level_2_click = False
		if not self.x_menu.active:
			self.active = False

		self.can_be_spring_clicked = self.can_be_spring_clicked and self.tauon.inp.mouse_down

class DLMon:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon: Tauon = tauon
		self.gui: GuiVar = tauon.gui
		self.windows: bool = tauon.windows
		self.pctl: PlayerCtl = tauon.pctl
		self.prefs: Prefs = tauon.prefs
		self.formats: Formats = tauon.formats
		self.music_directory: Path = tauon.music_directory
		self.ticker = Timer()
		self.ticker.force_set(8)

		self.watching: dict[str, int] = {}
		self.ready = set()
		self.done = set()
		self.unavailable_directories: set[str] = set()

	def scan(self) -> None:
		if len(self.watching) == 0:
			if self.ticker.get() < 10:
				return
		elif self.ticker.get() < 2:
			return

		self.ticker.set()

		for downloads in self.tauon.download_directories:
			try:
				items = os.listdir(downloads)
			except PermissionError:
				if downloads not in self.unavailable_directories:
					logging.warning(f"Skipping unreadable download directory: {downloads}")
					self.unavailable_directories.add(downloads)
				continue
			except OSError:
				logging.exception(f"Failed to scan download directory: {downloads}")
				self.unavailable_directories.add(downloads)
				continue
			else:
				self.unavailable_directories.discard(downloads)

			for item in items:
				path = os.path.join(downloads, item)

				if path in self.done:
					continue

				if path in self.ready and not os.path.exists(path):
					del self.ready[path]
					continue

				if path in self.watching and not os.path.exists(path):
					del self.watching[path]
					continue

				# stamp = os.stat(path)[stat.ST_MTIME]
				try:
					stamp = os.path.getmtime(path)
				except Exception:
					logging.exception(f"Failed to scan item at {path}")
					self.done.add(path)
					continue

				min_age = (time.time() - stamp) / 60
				ext = os.path.splitext(path)[1][1:].lower()

				if self.windows and "TauonMusicBox" in path:
					continue

				if min_age < 240 and os.path.isfile(path) and ext in self.formats.Archive:
					size = os.path.getsize(path)
					#logging.info("Check: " + path)
					if path in self.watching:
						# Check if size is stable, then scan for audio files
						#logging.info("watching...")
						if size == self.watching[path] and size != 0:
							#logging.info("scan")
							del self.watching[path]

							# Check if folder to extract to exists
							split = os.path.splitext(path)
							target_dir = split[0]
							if self.prefs.extract_to_music and self.music_directory is not None:
								target_dir = os.path.join(str(self.music_directory), os.path.basename(target_dir))

							if os.path.exists(target_dir):
								pass
								#logging.info("Target folder for archive already exists")

							elif archive_file_scan(path, self.formats.DA, self.tauon.launch_prefix) >= 0.4:
								self.ready.add(path)
								self.gui.update += 1
								#logging.info("Archive detected as music")
							else:
								pass
								#logging.info("Archive rejected as music")
							self.done.add(path)
						else:
							#logging.info("update.")
							self.watching[path] = size
					else:
						self.watching[path] = size
						#logging.info("add.")
				elif min_age < 60 \
				and os.path.isdir(path) \
				and path not in self.tauon.quick_import_done \
				and "encode-output" not in path:
					try:
						size = get_folder_size(path)
					except FileNotFoundError:
						logging.warning(f"Failed to find watched folder {path}, deleting from watchlist")
						if path in self.watching:
							del self.watching[path]
						continue
					except Exception:
						logging.exception("Unknown error getting folder size")
						continue
					if path in self.watching:
						# Check if size is stable, then scan for audio files
						if size == self.watching[path]:
							del self.watching[path]
							if folder_file_scan(path, self.formats.DA) > 0.5:

								# Check if folder not already imported
								imported = False
								for pl in self.pctl.multi_playlist:
									for i in pl.playlist_ids:
										if path.replace("\\", "/") == self.pctl.master_library[i].fullpath[:len(path)]:
											imported = True
										if imported:
											break
									if imported:
										break
								else:
									self.ready.add(path)
								self.gui.update += 1
							self.done.add(path)
						else:
							self.watching[path] = size
					else:
						self.watching[path] = size
				else:
					self.done.add(path)

		if len(self.ready) > 0:
			temp = set()
			#logging.info(self.tauon.quick_import_done)
			#logging.info(self.ready)
			for item in self.ready:
				if item not in self.tauon.quick_import_done:
					if os.path.exists(path):
						temp.add(item)
				# else:
				# 	logging.info("FILE IMPORTED")
			self.ready = temp

		if len(self.watching) > 0:
			self.tauon.gui.update += 1

class Fader:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon = tauon
		self.window_size = tauon.window_size

		self.total_timer = Timer()
		self.timer = Timer()
		self.ani_duration = 0.3
		self.state = 0  # 0 = Want off, 1 = Want fade on
		self.a = 0  # The fade progress (0-1)

	def render(self) -> None:
		if self.total_timer.get() > self.ani_duration:
			self.a = self.state
		elif self.state == 0:
			t = self.timer.hit()
			self.a -= t / self.ani_duration
			self.a = max(0, self.a)
		elif self.state == 1:
			t = self.timer.hit()
			self.a += t / self.ani_duration
			self.a = min(1, self.a)

		rect = [0, 0, self.window_size[0], self.window_size[1]]
		self.tauon.ddt.rect(rect, ColourRGBA(0, 0, 0, int(110 * self.a)))

		if self.a not in (0, 1):
			self.tauon.gui.update += 1

	def rise(self) -> None:
		self.state = 1
		self.timer.hit()
		self.total_timer.set()

	def fall(self) -> None:
		if self.state == 0:
			return
		self.state = 0
		self.timer.hit()
		self.total_timer.set()

class EdgePulse:

	def __init__(self, tauon: Tauon) -> None:
		self.gui     = tauon.gui
		self.ddt     = tauon.ddt
		self.colours = tauon.colours
		self.timer = Timer()
		self.timer.force_set(10)
		self.ani_duration = 0.5

	def render(self, x: int, y: int, w: int, h: int, r: int = 200, g: int = 120, b: int = 0) -> bool:
		r = self.colours.pulse_colour.r
		g = self.colours.pulse_colour.g
		b = self.colours.pulse_colour.b
		time = self.timer.get()
		if time < self.ani_duration:
			alpha = 255 - int(255 * (time / self.ani_duration))
			self.ddt.rect((x, y, w, h), ColourRGBA(r, g, b, alpha))
			self.gui.update = 2
			return True
		return False

	def pulse(self) -> None:
		self.timer.set()

class EdgePulse2:

	def __init__(self, tauon: Tauon) -> None:
		self.inp     = tauon.inp
		self.ddt     = tauon.ddt
		self.gui     = tauon.gui
		self.colours = tauon.colours
		self.timer = Timer()
		self.timer.force_set(10)
		self.ani_duration = 0.22

	def render(self, x: int, y: int, w: int, h: int, bottom: bool = False) -> bool | None:
		time = self.timer.get()
		if time < self.ani_duration:
			if bottom:
				if self.inp.mouse_wheel > 0:
					self.timer.force_set(10)
					return None
			elif self.inp.mouse_wheel < 0:
				self.timer.force_set(10)
				return None

			alpha = 30 - int(25 * (time / self.ani_duration))
			h_off = (h // 5) * (time / self.ani_duration) * 4

			if self.colours.lm:
				colour = ColourRGBA(0, 0, 0, alpha)
			else:
				# colour = ColourRGBA(255, 255, 255, alpha)
				colour = self.colours.pulse_colour

			if not bottom:
				self.ddt.rect((x, y, w, h - h_off), colour)
			else:
				self.ddt.rect((x, y - (h - h_off), w, h - h_off), colour)
			self.gui.update = 2
			return True
		return False

	def pulse(self) -> None:
		self.timer.set()

class Undo:

	def __init__(self, tauon: Tauon) -> None:
		self.gui: GuiVar = tauon.gui
		self.pctl: PlayerCtl = tauon.pctl
		self.star_store: StarStore = tauon.star_store
		self.show_message = tauon.show_message
		self.e: list[
			tuple[str, TrackClass, StarRecord | None, int, TrackClass, StarRecord | None, int] |
			tuple[str, list[int]] |
			tuple[str, int, list[tuple[int, int]]]
			] = []

	def undo(self) -> None:
		if not self.e:
			self.show_message(_("There are no more steps to undo."))
			return

		job = self.e.pop()

		if job[0] == "playlist":
			self.pctl.multi_playlist.append(job[1])
			self.pctl.switch_playlist(len(self.pctl.multi_playlist) - 1)
		elif job[0] == "tracks":
			uid = job[1]
			li = job[2]

			for i, playlist in enumerate(self.pctl.multi_playlist):
				if playlist.uuid_int == uid:
					pl = playlist.playlist_ids
					self.pctl.switch_playlist(i)
					break
			else:
				logging.info("No matching playlist ID to restore tracks to")
				return

			for i, ref in reversed(li):
				if i > len(pl):
					logging.error("restore track error - playlist not correct length")
					continue
				pl.insert(i, ref)

				if not self.pctl.playlist_view_position < i < self.pctl.playlist_view_position + self.gui.playlist_view_length:
					self.pctl.playlist_view_position = i
					logging.debug("Position changed by undo")
		elif job[0] == "ptt":
			j, fr, fr_s, fr_scr, to, to_s, to_scr = job
			self.star_store.insert(fr.index, fr_s)
			self.star_store.insert(to.index, to_s)
			to.lfm_scrobbles = to_scr
			fr.lfm_scrobbles = fr_scr

		self.gui.pl_update = 1

	def bk_playlist(self, pl_index: int) -> None:
		self.e.append(("playlist", self.pctl.multi_playlist[pl_index]))

	def bk_tracks(self, pl_index: int, indis: list[tuple[int, int]]) -> None:
		uid = self.pctl.multi_playlist[pl_index].uuid_int
		self.e.append(("tracks", uid, indis))

	def bk_playtime_transfer(self, fr: TrackClass, fr_s: StarRecord | None, fr_scr: int, to: TrackClass, to_s: StarRecord | None, to_scr: int) -> None:
		self.e.append(("ptt", fr, fr_s, fr_scr, to, to_s, to_scr))

class GetSDLInput:
	def __init__(self, tauon: Tauon) -> None:
		self.logical_size = tauon.logical_size
		self.window_size = tauon.window_size
		self.mouse_capture_want = False
		self.mouse_capture = False

	def mouse(self) -> tuple[int, int]:
		sdl3.SDL_PumpEvents()
		i_y = pointer(c_float(0))
		i_x = pointer(c_float(0))
		sdl3.SDL_GetMouseState(i_x, i_y)
		return (int(i_x.contents.value / self.logical_size[0] * self.window_size[0]),
			int(i_y.contents.value / self.logical_size[0] * self.window_size[0]))

	def test_capture_mouse(self) -> None:
		if not self.mouse_capture and self.mouse_capture_want:
			sdl3.SDL_CaptureMouse(True)
			self.mouse_capture = True
		elif self.mouse_capture and not self.mouse_capture_want:
			sdl3.SDL_CaptureMouse(False)
			self.mouse_capture = False


class XcursorImage(ctypes.Structure):
	_fields_ = [
			("version", c_uint32),
			("size", c_uint32),
			("width", c_uint32),
			("height", c_uint32),
			("xhot", c_uint32),
			("yhot", c_uint32),
			("delay", c_uint32),
			("pixels", c_void_p),
		]


SCROLL_PHYSICS_WHEEL_VELOCITY = 16.0
SCROLL_PHYSICS_PRECISE_WHEEL_PIXEL_MULTIPLIER = 0.11
SCROLL_PHYSICS_TRACKLIST_PRECISE_SCALE = 1.0
SCROLL_PHYSICS_GALLERY_PRECISE_PIXEL_BASE = 15
SCROLL_PHYSICS_TRACKPAD_GESTURE_WINDOW = 0.25
SCROLL_PHYSICS_REPEAT_WINDOW = 0.22
SCROLL_PHYSICS_REPEAT_ACCELERATION = 0.35
SCROLL_PHYSICS_REPEAT_CURVE = 0.08
SCROLL_PHYSICS_WHEEL_MAGNITUDE_ACCELERATION = 0.45
SCROLL_PHYSICS_WHEEL_MAX_VELOCITY_MULTIPLIER = 5.0
SCROLL_PHYSICS_PRECISE_SMOOTHING = 0.026
SCROLL_PHYSICS_PRECISE_MAX_TIMESTEP = 1.0 / 60.0
SCROLL_PHYSICS_PRECISE_RELEASE_GRACE = 0.03
SCROLL_PHYSICS_PRECISE_STOP_THRESHOLD = 5.0
SCROLL_PHYSICS_TOUCH_DRAG_MULTIPLIER = 1.0
SCROLL_PHYSICS_TOUCH_FLING_MULTIPLIER = 1.15
SCROLL_ANIMATION_MAX_FPS = 144.0
SCROLL_ANIMATION_FRAME_INTERVAL = 1.0 / SCROLL_ANIMATION_MAX_FPS
SCROLL_PHYSICS_FIXED_TIMESTEP = 1.0 / 240.0
SCROLL_PHYSICS_MAX_TIMESTEP = 0.05
SCROLL_PHYSICS_ACCELERATION_BOOST = 2
SCROLL_PHYSICS_DAMPING = 0.95
SCROLL_PHYSICS_FIXED_DAMPING = SCROLL_PHYSICS_DAMPING ** (SCROLL_PHYSICS_FIXED_TIMESTEP * 60)
SCROLL_PHYSICS_MAX_VELOCITY = 2100.0
SCROLL_PHYSICS_MIN_VELOCITY = 8.0


@dataclass
class ScrollMotionState:
	velocity: float = 0.0
	last_velocity: float = 0.0
	pending: float = 0.0
	accumulator: float = 0.0
	precise_buffer: float = 0.0
	last_precise_input: float = 0.0
	touching: bool = False
	from_touch: bool = False
	wheel_streak: int = 0
	last_wheel_direction: float = 0.0
	last_wheel_time: float = 0.0
	last_update: float = field(default_factory=time.monotonic)


class SmoothScroll:
	def __init__(self, tauon: Tauon) -> None:
		self.tauon = tauon
		self.inp = tauon.inp
		self.gui = tauon.gui
		self.scroll_bins:    dict[str:list[float]] = {}
		self.scroll_timeouts:      dict[str:Timer] = {}
		self.physics_states: dict[str, ScrollMotionState] = {}
		self.scroll_debug_modes: dict[str, str] = {}
		self.scroll_debug_last_logs: dict[str, float] = {}
		self.timeout = 0.5

	def _pixel_scale(self) -> float:
		return max(self.gui.scale, 0.1)

	def _scaled_max_velocity(self) -> float:
		return SCROLL_PHYSICS_MAX_VELOCITY * self._pixel_scale()

	def _scaled_min_velocity(self) -> float:
		return SCROLL_PHYSICS_MIN_VELOCITY * self._pixel_scale()

	def enabled(self) -> bool:
		prefs = self.tauon.prefs
		return prefs.smooth_scroll_enable or prefs.macos

	def speed(self) -> float:
		return max(self.tauon.prefs.smooth_scroll_speed, 0.05)

	def precise_scroll_active(self) -> bool:
		return self.enabled() and self.inp.mouse_wheel_precise

	def _debug_enabled(self) -> bool:
		return logging.getLogger().isEnabledFor(logging.DEBUG)

	@staticmethod
	def _format_debug_value(value: object) -> str:
		if isinstance(value, float):
			return f"{value:.4f}"
		return str(value)

	def _log_scroll_detail(self, source: str, event: str, throttle: float = 0.0, **fields: object) -> None:
		if not self._debug_enabled():
			return
		now = time.monotonic()
		key = f"{source}:{event}"
		if throttle > 0 and now - self.scroll_debug_last_logs.get(key, 0.0) < throttle:
			return
		if throttle > 0:
			self.scroll_debug_last_logs[key] = now
		payload = " ".join(
			f"{name}={self._format_debug_value(value)}"
			for name, value in fields.items()
		)
		# if payload:
		# 	logging.debug("Smooth scroll %s source=%s %s", event, source, payload)
		# else:
		# 	logging.debug("Smooth scroll %s source=%s", event, source)

	def scroll(self, source: str, coeff: float = 1) -> int:
		"""Used for sections that require integer scroll values, e.g. pixels or lines.
		Coeff should be the number that the scroll would be multiplied by if the scroll input was an integer;
		Source keeps everything straight (the string's contents don't matter at all).
		"""
		state = self._state(source)
		effective_wheel = self.inp.mouse_wheel
		if effective_wheel != 0:
			effective_wheel *= self._wheel_boost(state, effective_wheel)

		# if smooth scrolling isn't necessary
		if effective_wheel % 1 == 0:
			return int(effective_wheel * coeff)
		try:
			self.scroll_bins[source]
		except KeyError: # create for first time
			self.scroll_bins[source] = []
			self.scroll_timeouts[source] = Timer()

		# tally up float inputs over time & only return when the final output can be integerized
		if self.scroll_timeouts[source].get() > self.timeout:
			self.scroll_bins[source] = []
		self.scroll_bins[source].append(effective_wheel)

		if sum(self.scroll_bins[source]) * coeff > 1 or sum(self.scroll_bins[source]) * coeff < -1:
			scroll_distance = int(sum(self.scroll_bins[source]) * coeff)
			self.scroll_bins[source] = [sum(self.scroll_bins[source]) % (1 / coeff)] # save the remainder
		else:
			scroll_distance = 0
		self.scroll_timeouts[source].set()

		return scroll_distance

	def _state(self, source: str) -> ScrollMotionState:
		if source not in self.physics_states:
			self.physics_states[source] = ScrollMotionState()
		return self.physics_states[source]

	def _update_wheel_streak(self, state: ScrollMotionState, delta: float) -> tuple[float, float]:
		now = time.monotonic()
		dt = now - state.last_wheel_time if state.last_wheel_time else SCROLL_PHYSICS_REPEAT_WINDOW
		direction = 1.0 if delta > 0 else -1.0
		if (
			state.last_wheel_direction == direction
			and now - state.last_wheel_time <= SCROLL_PHYSICS_REPEAT_WINDOW
		):
			state.wheel_streak = min(state.wheel_streak + 1, 10)
		else:
			state.wheel_streak = 0
		state.last_wheel_direction = direction
		state.last_wheel_time = now
		streak = min(state.wheel_streak, 8)
		repeat_boost = 1.0 + streak * SCROLL_PHYSICS_REPEAT_ACCELERATION
		if streak > 1:
			repeat_boost += (streak - 1) ** 2 * SCROLL_PHYSICS_REPEAT_CURVE
		repeat_boost += max(abs(delta) - 1.0, 0.0) * SCROLL_PHYSICS_WHEEL_MAGNITUDE_ACCELERATION
		return repeat_boost, dt

	def _wheel_boost(self, state: ScrollMotionState, delta: float) -> float:
		repeat_boost, dt = self._update_wheel_streak(state, delta)
		return repeat_boost

	def reset_motion(self, source: str) -> None:
		state = self.physics_states.get(source)
		if state is None:
			return
		state.velocity = 0.0
		state.pending = 0.0
		state.accumulator = 0.0
		state.precise_buffer = 0.0
		state.last_precise_input = 0.0
		state.touching = False
		state.from_touch = False
		state.wheel_streak = 0
		state.last_wheel_direction = 0.0
		state.last_wheel_time = 0.0
		state.last_update = time.monotonic()
		self.scroll_debug_modes.pop(source, None)
		for key in [k for k in self.scroll_debug_last_logs if k.startswith(f"{source}:")]:
			del self.scroll_debug_last_logs[key]

	def _log_scroll_mode(
		self, source: str, mode: str, delta: float, px_per_unit: float, precise_scale: float, precise_px_per_unit: float | None
	) -> None:
		prev_mode = self.scroll_debug_modes.get(source)
		if prev_mode == mode:
			return
		self.scroll_debug_modes[source] = mode
		self._log_scroll_detail(
			source,
			"route",
			mode=mode,
			delta=delta,
			px_per_unit=px_per_unit,
			precise_scale=precise_scale,
			precise_px_per_unit=precise_px_per_unit if precise_px_per_unit is not None else "None",
			wheel_precise=self.inp.mouse_wheel_precise,
			window_active=time.monotonic() < self.inp.trackpad_scroll_mode_until,
		)

	def reset_disabled_motion(self) -> None:
		for source, state in self.physics_states.items():
			if not state.from_touch:
				self.reset_motion(source)

	def add_wheel_motion(
		self, source: str, delta: float, px_per_unit: float, precise_scale: float = 1.0, precise_px_per_unit: float | None = None
	) -> None:
		if delta == 0:
			return

		state = self._state(source)
		max_velocity = self._scaled_max_velocity()
		velocity_limit = max_velocity
		pending_before = state.pending
		precise_before = state.precise_buffer
		velocity_before = state.velocity
		state.from_touch = False
		route = "wheel"
		speed = self.speed()
		precise_unit = precise_px_per_unit if precise_px_per_unit is not None else px_per_unit
		pixel_delta = 0.0
		repeat_boost = 0.0
		boost = 0.0
		impulse = 0.0
		if self.precise_scroll_active():
			route = "precise"
			self._log_scroll_mode(source, "precise", delta, px_per_unit, precise_scale, precise_px_per_unit)
			state.velocity = 0.0
			state.wheel_streak = 0
			state.last_wheel_direction = 0.0
			state.last_wheel_time = 0.0
			pixel_delta = delta * precise_unit * SCROLL_PHYSICS_PRECISE_WHEEL_PIXEL_MULTIPLIER * precise_scale * speed
			state.precise_buffer += pixel_delta
			state.last_precise_input = time.monotonic()
		else:
			self._log_scroll_mode(source, "wheel", delta, px_per_unit, precise_scale, precise_px_per_unit)
			velocity_limit *= SCROLL_PHYSICS_WHEEL_MAX_VELOCITY_MULTIPLIER
			repeat_boost = self._wheel_boost(state, delta)
			boost = 1.0 + min(abs(state.velocity) / velocity_limit, 1.0) * SCROLL_PHYSICS_ACCELERATION_BOOST
			impulse = delta * px_per_unit * repeat_boost * speed
			state.velocity += impulse * SCROLL_PHYSICS_WHEEL_VELOCITY * boost
			state.last_update = time.monotonic()
		state.last_velocity = state.velocity
		state.velocity = max(min(state.velocity, velocity_limit), -velocity_limit)
		state.touching = False
		self._log_scroll_detail(
			source,
			"wheel-input",
			route=route,
			delta=delta,
			px_per_unit=px_per_unit,
			precise_scale=precise_scale,
			speed=speed,
			precise_unit=precise_unit,
			pixel_delta=pixel_delta,
			repeat_boost=repeat_boost,
			boost=boost,
			impulse=impulse,
			pending_before=pending_before,
			pending_after=state.pending,
			precise_before=precise_before,
			precise_after=state.precise_buffer,
			velocity_before=state.last_velocity,
			velocity_after=state.velocity,
			velocity_limit=velocity_limit,
		)

	def apply_touch_drag(self, source: str, delta_pixels: float) -> None:
		state = self._state(source)
		max_velocity = self._scaled_max_velocity()
		now = time.monotonic()
		dt = max(now - state.last_update, 1 / 240)
		pending_before = state.pending
		precise_before = state.precise_buffer
		state.last_velocity = state.velocity
		state.touching = True
		state.from_touch = True
		state.precise_buffer = 0.0
		state.pending += delta_pixels * SCROLL_PHYSICS_TOUCH_DRAG_MULTIPLIER
		state.velocity = delta_pixels / dt * SCROLL_PHYSICS_TOUCH_FLING_MULTIPLIER
		state.velocity = max(min(state.velocity, max_velocity), -max_velocity)
		state.last_update = now
		self._log_scroll_detail(
			source,
			"touch-drag",
			delta_pixels=delta_pixels,
			dt=dt,
			pending_before=pending_before,
			pending_after=state.pending,
			precise_before=precise_before,
			precise_after=state.precise_buffer,
			velocity_before=state.last_velocity,
			velocity_after=state.velocity,
			max_velocity=max_velocity,
		)

	def release_touch(self, source: str) -> None:
		if source in self.physics_states:
			self.physics_states[source].touching = False
			self.physics_states[source].from_touch = True
			self.physics_states[source].last_update = time.monotonic()
			# velocity on release should be based on the last two frames of motion
			if self.physics_states[source].last_velocity != 0.0:
				self.physics_states[source].velocity = (self.physics_states[source].velocity + self.physics_states[source].last_velocity) / 2
			state = self.physics_states[source]
			self._log_scroll_detail(
				source,
				"touch-release",
				pending=state.pending,
				precise_buffer=state.precise_buffer,
				velocity=state.velocity,
			)

	def step_motion(self, source: str) -> float:
		state = self._state(source)
		min_velocity = self._scaled_min_velocity()
		now = time.monotonic()
		dt = min(max(now - state.last_update, 0.0), SCROLL_PHYSICS_MAX_TIMESTEP)
		accumulator_before = state.accumulator
		velocity_before = state.velocity
		pending_before = state.pending
		precise_before = state.precise_buffer
		state.last_update = now
		state.accumulator = min(state.accumulator + dt, SCROLL_PHYSICS_MAX_TIMESTEP)

		delta = state.pending
		state.pending = 0.0
		precise_dt = 0.0
		precise_delta = 0.0
		precise_release_age = 0.0
		precise_snapped = False
		fixed_steps = 0
		exit_reason = "active"

		if abs(state.precise_buffer) >= 0.01:
			precise_dt = min(max(dt, 1 / 240), SCROLL_PHYSICS_PRECISE_MAX_TIMESTEP)
			precise_alpha = 1.0 - math.exp(-precise_dt / max(SCROLL_PHYSICS_PRECISE_SMOOTHING, 1e-4))
			precise_delta = state.precise_buffer * precise_alpha
			delta += precise_delta
			state.precise_buffer -= precise_delta
			if state.last_precise_input:
				precise_release_age = max(now - state.last_precise_input, 0.0)
			if (
				precise_release_age >= SCROLL_PHYSICS_PRECISE_RELEASE_GRACE
				and abs(state.precise_buffer) <= SCROLL_PHYSICS_PRECISE_STOP_THRESHOLD
			):
				state.precise_buffer = 0.0
				precise_snapped = True
			if abs(state.precise_buffer) < 0.01:
				state.precise_buffer = 0.0

		if state.touching:
			state.accumulator = 0.0
			self._log_scroll_detail(
				source,
				"step",
				dt=dt,
				accumulator_before=accumulator_before,
				accumulator_after=state.accumulator,
				pending_before=pending_before,
				precise_before=precise_before,
				precise_dt=precise_dt,
				precise_delta=precise_delta,
				precise_release_age=precise_release_age,
				precise_snapped=precise_snapped,
				precise_after=state.precise_buffer,
				velocity_before=velocity_before,
				velocity_after=state.velocity,
				fixed_steps=fixed_steps,
				delta_out=delta,
				reason="touching",
			)
			return delta

		if abs(state.velocity) < min_velocity:
			state.velocity = 0.0
			state.accumulator = 0.0
			self._log_scroll_detail(
				source,
				"step",
				dt=dt,
				accumulator_before=accumulator_before,
				accumulator_after=state.accumulator,
				pending_before=pending_before,
				precise_before=precise_before,
				precise_dt=precise_dt,
				precise_delta=precise_delta,
				precise_release_age=precise_release_age,
				precise_snapped=precise_snapped,
				precise_after=state.precise_buffer,
				velocity_before=velocity_before,
				velocity_after=state.velocity,
				fixed_steps=fixed_steps,
				delta_out=delta,
				min_velocity=min_velocity,
				reason="below-min-velocity",
			)
			return delta

		while state.accumulator >= SCROLL_PHYSICS_FIXED_TIMESTEP:
			fixed_steps += 1
			delta += state.velocity * SCROLL_PHYSICS_FIXED_TIMESTEP
			state.velocity *= SCROLL_PHYSICS_FIXED_DAMPING
			state.accumulator -= SCROLL_PHYSICS_FIXED_TIMESTEP
			if abs(state.velocity) < min_velocity:
				state.velocity = 0.0
				state.accumulator = 0.0
				exit_reason = "damped-below-min"
				break

		if state.velocity != 0.0 and state.accumulator > 0:
			delta += state.velocity * state.accumulator
			state.velocity *= SCROLL_PHYSICS_DAMPING ** (state.accumulator * 60)
			state.accumulator = 0.0

		if abs(state.velocity) < min_velocity:
			state.velocity = 0.0
			if exit_reason == "active":
				exit_reason = "clamped-below-min"
		self._log_scroll_detail(
			source,
			"step",
			dt=dt,
			accumulator_before=accumulator_before,
			accumulator_after=state.accumulator,
			pending_before=pending_before,
			pending_after=state.pending,
			precise_before=precise_before,
			precise_dt=precise_dt,
			precise_delta=precise_delta,
			precise_release_age=precise_release_age,
			precise_snapped=precise_snapped,
			precise_after=state.precise_buffer,
			velocity_before=velocity_before,
			velocity_after=state.velocity,
			min_velocity=min_velocity,
			fixed_steps=fixed_steps,
			delta_out=delta,
			reason=exit_reason,
		)
		return delta

	def active(self, source: str) -> bool:
		state = self.physics_states.get(source)
		if state is None:
			return False
		return (
			state.touching
			or abs(state.velocity) >= self._scaled_min_velocity()
			or abs(state.pending) >= 0.01
			or abs(state.precise_buffer) >= 0.01
		)

	def any_active(self) -> bool:
		return any(self.active(source) for source in self.physics_states)



class TimedLyricsEdit:

	def __init__(self, tauon: Tauon) -> None:
		self.tauon: Tauon             = tauon
		self.inp: Input               = tauon.inp
		self.gui: GuiVar              = tauon.gui
		self.ddt: TDraw               = tauon.ddt
		self.coll                     = tauon.coll
		self.draw: Drawing            = tauon.draw
		self.pctl: PlayerCtl          = tauon.pctl
		self.prefs: Prefs             = tauon.prefs
		self.colours: ColoursClass    = tauon.colours
		self.renderer                 = tauon.renderer
		self.overlay: SearchOverlay   = tauon.search_over
		self.window_size: list[int]   = tauon.window_size
		self.scroll: SmoothScroll     = tauon.smooth_scroll

		# main boys big kahunas
		self.struct_track:                          int = -1 # what track are we on
		self.structure: list[ tuple[ str, float, str] ] = [] # backbone of synced editing system
		                                                     # each line contains a timestamp as a string, that timestamp's actual time, and the line itself
		self.line_edit_box:                    TextBox2 = TextBox2(tauon=tauon) # there are no multi line text boxes so we have to reuse the same one for every single line
		self.x_posns:                         list[int] = [] # to display text editing buttons in the right places
		self.line_active:                           int = 0  # which lyric is currently playing
		self.view_is_synced:                       bool = True # which view do we display
		self.text:                                  str = "" # unsynced lyrics after lrc stuff is filtered out

		# icons
		self.synced_img   = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "synced.png", True)
		self.unsynced_img = asset_loader(tauon.bag, tauon.bag.loaded_asset_dc, "unsynced.png", True)

		# these ones are only stored from one frame to the next
		self.pausing:          bool = False # prevents typing a space in text box when pausing
		self.cursor:     int | None = None  # tracks text box cursor position across frames
		self.big_paste:        bool = False # do special things the frame after receiving a multi-line paste
		self.continuous:       bool = True  # track change behavior should only happen if the editor wasn't closed when the track changed
		self.queue_next_frame: bool = False # update gui when things happen
		self.temp_line:         int = -1    # special scroll thingy when the line advances while it's offscreen
		self.temp_w:            int = 0     # these two are used to recalculate self.x_posns when the user...
		self.temp_scale:      float = self.gui.scale # ...resizes or rescales the window
		self.editing_line:      int = -1    # clear selection when line changes
		self.track_time_left:   int = -1    # we'll try to filter out manual track skips so we don't unexpectedly save
		self.repeat_mode:list[bool] = []    # save the global repeat mode yada yada track end behavior

		# scrolling
		self.scroll_position: int = 0 # measured in pixels - greater means scrolled further down - 0 means centered on active line
		self.lyrics_position: int = 0 # same thing but for the static view
		self.allow_scroll:   bool = True # cancels auto scroll correction when adding & removing lines. probably doesn't actually do anything
		self.font:            int = 20
		self.line_height:     int = round(self.ddt.get_text_w("?", self.font, True))
		self.yy:              int = self.line_height + round(10 * self.gui.scale) #line height plus spacing

		# timers
		self.recenter_timeout: Timer = Timer() # when playing, snap back to current line after 5 seconds no scrolling
		self.autosave_timer:   Timer = Timer() # create autosave a couple seconds after most recent edit
		self.autosaved:         bool = True    # then don't do it again until next edit
		self.text_timer:       Timer = Timer() # should display lyrics search indicator

		# nudge timestamp
		self.check_timer:   Timer = Timer() # preview timing after half a second
		self.check_line:      int = -1
		self.check:   bool | None = None    # True if previewing now, False if waiting to preview, None if disengaged
		self.alt_timer:     Timer = Timer()
		self.alted:          bool = False

		# lrclib upload
		self.upload_synced:                                      bool = True
		self.upload_static:                                      bool = False
		self.potential_uploads: dict[ int, dict[ str, str | float ] ] = {}
		self.box_open:                                           bool = False
		self.shake_frames:                                        int = 0 # less intrusive than a whole error box
		self.clicks:                                              int = 0 # but if they click a bunch anyway then we should show an error box.

		# menus
		self.menu:          Menu = Menu(tauon, 135)
		self.unsynced_menu: Menu = Menu(tauon, 135)
		self.reload_menu()
		self.unsynced_menu.add(MenuItem(_("Exit Lyrics Editor"), self.exit_lyrics_editor, pass_ref=False))
		self.unsynced_menu.add(MenuItem(_("Search for Lyrics"), self.tauon.get_lyric_wiki, pass_ref=True))
		self.unsynced_menu.add(MenuItem(_("Copy From Synced"), self.copy_from_synced, pass_ref=False))
		self.unsynced_menu.add(MenuItem(_("Upload To LRCLIB"), self.upload_both_to_lrclib, pass_ref=False))


	# FUNCTIONS FROM THE RIGHT CLICK MENU

	def reload_menu(self) -> None:
		"""Recreates the context menu to properly display checkmarks"""
		self.menu.subs = []
		self.menu.sub_number = 0
		self.menu.items = []

		self.menu.add(MenuItem(_("Exit Lyrics Editor"), self.exit_lyrics_editor, pass_ref=False))
		self.menu.add(MenuItem(_("Search for Lyrics"), self.tauon.get_lyric_wiki, pass_ref=True))
		self.menu.add(MenuItem(_("Copy From Unsynced"), self.copy_from_unsynced, pass_ref=False))
		self.menu.add(MenuItem(_("Clear All Section Markers"), self.clear_section_markers, pass_ref=False))
		self.menu.add(MenuItem(_("Clear All Timestamps"), self.clear_all_timestamps, pass_ref=False))
		self.menu.add(MenuItem(_("Clear All Lyrics"), self.clear_lyrics, pass_ref=False))
		self.menu.add(MenuItem(_("Upload To LRCLIB"), self.upload_both_to_lrclib, pass_ref=False))

		self.menu.add_sub(_("Backups..."), 165)
		self.menu.add_to_sub(0, MenuItem(_("Load Current Backup"), self.autoload, pass_ref=False))
		self.menu.add_to_sub(0, MenuItem(_("Visit Current Backup"), self.visit_backup, pass_ref=False))
		self.menu.add_to_sub(0, MenuItem(_("Delete All Backups"), self.delete_autosaves, pass_ref=False))

		self.menu.add_sub(_("When track ends..."), 165)

		def end_mode_check(mode: str) -> Callable[[], bool]:
			return lambda: self.prefs.synced_lyrics_editor_track_end_mode == mode

		self.menu.add_to_sub(1, MenuItem(_("Repeat track"), self.end_set_repeat,
			check_test=end_mode_check("repeat")))
		self.menu.add_to_sub(1, MenuItem(_("Stop immediately"), self.end_set_stop,
			check_test=end_mode_check("stop")))
		self.menu.add_to_sub(1, MenuItem(_("Backup and continue"), self.end_set_autosave,
			check_test=end_mode_check("autosave")))
		self.menu.add_to_sub(1, MenuItem(_("Fully save and continue"), self.end_set_full_save,
			check_test=end_mode_check("full save")))

		self.menu.add(MenuItem(_("Save synced to .lrc"), self.toggle_lrc, pass_ref=False,
			check_test=lambda: self.prefs.save_synced_to_lrc))

	def end_set_repeat(self) -> None:
		self.prefs.synced_lyrics_editor_track_end_mode = "repeat"
		self.reload_menu()

	def end_set_stop(self) -> None:
		self.prefs.synced_lyrics_editor_track_end_mode = "stop"
		self.reload_menu()

	def end_set_autosave(self) -> None:
		self.prefs.synced_lyrics_editor_track_end_mode = "autosave"
		self.reload_menu()

	def end_set_full_save(self) -> None:
		self.prefs.synced_lyrics_editor_track_end_mode = "full save"
		self.reload_menu()

	def exit_lyrics_editor(self) -> None:
		self.autosave()
		self.gui.timed_lyrics_edit_view = False

	def delete_autosaves(self) -> None:
		count = 0
		target = self.tauon.config_directory / "lyrics-editor"
		if not target.is_dir():
			return
		for child in target.iterdir():
			if child.is_file():
				count += 1
				child.unlink()
		try:
			target.rmdir()
		except OSError:
			logging.error( _("You put a folder in the lyrics-editor directory. Don't do that.") )
		logging.info(f"Deleted {count} autosave files.")

	def clear_all_timestamps(self) -> None:
		for i, line in enumerate(self.structure):
			if line[0] != "tag":
				self.structure[i] = "??:??.??", -1.0, line[2]
		self.autosave_timer.set()
		self.autosaved = False

	def clear_lyrics(self) -> None:
		self.structure = [ ("??:??.??", -1.0, "") ]

	def clear_section_markers(self) -> None:
		deletes = []
		for i, line in enumerate(self.structure):
			if line[0] == "tag":
				continue
			if line[2].startswith("[") and line[2].endswith("]"):
				deletes.append(i)
		deletes.reverse()
		for i in deletes:
			del self.structure[i]

	def copy_from_unsynced(self) -> None:
		track = self.pctl.master_library[self.struct_track]
		self.structurize_current(track, True)

	def copy_from_synced(self) -> None:
		self.text = ""
		for line in self.structure:
			self.text += line[2] + "\n"
		self.text = self.text.strip()

	def upload_both_to_lrclib(self) -> None:
		track = self.pctl.master_library[self.struct_track]
		synced_text = self.save(fetch_text=True)
		p = {
			"trackName": track.title,
			"artistName": track.artist,
			"albumName": track.album,
			"duration": track.length,
			"plainLyrics": self.text,
			"syncedLyrics": synced_text
		}
		self.potential_uploads[self.struct_track] = p

	def toggle_lrc(self) -> None:
		self.prefs.save_synced_to_lrc = not self.prefs.save_synced_to_lrc
		if not self.prefs.save_synced_to_lrc:
			self.tauon.show_message(
				_("Be careful!"),
				_("A file's metadata can only store ONE type of lyrics data, synced or static, at a time."),
				_("Saving one type will now OVERWRITE the other in the files. (Tauon itself doesn't care.)"),
				mode="warning"
			)
		self.reload_menu()




	def button(
			self, text: str, x_pos: int, y_pos: int, font: int,
			bg: ColourRGBA | None = None, active_bg: ColourRGBA | None = None,
			txt: ColourRGBA | None = None, active_txt: ColourRGBA | None = None,
			tooltip: str = "", off: bool = False, return_rect: bool = False) -> tuple[ bool | None, tuple[int, int, int, int] | None ]:
		"""Button centered around text display. off will disable the button if the condition is true,
		return_rect will return the rect as a second parameter. returns True for click, False for right click,
		None for nothing
		"""
		if bg is None:
			bg = self.colours.box_button_background
		if active_bg is None:
			active_bg = self.colours.box_button_background_highlight
		if txt is None:
			txt = self.colours.box_button_text
		if active_txt is None:
			active_txt = self.colours.box_button_text_highlight

		if off:
			bg = copy.deepcopy(bg)
			bg.a = round(bg.a * 0.5)
			txt = copy.deepcopy(txt)
			txt.a = round(txt.a * 0.5)
		inner_border = 7*self.gui.scale
		width = self.ddt.get_text_w(text, font)
		height = self.ddt.get_text_w("?", font, True) /2
		rect = (x_pos - inner_border, y_pos - inner_border, width + 2*inner_border, height + 2*inner_border)
		self.tauon.fields.add(rect)
		t_rect = (x_pos, y_pos)
		if self.coll(rect) and not off:
			self.ddt.bordered_rect( rect, active_bg, self.colours.box_text_border, round(1*self.gui.scale))
			self.ddt.text( t_rect, text, active_txt, font, bg=active_bg)
			if tooltip:
				self.tauon.tool_tip.test(x_pos + 15 * self.gui.scale, y_pos - 28 * self.gui.scale, tooltip)
			if self.inp.mouse_click:
				if return_rect:
					return True, rect
				return True, None
			if self.inp.right_click:
				self.inp.right_click = False
				if return_rect:
					return False, rect
				return False, None
		else:
			self.ddt.bordered_rect( rect, bg, self.colours.box_text_border, round(1*self.gui.scale))
			self.ddt.text( t_rect, text, txt, font, bg=bg)
		if return_rect:
			return None, rect
		return None, None

	def get_time_from_stamp(self, t: str) -> float:
		a = t.lstrip("[")
		t = t.split("]")[1] + "]"

		a = a.split("]")[0]
		mm, b = a.split(":")
		ss, ms = b.split(".")

		s = int(mm) * 60 + int(ss)
		if len(ms) == 2:
			s += int(ms) / 100
		elif len(ms) == 3:
			s += int(ms) / 1000
		return s

	def get_stamp_from_time(self, t: float) -> str:
		if t<0:
			return "??:??.??"
		ms = round( 100 * t ) % 100
		t = t//1
		ss = round(t%60)
		mm = round(t//60)
		return f"{format(mm,'02d')}:{format(ss,'02d')}.{format(ms,'02d')}"

	def structurize_current(self, track: TrackClass, from_unsynced: bool = False) -> None:
		LRC_tags = "[ti:", "[ar:", "[al:", "[au:", "[lr:", "[length:", "[by:", "[offset:", "[re:", "[tool:", "[ve:", "[#:"
		self.structure = []
		self.struct_track = track.index

		if from_unsynced:
			lyrics = self.text
		elif track.synced:
			lyrics = find_synced_lyric_data(track)
		elif track.lyrics:
			lyrics = track.lyrics.splitlines()
		else:
			self.structure = [("??:??.??", -1.0, "")]
			self.scroll_position = 0
			lyrics=""

		for i, line in enumerate(lyrics):
			if any(tag in line for tag in LRC_tags):
				self.structure.append( ("tag", -1.0, line) )
				continue

			if len(line) >= 10 and line[0] == "[" and ":" in line[:10] \
			and "." in line[:10] and "]" in line:
				try:
					int( line[1] )
				except ValueError:
					pass
				else: # if current line is LRC-formatted
					stamp = line.split("]")[0].lstrip("[")
					time = self.get_time_from_stamp( line )
					line = line.split("]",1)[1]
					# LRCLIB returns lines with spaces at the start of them
					# it's not important when displaying but it kind of is in editing
					line = line.removeprefix(" ")
					self.structure.append( (stamp,time,line) )
					continue

			# if current line is NOT LRC-formatted
			self.structure.append( ("??:??.??", -1.0, line) )
		if self.structure == []:
			self.structure = [ ("??:??.??", -1.0, "") ]
		self.autosaved = True
		self.queue_next_frame = True

	def save(
		self,
		synced: bool = True,
		fetch_text: bool = False,
		save_to_lrc: bool | None = None,
		save_to_tags: bool | None = None,
	) -> None | str:
		lyrics: str = ""
		warning: list[bool] = [False, False, False]
		timed: int = 0
		max_stamp: float = 0.0

		track = self.pctl.master_library[self.struct_track]
		over = self.will_overwrite_lyrics(track)
		save_tags = self.prefs.save_lyrics_changes_to_files if save_to_tags is None else save_to_tags
		save_lrc = self.prefs.save_synced_to_lrc if save_to_lrc is None else save_to_lrc

		if synced:
			for line in self.structure:
				if line[1] < 0:
					lyrics += line[2].rstrip()
					if line[0] != "tag":
						warning[0] = True
					else:
						timed += 1
				else:
					if line[1] < max_stamp:
						warning[2] = True
					stamp = self.get_stamp_from_time( line[1] )
					lyrics += "[" + stamp + "]" + line[2]
					warning[1] = True
					timed += 1
					max_stamp = max(max_stamp, line[1])
				lyrics += "\n"
			lyrics = lyrics.strip()
		else:
			lyrics = self.text.strip()

		if warning[0] and warning[1] and not ( self.inp.key_lalt or self.inp.key_ralt ):
			if fetch_text:
					return "failed"
			self.tauon.show_message(
				_("Lyrics will misbehave if only some of them are timed."),
				_("You can time all of them or none of them, either will work."),
				_("Alternatively, save again while holding Alt to override."),
				mode="warning"
			)
			self.autosave()
		elif warning[2] and not ( self.inp.key_lalt or self.inp.key_ralt ):
			if fetch_text:
					return "failed"
			self.tauon.show_message(
				_("Lyrics might misbehave if their timestamps are out of order."),
				_("Please make sure all timestamps are ordered correctly."),
				_("Alternatively, save again while holding Alt to override."),
				mode="warning"
			)
			self.autosave()
		else:
			if ( self.inp.key_lalt or self.inp.key_ralt ):
				self.alted = True
			if timed > 0:
				if fetch_text:
					return lyrics
				track.synced = lyrics
				saved_lrc = False
				saved_tags = False
				if save_lrc:
					saved_lrc = self.tauon.write_lyrics(track, True, True, synced_target="lrc")
				if save_tags:
					saved_tags = self.tauon.write_lyrics(track, True, True, synced_target="tags")

				if saved_lrc and saved_tags:
					self.tauon.show_message(
						_("Synced lyrics saved successfully"),
						_("Saved to .lrc and tags"),
						mode="done"
					)
				elif saved_lrc:
					self.tauon.show_message(
						_("Synced lyrics saved successfully"),
						_("Saved to .lrc"),
						mode="done"
					)
				elif saved_tags:
					if over:
						self.tauon.show_message(
							_("Synced lyrics saved to tags"),
							_("Destroyed previously existing lyrics in file"),
							mode="done"
						)
					else:
						self.tauon.show_message(
							_("Synced lyrics saved to tags"),
							mode="done"
						)
				else:
					self.tauon.show_message(
						_("Synced lyrics saved in Tauon only"),
						mode="done"
					)
			else:
				if fetch_text:
					return "failed"
					# we don't want to upload any synced lyrics to LRCLIB
					# that are not actually synced properly
				track.lyrics = lyrics
				if save_tags and self.tauon.write_lyrics(track, False, True):
					if over:
						self.tauon.show_message(
							_("Unsynced lyrics saved successfully"),
							_("Destroyed previously existing lyrics in file"),
							mode="done"
						)
					else:
						self.tauon.show_message(
							_("Unsynced lyrics saved successfully"),
							mode="done"
						)
				elif not save_tags:
					self.tauon.show_message(
						_("Unsynced lyrics saved in Tauon only"),
						mode="done"
					)
			self.test_update()
		self.queue_next_frame = True

		# clear cached search data (user can search by lyrics immediately)
		self.tauon.search_string_cache.pop(self.struct_track, None)
		self.tauon.search_dia_string_cache.pop(self.struct_track, None)
		self.tauon.search_field_cache.pop(self.struct_track, None)
		self.tauon.search_dia_field_cache.pop(self.struct_track, None)

		return None

	def will_overwrite_lyrics(self, track: TrackClass) -> bool:
		file = Path(track.fullpath)
		if track.file_ext == "MP3":
			audio = mutagen.id3.ID3(track.fullpath)
			if audio.getall("USLT"):
				return True
		elif track.file_ext == "FLAC":
			audio = mutagen.flac.FLAC(track.fullpath)
			if any(key in audio for key in ("LYRICS", "SYNCEDLYRICS", "UNSYNCEDLYRICS")):
				return True
		elif track.file_ext == "OPUS":
			audio = mutagen.oggopus.OggOpus(track.fullpath)
			if any(key in audio for key in ("LYRICS", "SYNCEDLYRICS", "UNSYNCEDLYRICS")):
				return True
		elif track.file_ext == "OGG":
			audio = mutagen.oggvorbis.OggVorbis(track.fullpath)
			if any(key in audio for key in ("LYRICS", "SYNCEDLYRICS", "UNSYNCEDLYRICS")):
				return True
		elif track.file_ext in ("APE","WV","TTA"):
			audio = mutagen.apev2.APEv2(track.fullpath)
			if "Lyrics" in audio:
				return True
		elif track.file_ext in ("MP4","M4A","M4B","M4P"):
			audio = mutagen.mp4.MP4(track.fullpath)
			if "\xa9lyr" in audio:
				return True
		return False

	def autosave(self) -> None:
		target = Path( self.tauon.config_directory / "lyrics-editor" / str( self.struct_track )).with_suffix(".csv")
		if not target.parent.is_dir():
			target.parent.mkdir()
		with open(target, "w", encoding="utf-8") as lyrics_file:
			for line in self.structure:
				stamp, time, line = line
				if stamp == "tag":
					stamp = "tag"
				lyrics_file.write( f"{stamp},{time!s},{line}\n")
		self.autosaved = True

	def autoload(self) -> None:
		target = Path( self.tauon.config_directory / "lyrics-editor" / str( self.struct_track )).with_suffix(".csv")
		if not target.is_file():
			return
		with target.open(encoding="utf-8-sig", errors="replace") as lyrics_file:
			self.structure = []
			for lyric in lyrics_file.readlines():
				stamp, time, line = lyric.strip().split(",", 2)
				if stamp == "tag":
					stamp = "tag"
				time = float(time)
				self.structure.append( (stamp,time,line) )
		self.queue_next_frame = True

	def visit_backup(self, synced: bool = True) -> None:
		suffix = ".csv" if synced else ".txt"
		target = Path( self.tauon.config_directory / "lyrics-editor" / str( self.struct_track )).with_suffix(suffix)
		if not target.parent.is_dir() and not synced:
			target.parent.mkdir()
		elif not target.is_file():
			self.tauon.show_message(
				_("Backup file does not exist")
			)
			return
		if not synced:
			with open(target, "w", encoding="utf-8") as lyrics_file:
				if self.text:
					lyrics_file.write( self.text )
				else:
					lyrics_file.write( _("Put the lyrics in this file."))

		if self.tauon.windows:
			os.startfile(target)
		elif self.tauon.macos:
			subprocess.call(["open", "-t", target])
		else:
			subprocess.call(["xdg-open", target])



	# SYNCED EDITING FUNCTIONS

	def previous(self, prev: float) -> None:
		self.pctl.seek_time(prev + self.prefs.sync_lyrics_time_offset/1000)
		if (self.inp.key_lalt or self.inp.key_ralt):
			self.alted = True
		if (len(self.structure)==self.line_active+1 or self.structure[self.line_active+1][1]<0) and self.structure[self.line_active][1]>prev \
			and not (self.inp.key_lalt or self.inp.key_ralt):
			stamp, time, line = self.structure[self.line_active]
			stamp = "??:??.??"
			time = -1.0
			full_line = ( stamp, time, line )
			self.structure[self.line_active] = full_line
			if self.line_active == len(self.structure)-1 and line == "":
				del self.structure[self.line_active]
		self.queue_next_frame = True

	def time_next_line(self, current: bool = False) -> None:
		time = self.pctl.decode_time
		if current:
			self.alted = True
		if (self.structure[self.line_active][1] < 0 or self.structure[self.line_active][1] > time or current) and self.structure[self.line_active][0] != "tag":
			# if current line needs to be timed, time it
			full_line = ( self.get_stamp_from_time(time), time, self.structure[self.line_active][2] )
			self.structure[self.line_active] = full_line
		else:
			while ( self.line_active < len(self.structure)-1 and self.structure[self.line_active+1][0] == "tag"):
				self.line_active += 1 # increment until we're not going to timestanmp a tag
			if self.line_active == len(self.structure)-1 or (self.inp.key_rctrl_down or self.inp.key_ctrl_down):
				self.structure.insert( self.line_active+1, (self.get_stamp_from_time(time), time, "") )
			else:
				full_line = ( self.get_stamp_from_time(time), time, self.structure[self.line_active+1][2] ) # else time the next line
				self.structure[self.line_active+1] = full_line
		self.queue_next_frame = True

	def scroll_timestamp(self, current_line: int, active: bool = True) -> bool:
		stamp, time, line = self.structure[current_line]
		if time == -1.0:
			return False

		if self.inp.key_ctrl_down or self.inp.key_rctrl_down:
			adjust = 0.01
		else:
			adjust = 0.1

		old_time = time
		if self.inp.mouse_wheel:
			time -= self.inp.mouse_wheel * adjust
			if active:
				self.check = False
				self.check_line = current_line
				self.check_timer.set()
		elif self.inp.key_right_press or self.inp.key_left_press:
			time += adjust * (self.inp.key_right_press - self.inp.key_left_press)
			if active:
				self.check = False
				self.check_line = current_line
				self.check_timer.set()

		self.allow_scroll = False

		if old_time == time:
			return False

		self.alted = True
		time = round(time, 2)
		if current_line != 0 and self.structure[current_line - 1][1] != -1.0:
			time = max( time, self.structure[current_line - 1][1] )
		if current_line != len(self.structure)-1 and self.structure[current_line+1][1] != -1.0:
			time = min( time, self.structure[current_line+1][1] )
		time = max( time, 0 )
		stamp = self.get_stamp_from_time(time)
		self.structure[current_line] = (stamp,time,line)
		self.autosave_timer.set()
		self.autosaved = False
		if active:
			self.scroll_position += (current_line - self.line_active) * self.yy
			self.line_active = current_line
		self.check_if_time_is_good(current_line, active)
		self.queue_next_frame = True
		return True

	# for scrolling timestamps - will play one second of audio after 0.5 seconds of waiting
	# to make sure the timestamp is correct
	def check_if_time_is_good(self, line_number: int, active: bool) -> None:
		if self.check == False and self.structure[line_number][1] >= 0: # and self.check_timer.get > 0.5  and line_number == self.check_line
			self.recenter_timeout.set()
			if self.pctl.playing_state != PlayingState.PLAYING:
				self.pctl.stop(update_gui=False)
				self.pctl.jump_time = self.structure[line_number][1] + self.prefs.sync_lyrics_time_offset/1000
				self.pctl.play(update_gui=False)
				self.line_active = line_number
				self.check_timer.set()
				self.check = True
			else:
				self.pctl.decode_time = self.structure[line_number][1] + self.prefs.sync_lyrics_time_offset/1000
				self.pctl.new_time = self.pctl.decode_time
				self.pctl.playing_time = self.pctl.decode_time
				_seek = self.tauon.aud.seek(int((self.pctl.decode_time) * 1000), self.prefs.pa_fast_seek)
				if not active:
					self.check = None
			#self.scroll_position += (line_number - self.line_active) * self.yy
			self.allow_scroll = False
			self.queue_next_frame = True

	def accept_paste(self, current_line: int) -> None:
		LRC_tags = "[ti:", "[ar:", "[al:", "[au:", "[lr:", "[length:", "[by:", "[offset:", "[re:", "[tool:", "[ve:", "[#:"
		pasted_lines = self.line_edit_box.text.splitlines()
		temp_line = self.structure[current_line]
		overwrite: bool = (self.inp.key_lalt or self.inp.key_ralt)

		def is_int(number: str) -> bool:
			try:
				int(number)
				return True
			except ValueError:
				return False

		if not overwrite:
			out_line = ( temp_line[0], temp_line[1], pasted_lines[0] )
			self.structure[current_line] = out_line
			del pasted_lines[0]

		for i, line in enumerate(pasted_lines): # try to accept LRC-formatted paste text
			if any(tag in line for tag in LRC_tags):
					self.structure.append( ("tag", -1.0, line) )
					continue

			if len(line) >= 10 and line[0] == "[" and ":" in line[:10] \
				and "." in line[:10] and "]" in line and is_int(line[1]): # if line is LRC-formatted
				stamp = line.split("]")[0].lstrip("[")
				time = self.get_time_from_stamp( line )
				line = line.split("]",1)[1]
			else:
				stamp = "??:??.??"
				time = -1.0

			if overwrite and current_line+i <= len(self.structure)-1:
				if self.structure[current_line+i][1] != -1.0:
					time = self.structure[current_line+i][1]
					stamp = self.structure[current_line+i][0]
				self.structure[current_line+i] = (stamp,time,line)
			else:
				self.structure.insert( current_line+1+i, (stamp,time,line) )

			self.scroll_position -= self.yy
			if overwrite:
				self.big_paste = True

	def settings_for_one_line(self, line_number: int, y_pos: int) -> None:
		"""Deals with editing and manipulating synced lines while paused"""
		# x_posns contains in order: position for delete timestamp button, position for stamp teleport, position for text box, position for end of line
		if self.pausing:
			return

		stamp, time, line = self.structure[line_number]
		temp = self.structure[line_number]

		# TIMESTAMP - TELEPORT, DELETE AND SCROLL EDIT
		if stamp != "??:??.??" and stamp != "tag":
			button, rect = self.button(stamp, self.x_posns[1], y_pos, self.font, tooltip=_("Teleport to timestamp"), return_rect=True) # timestamp button
			if time >= 0 and button:
				self.pctl.stop()
				self.pctl.jump_time = time + self.prefs.sync_lyrics_time_offset/1000
				self.pctl.play()
				self.scroll_position += (line_number-self.line_active)*self.yy

			if self.button("x", self.x_posns[0], y_pos, self.font, active_bg=self.colours.level_red, tooltip=_("Delete timestamp"))[0]:
				stamp = "??:??.??"
				time = -1.0
				full_line = ( stamp, time, line )
				self.structure[line_number] = full_line
		else:
			self.button(stamp, self.x_posns[1], y_pos, self.font, tooltip=_("Timestamp unknown"))

		self.line_edit_box.text = line
		temp_text = self.line_edit_box.text # so we don't delete lines a frame early
		if self.big_paste:
			self.line_edit_box.text = self.line_edit_box.text[ : -self.line_edit_box.cursor_position ]
			self.big_paste = False

		if self.cursor is not None:
			self.line_edit_box.cursor_position =  len(self.line_edit_box.text) - self.cursor
			self.line_edit_box.cursor_position = max( self.line_edit_box.cursor_position, 0 )
			self.line_edit_box.selection = self.line_edit_box.cursor_position
			self.cursor = None
		self.line_edit_box.cursor_position = min( self.line_edit_box.cursor_position, len(self.line_edit_box.text) )
		self.line_edit_box.selection = min( self.line_edit_box.selection, len(self.line_edit_box.text) )
		if self.editing_line != line_number:
			self.line_edit_box.selection = self.line_edit_box.cursor_position
			self.editing_line = line_number

		if self.inp.key_lalt or self.inp.key_ralt:
			self.scroll_timestamp(line_number)
			stamp, time, line = self.structure[line_number]
			temp = self.structure[line_number]
			self.inp.key_left_press = False
			self.inp.key_right_press = False
			self.inp.mouse_wheel = 0

		height = self.ddt.get_text_w("?", self.font, True)
		x = round( (self.x_posns[3]-self.x_posns[2]) * 0.9 ) + 7*self.gui.scale

		back_cursor = self.line_edit_box.cursor_position
		front_cursor = len( self.line_edit_box.text ) - self.line_edit_box.cursor_position
		rect = (self.x_posns[2]-height/4, y_pos-height/4, x, height)
		self.ddt.bordered_rect( rect, self.colours.box_background, self.colours.box_text_border, round(1 * self.gui.scale) )
		self.line_edit_box.draw(
			self.x_posns[2], y_pos, self.colours.box_input_text, True,
			font = self.font, width = ( x ), big=True
		)
		line = self.line_edit_box.text
		full_line = ( stamp, time, line )
		self.structure[line_number] = full_line
		position = len( self.line_edit_box.text ) - self.line_edit_box.cursor_position

		# DELETE LINE BUTTON
		x += self.x_posns[2]-height/4 + round(12*self.gui.scale)
		x = min( x, round( self.window_size[0]-self.yy - 12*self.gui.scale ) )
		if self.button("x", x, y_pos, self.font, active_bg=self.colours.level_red, tooltip=_("Delete line"))[0]:# or (self.inp.key_backspace_press and temp_text==""):
			del self.structure[line_number]
			if line_number >= self.line_active:
				if (self.inp.key_lalt or self.inp.key_ralt):
					self.scroll_position += self.yy
			elif not (self.inp.key_lalt or self.inp.key_ralt):
				self.scroll_position -= self.yy
			self.allow_scroll = False
		x += round(30*self.gui.scale)
		x = min( x, self.window_size[0]-self.line_height*0.9)

		# ADD LINE BUTTON
		if self.button("+", x, y_pos, self.font, active_bg=self.colours.level_green, tooltip=_("Add line"))[0]:
			if self.inp.key_lalt or self.inp.key_ralt: # hold alt to make a new line above
				self.structure.insert(line_number, ("??:??.??",-1.0,""))
				if line_number <= self.line_active:
					self.scroll_position += self.yy
			else:
				self.structure.insert(line_number+1, ("??:??.??",-1.0,""))
				if line_number >= self.line_active:
					self.scroll_position -= self.yy
			self.allow_scroll = False

		# advanced text editing
		if "\n" in self.line_edit_box.text: # can only happen if user pastes multi line string
			self.accept_paste(line_number)

		rect = (self.x_posns[2]-height/4, y_pos-height/4, x, self.yy)
		if self.coll(rect):
			position = len( self.line_edit_box.text ) - self.line_edit_box.cursor_position
			# ENTER
			if self.inp.key_return_press:
				if self.inp.key_lalt or self.inp.key_ralt:
					self.structure.insert(line_number, ("??:??.??",-1.0,""))
				else:
					line_one = self.line_edit_box.text[:position]
					line_two = self.line_edit_box.text[position:]
					self.structure[line_number] = (stamp, time, line_one)
					self.structure.insert(line_number+1, ("??:??.??",-1.0,line_two))
					if line_number >= self.line_active:
						self.scroll_position -= self.yy
					self.cursor = 0

			# BACKSPACE
			elif self.inp.key_backspace_press and position==0 and line_number >= 1 and temp_text == self.line_edit_box.text:
				p_stamp, p_time, p_line = self.structure[line_number-1]
				self.structure[line_number-1] = (p_stamp, p_time, (p_line + self.line_edit_box.text))
				del self.structure[line_number]
				if line_number > self.line_active:
					self.scroll_position += self.yy
				#self.inp.key_backspace_press = False
				self.line_edit_box.cursor_position = len(self.line_edit_box.text)
				self.line_edit_box.selection = self.line_edit_box.cursor_position
				#self.cursor = position

			# DELETE
			elif self.inp.key_del and self.line_edit_box.cursor_position==0 and line_number+1<len(self.structure) and temp_text == self.line_edit_box.text:
				p_stamp, p_time, p_line = self.structure[line_number+1]
				self.structure[line_number] = (stamp, time, (self.line_edit_box.text + p_line))
				del self.structure[line_number+1]
				self.line_edit_box.cursor_position += len(p_line)
				self.line_edit_box.selection = self.line_edit_box.cursor_position
				self.inp.key_del = False
				self.cursor = position
				if line_number < self.line_active:
					self.scroll_position -= self.yy

			# ARROW KEYS
			elif self.inp.key_up_press and line_number > 0:
				self.scroll_position += self.yy
				self.cursor = position
			elif self.inp.key_down_press and line_number+1 < len(self.structure):
				self.scroll_position -= self.yy
				self.cursor = position
			elif self.inp.key_left_press and line_number > 0 and front_cursor == 0:
				self.scroll_position += self.yy
				self.line_edit_box.cursor_position = 0
				self.line_edit_box.selection = self.line_edit_box.cursor_position
			elif self.inp.key_right_press and line_number+1 < len(self.structure) and back_cursor == 0:
				self.scroll_position -= self.yy
				self.cursor = 0

		if len(self.line_edit_box.text) == 0:
			pass
		#self.inp.key_backspace_press = False

		if len(self.structure) == 0:
			self.structure = [ ("??:??.??",-1.0,"") ]

		if line_number == len(self.structure) or temp != self.structure[line_number]:
			self.autosave_timer.set()
			self.autosaved = False

	def synced_render(self, index: int, x: int, y: int, hide_art: bool = False, w: int = 0, h: int = 0) -> None:
		line_ys: list[ tuple[ tuple[ int, int ], float ] | None ] = []
		# saves collider positions alongside their respective lines

		# pause after timestamp edit preview
		if ( self.check_timer.get() > 1.0 or \
		( (self.inp.key_lalt or self.inp.key_ralt) and ( self.inp.mouse_wheel or self.inp.key_left_press or self.inp.key_right_press ) ) ) \
		and self.pctl.playing_state == PlayingState.PLAYING and self.check:
			self.pctl.pause_only()
			self.pctl.decode_time = self.structure[self.check_line][1]
			self.check = None
		elif self.check:
			self.inp.key_left_press = False
			self.inp.key_right_press = False

		# scroll
		if self.inp.mouse_wheel and not (self.inp.key_lalt or self.inp.key_ralt):
			scroll_distance = self.scroll.scroll("timed lyrics", 30*self.gui.scale)
			self.scroll_position += scroll_distance
			self.recenter_timeout.set()

		highlight = True

		scroll_to = 0
		bg = self.colours.lyrics_panel_background
		spacing = round(10 * self.gui.scale)
		y_center = self.window_size[1]/2

		# reset scroll position after 5 seconds
		if self.recenter_timeout.get() > 5 and self.pctl.playing_state == PlayingState.PLAYING:
			self.scroll_position = 0

		test_time = self.tauon.get_real_time()

		# determine active lyric
		if self.pctl.track_queue[self.pctl.queue_step] == index and self.allow_scroll:
			self.line_active = -1
			last = 0
			has_timed = 0
			for i, line in enumerate(self.structure):
				if line[0] == "tag":
					last = i
					continue

				if 0 <= line[1] < test_time:
					has_timed = i
					last = i

				if line[1] >= test_time:
					self.pctl.wake_past_time = line[1]
					self.line_active = last
					has_timed = i
					break
			else:
				self.line_active = has_timed

		# record line heights so we can perfectly center the active lyric
		if self.temp_scale != self.gui.scale or self.temp_w != w:
			self.scroll_position = scroll_to
			self.temp_scale = self.gui.scale
			self.temp_w = w
			self.line_height = self.ddt.get_text_w("?", self.font, True)
			self.yy = self.line_height + spacing

		# don't autoscroll if the new active line is not visible
		if ( self.scroll_position > self.window_size[1]/2 or self.scroll_position < -self.window_size[1]/2 ) \
		and self.temp_line != self.line_active and self.allow_scroll:
			self.scroll_position += self.yy
			self.temp_line = self.line_active

		# scroll boundaries
		if self.allow_scroll:
			self.scroll_position = min( self.scroll_position,  self.line_active*(self.yy) + self.window_size[1]/2 -(self.yy + self.gui.panelBY) )
			self.scroll_position = max( self.scroll_position, -(len(self.structure)-self.line_active)*(self.yy) - self.window_size[1]/2 +(self.yy + self.gui.panelY) )
		self.allow_scroll = True

		center = y_center + self.scroll_position
		# scroll position refers to y offset (in pixels) from the active lyric



		# RENDER LINES
		prev = 0.0
		w = round( (self.x_posns[3]-self.x_posns[2]) * 0.9 )
		location = [ self.x_posns[2], 0 ]
		collider_width = self.x_posns[3] - self.x_posns[4]
		if hide_art:
			maximum_y = self.window_size[1]-self.gui.panelBY-35*self.gui.scale
		else:
			maximum_y = self.window_size[1]
		for i, line in enumerate(self.structure):
			# determine y val
			possible_y = center + self.yy*(i-self.line_active)

			if possible_y > 0 and possible_y+self.line_height/2 < maximum_y:
				colour = self.colours.lyrics

				if i < self.line_active:
					prev = max( prev, line[1] )

				if i == self.line_active and highlight and test_time >= line[1]:
					colour = self.colours.active_lyric

				location[1] = round(possible_y)
				text = line[2]
				if text.rstrip() == "":
					text = "♪♪♪"
				# see t_draw.py -> __draw_text_cairo -> line that says #Hack
				self.ddt.text(location, text, colour, self.font, w, bg) # line
				location[0] = self.x_posns[1]
				self.ddt.text(location, line[0], colour, self.font, 100 * self.gui.scale, bg) # timestamp
				location[0] = self.x_posns[2]

				collider = ( round(possible_y), round(possible_y + self.yy) )
				association = collider, line[1]
				line_ys.append( association )
				self.tauon.fields.add( (self.x_posns[4], round(possible_y-0.25*self.line_height), collider_width, self.yy) )
			else:
				line_ys.append( None )



		# CLICK LINE TO SEEK or LET USER EDIT LINE
		did_one_line = False
		if not (self.gui.box_over or self.tauon.pref_box.enabled or self.box_open):
			self.gui.timed_lyrics_editing_now = False
			# click a lyric to seek to it
			if self.x_posns[4] < self.inp.mouse_position[0] < self.x_posns[3]:
				if self.pctl.playing_state == PlayingState.PLAYING:
					if self.gui.panelY < self.inp.mouse_position[1] < self.window_size[1] - self.gui.panelBY \
					and (not h or y < self.inp.mouse_position[1] < y+h):
						for i, rendered_line in enumerate(line_ys):
							if rendered_line is None:
								continue
							if rendered_line[0][0] < self.inp.mouse_position[1] < rendered_line[0][1]:
								if self.inp.mouse_click and rendered_line[1] != -1.0:
									self.pctl.seek_time(rendered_line[1] + self.prefs.sync_lyrics_time_offset/1000)
									self.scroll_position = scroll_to
								elif self.inp.middle_click:
									if rendered_line[1] != -1.0:
										self.structure[i] = ( "??:??.??", -1.0, self.structure[i][2] )
									else:
										self.line_active = i
										self.time_next_line(True)
								break

				elif (self.window_size[1]-self.gui.panelBY < self.inp.mouse_position[1] or self.inp.mouse_position[1] < self.gui.panelY) or \
					(line_ys[0] is not None and line_ys[0][0][0]-0.25*self.line_height > self.inp.mouse_position[1]) or \
					(line_ys[ len(line_ys)-1 ] is not None and line_ys[ len(line_ys)-1 ][0][0]+0.75*self.line_height < self.inp.mouse_position[1]):
					pass
					# if self.check:
					# 	self.check = False # if mouse is below or above relevant area, or first line is visible and mouse is above it, or last line is visible and mouse is below
					# i'm only guessing this is more efficient than not doing it
				else:
					self.queue_next_frame = True
					self.gui.timed_lyrics_editing_now = True
					for i, rendered_line in enumerate(line_ys):
						if rendered_line is None:
							continue
						if (rendered_line[0][0]-0.25*self.line_height) < self.inp.mouse_position[1] < (rendered_line[0][1]-0.25*self.line_height):
							self.settings_for_one_line(i, rendered_line[0][0])
							did_one_line = True

			# KEYBOARD SHORTCUTS
			if not did_one_line: # self.pctl.playing_state != PlayingState.PLAYING and
				if (self.inp.key_lalt or self.inp.key_ralt):
					self.scroll_timestamp(self.line_active)

			if (self.inp.key_lalt or self.inp.key_ralt) and (self.inp.key_up_press or self.inp.key_down_press):
				if self.inp.key_up_press and self.line_active == 0:
					pass
				elif self.inp.key_down_press and self.line_active == len(self.structure) -1:
					pass
				elif self.structure[self.line_active - self.inp.key_up_press + self.inp.key_down_press][1] < 0:
					pass
				elif self.pctl.playing_state == PlayingState.PLAYING:
					self.pctl.seek_time(self.structure[self.line_active - self.inp.key_up_press + self.inp.key_down_press][1] + self.prefs.sync_lyrics_time_offset/1000)
					self.line_active -= self.inp.key_up_press - self.inp.key_down_press
					self.alted = True
				elif self.pctl.playing_state != PlayingState.STOPPED:
					self.pctl.decode_time = self.structure[self.line_active - self.inp.key_up_press + self.inp.key_down_press][1] + self.prefs.sync_lyrics_time_offset/1000
					self.pctl.new_time = self.pctl.decode_time
					self.pctl.playing_time = self.pctl.decode_time
					_seek = self.tauon.aud.seek(int((self.pctl.decode_time) * 1000), self.prefs.pa_fast_seek)
					self.alted = True
			elif self.alt_timer.get() < 0.3 and not self.alted and not (self.inp.key_lalt or self.inp.key_ralt) and self.structure[self.line_active][1] >= 0:
				if self.pctl.playing_state == PlayingState.PLAYING:
					self.pctl.seek_time(self.structure[self.line_active][1] + self.prefs.sync_lyrics_time_offset/1000)
					self.line_active -= self.inp.key_up_press - self.inp.key_down_press
				elif self.pctl.playing_state != PlayingState.STOPPED:
					self.pctl.decode_time = self.structure[self.line_active][1] + self.prefs.sync_lyrics_time_offset/1000
					self.pctl.new_time = self.pctl.decode_time
					self.pctl.playing_time = self.pctl.decode_time
					_seek = self.tauon.aud.seek(int((self.pctl.decode_time) * 1000), self.prefs.pa_fast_seek)
				self.alted = True
			elif self.alt_timer.get() > 0.3:
				self.alted = False

			if (self.inp.key_lalt or self.inp.key_ralt):
				self.alt_timer.set()

			# ctrl s to save
			if (self.inp.key_ctrl_down or self.inp.key_rctrl_down) and self.inp.key_s_press:
				self.save()
				self.inp.key_s_press = False



			# BUTTONS IN THE CORNER
			widths = [
				self.ddt.get_text_w("≪5", self.font),
				self.ddt.get_text_w(_("Previous"), self.font),
				max( self.ddt.get_text_w(_("TIME"), self.font), self.ddt.get_text_w(_("ADD TIME"), self.font), self.ddt.get_text_w(_("CURRENT"), self.font)),
				self.ddt.get_text_w(_("SAVE"), self.font),
				self.ddt.get_text_w(_("Save to .lrc"), self.font),
				self.ddt.get_text_w(_("Save to tags"), self.font),
				self.ddt.get_text_w(_("Discard"), self.font),
				self.ddt.get_text_w("   ", self.font)
			]
			if hide_art:
				buttons_y = self.window_size[1]-self.gui.panelBY-20*self.gui.scale
				buttons_x = 10*self.gui.scale
				x_gap = 18*self.gui.scale #min( self.yy, (self.window_size[0]-sum(widths))/4 )
			else:
				buttons_y = self.window_size[1]-self.gui.panelBY-35*self.gui.scale
				buttons_x = 25*self.gui.scale
				x_gap = self.yy
			save_gap = round(12 * self.gui.scale)

			# DELETE LINES WHILE PLAYING
			if self.inp.key_del and self.pctl.playing_state == PlayingState.PLAYING:
				self.inp.key_del = False
				if self.inp.key_lalt or self.inp.key_ralt:
					del self.structure[self.line_active]
					if self.line_active == len(self.structure):
						self.line_active -= 1
				elif self.line_active+1 <= len(self.structure)-1:
					del self.structure[self.line_active+1]
				if not self.structure:
					self.structure = [("??:??.??", -1.0, "")]
					self.line_active = max(0, self.line_active)
				self.queue_next_frame = True

			# SWITCH MODES
			if self.button("   ", buttons_x, buttons_y, self.font, tooltip="Go to Unsynced View")[0]:
				self.view_is_synced = False
				self.queue_next_frame = True
			self.synced_img.render(buttons_x-6*self.gui.scale, buttons_y-6*self.gui.scale, self.colours.box_button_text)
			buttons_x += widths[7] + x_gap

			# SAVE AND DISCARD
			gn = copy.deepcopy(self.colours.level_green)
			gn.a = round(gn.a * 0.3)
			if self.button( _("SAVE"), buttons_x, buttons_y, self.font, gn, self.colours.level_green)[0]:
				self.save()
			buttons_x += widths[3] + save_gap

			if self.button(_("Save to .lrc"), buttons_x, buttons_y, self.font, gn, self.colours.level_green)[0]:
				self.save(save_to_lrc=True, save_to_tags=False)
			buttons_x += widths[4] + save_gap

			if self.button(_("Save to tags"), buttons_x, buttons_y, self.font, gn, self.colours.level_green)[0]:
				self.save(save_to_lrc=False, save_to_tags=True)
			buttons_x += widths[5] + save_gap

			rd = copy.deepcopy(self.colours.level_red)
			rd.a = round(rd.a * 0.3)
			if self.button(_("Discard"), buttons_x, buttons_y, self.font, rd, self.colours.level_red)[0]:
				self.structurize_current(self.pctl.master_library[self.struct_track])
			buttons_x += widths[6] + x_gap

			if not hide_art:
				btx_top: float = 25*self.gui.scale
				bty_top = buttons_y-self.yy-10*self.gui.scale
			else:
				btx_top = buttons_x
				bty_top = buttons_y

			# BACK 5 AND PREVIOUS
			if self.button("≪5", btx_top, bty_top, self.font)[0]:
				self.previous( max(test_time-5, 0) )
			btx_top += widths[0] + x_gap

			if self.button(_("Previous"), btx_top, bty_top, self.font,
				off = ( not prev))[0]:
				self.previous(prev)
			btx_top += widths[1] + x_gap

			# TIME or CURRENT or ADD TIME: click button or go to previous
			if self.inp.key_lalt or self.inp.key_ralt:
				text = _("CURRENT")
				time_width = self.ddt.get_text_w(_("CURRENT"), self.font)
			elif self.inp.key_ctrl_down or self.inp.key_rctrl_down:
				text = _("ADD TIME")
				time_width = self.ddt.get_text_w(_("ADD TIME"), self.font)
			else:
				text = _("TIME")
				time_width = self.ddt.get_text_w(_("TIME"), self.font)
			width = self.ddt.get_text_w(text, self.font)
			if hide_art:
				x_pos = btx_top + (widths[2] - width)/2
			else:
				x_pos = btx_top

			match self.button(text, x_pos, bty_top, self.font,
				off=self.pctl.playing_state!=PlayingState.PLAYING or not (len(self.structure)>=self.line_active or self.structure[self.line_active][1]<0) )[0]:
				case True:
					self.time_next_line(self.inp.key_lalt or self.inp.key_ralt)
				case False:
					self.previous( max(prev, self.pctl.decode_time-5, 0) )
				case None:
					if not (self.pctl.playing_state!=PlayingState.PLAYING or not (len(self.structure)>=self.line_active or self.structure[self.line_active][1]<0)):
						if self.inp.key_return_press:
							self.time_next_line(self.inp.key_lalt or self.inp.key_ralt)
						elif self.inp.key_backspace_press:
							self.previous( max(test_time-5, 0, prev) )

			if btx_top + widths[2] + x_gap + max( self.ddt.get_text_w(_("Searching..."), self.font), self.ddt.get_text_w(_("Errored"), self.font) ) > self.window_size[0]:
				btx_top = (25 - 15*hide_art) * self.gui.scale
				bty_top -= self.yy+10*self.gui.scale
			else:
				btx_top += time_width + x_gap -7*self.gui.scale
			match self.tauon.now_searching:
				case "off":
					pass
				case "searching":
					self.ddt.text([btx_top,bty_top],_("Searching..."), self.colours.lyrics, self.font)
				case "errored":
					if self.text_timer.get() > 10:
						self.text_timer.set()
						self.ddt.text([btx_top,bty_top],_("Failed"), self.colours.level_yellow, self.font)
					elif self.text_timer.get() < 2: # display error text for 2 seconds
						self.ddt.text([btx_top,bty_top],_("Failed"), self.colours.level_yellow, self.font)
					else:
						self.tauon.now_searching = "off"
				case "success":
					index = self.pctl.track_queue[self.pctl.queue_step]
					track = self.pctl.master_library[index]
					self.structurize_current(track)
					self.tauon.now_searching = "off"

			if self.coll((0,0,self.window_size[0],self.window_size[1])): # DIRTY - always refresh if cursor is on window
				pass

		# end of stuff blocked by boxes being open

		if self.prefs.synced_lyrics_editor_track_end_mode == "stop" or (self.prefs.synced_lyrics_editor_track_end_mode == "repeat" and self.pctl.playing_state == PlayingState.PLAYING):
			if self.pctl.playing_length - self.pctl.decode_time < 5.5:
				self.queue_next_frame = True
			if self.pctl.playing_length - self.pctl.decode_time < 2.1:
				if self.prefs.synced_lyrics_editor_track_end_mode == "stop":
					self.pctl.stop()
				elif not self.repeat_mode: # repeat
					self.repeat_mode = [ self.pctl.repeat_mode, self.pctl.album_repeat_mode ]
					self.pctl.repeat_mode = True
					self.pctl.album_repeat_mode = False


		if self.autosave_timer.get() > 5 and not self.autosaved:
			self.autosave()

		if self.pctl.playing_state == PlayingState.PLAYING:
			self.pausing = True
		else:
			self.pausing = False


	# UNSYNCED EDITING FUNCTIONS

	def edit_static(self) -> None:
		track_object = self.pctl.master_library[self.struct_track]
		target = Path( self.tauon.config_directory / "lyrics-editor" / str(self.struct_track)).with_suffix(".txt")
		if not target.parent.is_dir():
			target.parent.mkdir()
		with open(target, "w", encoding="utf-8") as lyrics_file:
			if self.text:
				lyrics_file.write( self.text )
			else:
				lyrics_file.write( _("Put the lyrics in this file and save it."))
		if self.tauon.windows:
			os.startfile(target)
		elif self.tauon.macos:
			subprocess.call(["open", "-t", target])
		else:
			subprocess.call(["xdg-open", target])


	def reload_lyric_file(self) -> None:
		track = self.pctl.master_library[self.struct_track]
		target = Path( self.tauon.config_directory / "lyrics-editor" / str( self.struct_track )).with_suffix(".txt")
		with open(target, encoding="utf-8-sig", errors="replace") as lyric_file:
			new_lyrics = lyric_file.read().strip()
		track = self.pctl.master_library[self.struct_track]
		if not new_lyrics == _("Put the lyrics in this file and save it."):
			if not new_lyrics == track.lyrics:
				self.text = new_lyrics
				if self.inp.key_lalt or self.inp.key_ralt:
					track.lyrics = new_lyrics
				# self.tauon.write_lyrics(track)
		#self.test_update()
		target.unlink()
		self.queue_next_frame = True


	def test_update(self) -> None:
		track_object = self.pctl.master_library[self.struct_track]
		LRC_tags = "[ti:", "[ar:", "[al:", "[au:", "[lr:", "[length:", "[by:", "[offset:", "[re:", "[tool:", "[ve:", "[#:"
		self.text = ""

		def is_int(number: str) -> bool:
			try:
				int(number)
				return True
			except ValueError:
				return False

		for line in track_object.lyrics.split("\n"):
			if any(tag in line for tag in LRC_tags):
				continue
			if len(line) >= 10 and line[0] == "[" and ":" in line[:10] \
			and "." in line[:10] and "]" in line and is_int(line[1]):
				pos = line.index("]")+1
				self.text += line[pos:] + "\n"
			else:
				self.text += line + "\n"


	def unsynced_render(self, x: int, y: float, box: float, hide_art: bool) -> None:
		colour = self.colours.lyrics
		bg = self.colours.lyrics_panel_background


		x += box + int(self.window_size[0] * 0.15) + 10 * self.gui.scale
		x -= 100 * self.gui.scale
		w = self.window_size[0] - x - 30 * self.gui.scale
		h = int(self.window_size[1] - 100 * self.gui.scale)
		offset = 20*self.gui.scale

		old_pos = self.lyrics_position
		if self.inp.key_up_press:
			self.lyrics_position += 35 * self.gui.scale
		if self.inp.key_down_press:
			self.lyrics_position -= 35 * self.gui.scale
		if self.inp.mouse_wheel:
			self.lyrics_position += self.scroll.scroll("timed lyrics", 30*self.gui.scale)
		self.queue_next_frame = self.queue_next_frame or old_pos != self.lyrics_position

		tw, th = self.ddt.get_text_wh(self.text + "\n", self.font, w, True)

		self.lyrics_position = max(self.lyrics_position, th * -1 + 100 * self.gui.scale)
		self.lyrics_position = min(self.lyrics_position, 70 * self.gui.scale)
		self.ddt.text((x, y + self.lyrics_position, 4, w), self.text, colour, self.font, w, bg)


		widths = [
			self.ddt.get_text_w("   ", self.font),
			self.ddt.get_text_w(_("Edit Lyrics"), self.font),
			self.ddt.get_text_w(_("Read Back"), self.font),
			self.ddt.get_text_w(_("Cancel"), self.font),
			self.ddt.get_text_w(_("SAVE"), self.font),
			self.ddt.get_text_w(_("Save to tags"), self.font),
			self.ddt.get_text_w(_("Discard"), self.font),
			self.ddt.get_text_w(_("Reopen Editor"), self.font)
		]
		if hide_art:
			buttons_y = self.window_size[1]-self.gui.panelBY-20*self.gui.scale
			buttons_x = 10*self.gui.scale
			x_gap = 18*self.gui.scale #min( self.yy, (self.window_size[0]-sum(widths))/4 )
		else:
			buttons_y = self.window_size[1]-self.gui.panelBY-35*self.gui.scale
			buttons_x = 25*self.gui.scale
			x_gap = self.yy
		save_gap = round(12 * self.gui.scale)

		lyric_file = Path( self.tauon.config_directory / "lyrics-editor" / str( self.pctl.track_queue[self.pctl.queue_step] )).with_suffix(".txt")
		can_load = lyric_file.is_file()

		if self.button("   ", buttons_x, buttons_y, self.font, tooltip="Go to Synced View")[0]:
			self.view_is_synced = True
		self.unsynced_img.render(buttons_x-6*self.gui.scale, buttons_y-6*self.gui.scale, self.colours.box_button_text)
		buttons_x += widths[0] + x_gap

		# SAVE AND DISCARD
		gn = copy.deepcopy(self.colours.level_green)
		gn.a = round(gn.a * 0.3)
		if self.button( _("SAVE"), buttons_x, buttons_y, self.font, gn, self.colours.level_green)[0]:
			self.save(False)
		buttons_x += widths[4] + save_gap

		if self.button(_("Save to tags"), buttons_x, buttons_y, self.font, gn, self.colours.level_green)[0]:
			self.save(False, save_to_tags=True)
		buttons_x += widths[5] + save_gap

		rd = copy.deepcopy(self.colours.level_red)
		rd.a = round(rd.a * 0.3)
		if self.button(_("Discard"), buttons_x, buttons_y, self.font, rd, self.colours.level_red)[0]:
			self.structurize_current(self.pctl.master_library[self.struct_track])
		buttons_x += widths[6] + x_gap

		if not hide_art:
			btx_top = 25*self.gui.scale
			bty_top = buttons_y-self.yy-10*self.gui.scale
		else:
			btx_top = buttons_x
			bty_top = buttons_y

		if self.button(_("Edit Lyrics"), btx_top, bty_top, self.font, tooltip=_("Opens an external editor."))[0]:
			self.edit_static()
		btx_top += widths[1] + x_gap -7*self.gui.scale

		match self.tauon.now_searching:
			case "off":
				pass
			case "searching":
				self.ddt.text([btx_top,bty_top],_("Searching..."), self.colours.lyrics, self.font)
			case "errored":
				if self.text_timer.get() > 10:
					self.text_timer.set()
					self.ddt.text([btx_top,bty_top],_("Failed"), self.colours.level_yellow, self.font)
				elif self.text_timer.get() < 2: # display error text for 2 seconds
					self.ddt.text([btx_top,bty_top],_("Failed"), self.colours.level_yellow, self.font)
				else:
					self.tauon.now_searching = "off"
			case "success":
				self.test_update()
				self.tauon.now_searching = "off"

		if can_load and not self.box_open:
			# measure box height
			box_width = 400*self.gui.scale
			drop_w, text_height = self.ddt.get_text_wh(
					_("Edit the opened lyrics in your text editor, then save the file and click \"Read Back.\""),
					self.font,
					box_width - 2*offset,
					True
				)
			too_wide = widths[2] + widths[3] + widths[6] + 3*offset > box_width
			if too_wide:
				button_height = self.line_height + 28*self.gui.scale + offset/2
			else:
				button_height = self.line_height/2 + 14*self.gui.scale
			box_height = text_height + button_height + offset
			x, y = self.window_size[0]/2, self.window_size[1]/2

			rect = ( x - 0.5*box_width, y - 0.5*box_height, box_width, box_height)
			self.ddt.bordered_rect( rect, self.colours.box_background, self.colours.box_text_border, round(1*self.gui.scale))
			txt = self.colours.box_button_text
			x0 = rect[0] + offset
			y0 = rect[1] + offset
			self.ddt.text( [x0,y0,4,box_width-2*offset], _("Edit the opened lyrics in your text editor, then save the file and click \"Read Back.\""), txt, self.font)

			y0 += text_height - 7*self.gui.scale
			x0 = rect[0] + offset
			if self.button(_("Read Back"), x0, y0, self.font, gn, self.colours.level_green, tooltip=_("Make sure to save your changes."))[0]:
				self.reload_lyric_file()
			x0 += offset + widths[2]
			if self.button(_("Cancel"), x0, y0, self.font, rd, self.colours.level_red, tooltip=_("Delete the file."))[0]:
				lyric_file.unlink()
			if too_wide:
				x0 = rect[0] + offset/2
				y0 += self.line_height/2 + 14*self.gui.scale + offset
			else:
				x0 += offset + widths[3]
			if self.button(_("Reopen Editor"), x0, y0, self.font, tooltip=_("In case you closed it by accident."))[0]:
				if self.tauon.windows:
					os.startfile(lyric_file)
				elif self.tauon.macos:
					subprocess.call(["open", "-t", lyric_file])
				else:
					subprocess.call(["xdg-open", lyric_file])




	def render(self) -> None:
		box = int(self.window_size[1] * 0.4 + 120 * self.gui.scale)
		box = min(self.window_size[0] // 2, box)

		hide_art = False
		if self.window_size[0] < 1200 * self.gui.scale:
			hide_art = True

		self.queue_next_frame = False
		index = self.pctl.track_queue[self.pctl.queue_step]
		track = self.pctl.master_library[index]
		if not self.structure or self.struct_track != index:
			if self.struct_track != index and self.struct_track != -1 and self.continuous and 0 < self.track_time_left < 5.0:
				match self.prefs.synced_lyrics_editor_track_end_mode:
					case "autosave":
						self.autosave()
					case "full save":
						self.save()
			self.structurize_current(track)
			self.test_update()
			self.lyrics_position = 0
			self.continuous = True
			self.clicks = 0
		if self.pctl.decode_time < 1.0 and len(self.repeat_mode) == 2:
			self.pctl.repeat_mode, self.pctl.album_repeat_mode = self.repeat_mode
			self.repeat_mode = []

		self.track_time_left = self.pctl.playing_length - self.pctl.decode_time

		if self.gui.lyrics_editor_update_now[0]:
			self.test_update()
		if self.gui.lyrics_editor_update_now[1]:
			self.structurize_current(track)
		self.gui.lyrics_editor_update_now = [False, False]

		x = int(self.window_size[0] * 0.05)
		y = int((self.window_size[1] / 2) - (box / 2)) - 10 * self.gui.scale

		if hide_art:
			box = 45 * self.gui.scale
		elif self.window_size[1] / self.window_size[0] > 0.7:
			x = int(self.window_size[0] * 0.07)

		bbg = rgb_add_hls(self.colours.lyrics_panel_background, 0, 0.05, 0)  # [255, 255, 255, 18]
		bfg = rgb_add_hls(self.colours.lyrics_panel_background, 0, 0.09, 0)  # [255, 255, 255, 30]
		bft = self.colours.grey(235)
		bbt = self.colours.grey(200)

		t1 = self.colours.grey(250)

		self.gui.vis_4_colour = None
		light_mode = False
		if self.colours.lm:
			bbg = self.colours.vis_colour
			bfg = alpha_blend(ColourRGBA(255, 255, 255, 60), self.colours.vis_colour)
			bft = self.colours.grey(250)
			bbt = self.colours.grey(245)

		if test_lumi(self.colours.lyrics_panel_background) < 0.7:
			light_mode = True
			t1 = self.colours.grey(30)
			self.gui.vis_4_colour = ColourRGBA(40, 40, 40, 255)

		self.ddt.rect((0, self.gui.panelY, self.window_size[0], self.window_size[1] - self.gui.panelY), self.colours.lyrics_panel_background)

		if self.pctl.playing_state == PlayingState.URL_STREAM and not self.tauon.radiobox.dummy_track.title:
			if not self.pctl.tag_meta:
				y = int(self.window_size[1] / 2) - 60 - self.gui.scale
				self.ddt.text((self.window_size[0] // 2, y, 2), self.pctl.url, self.colours.side_bar_line2, 317)
			else:
				w = self.window_size[0] - (x + box) - 30 * self.gui.scale
				x = int((self.window_size[0]) / 2)

				y = int(self.window_size[1] / 2) - 60 - self.gui.scale
				self.ddt.text((x, y, 2), self.pctl.tag_meta, self.colours.side_bar_line1, 216, w)
		else:
			if len(self.pctl.track_queue) < 1:
				self.ddt.alpha_bg = False
				return

			# if self.pctl.draw.button("Return", 20, self.gui.panelY + 5, bg=colours.grey(30)):
			# 	pass

			if self.gui.force_showcase_index >= 0:
				if self.pctl.draw.button(
					_("Playing"), 25 * self.gui.scale, self.gui.panelY + 20 * self.gui.scale, text_highlight_colour=bft,
					text_colour=bbt, background_colour=bbg, background_highlight_colour=bfg):
					self.gui.force_showcase_index = -1
					self.ddt.force_gray = False

			if self.gui.force_showcase_index >= 0:
				index = self.gui.force_showcase_index
				track = self.pctl.master_library[index]
			elif self.pctl.playing_state == PlayingState.URL_STREAM:
				track = self.tauon.radiobox.dummy_track
			else:
				index = self.pctl.track_queue[self.pctl.queue_step]
				track = self.pctl.master_library[index]

			if not hide_art:
				draw_showcase_art_box(self.tauon, track, x, y, box)

			gcx = x + box + int(self.window_size[0] * 0.15) + 10 * self.gui.scale
			gcx -= 50 * self.gui.scale
			w = self.window_size[0] - (x + box) - round(30 * self.gui.scale)
			if self.temp_scale != self.gui.scale or self.temp_w != w or not self.x_posns:
				self.temp_scale = self.gui.scale
				self.temp_w = w
				self.line_height = round(self.ddt.get_text_w("?", self.font, True))
				self.yy = self.line_height + round( 10*self.gui.scale )
				self.x_posns = [ # determines button and text box placement
					round( max( gcx - 130*self.gui.scale, self.line_height/2 ) ),
					round( max( gcx - 100*self.gui.scale, self.yy) ),
					round( gcx ),
					round( max(self.window_size[0] - 90*self.gui.scale, self.window_size[0]*0.98) ),
					round( max( gcx - 145*self.gui.scale, 0 )) ]
				self.x_posns[2] = max(self.x_posns[1] + 90*self.gui.scale, gcx)

			if self.view_is_synced:
				self.synced_render(track.index, gcx, y, hide_art, w)
			else:
				self.unsynced_render(x, y, box, hide_art)

			if self.struct_track in self.potential_uploads:
				upload = self.potential_uploads[self.struct_track]
				if not self.box_open:
					self.upload_synced = self.view_is_synced and upload["syncedLyrics"] != "failed"
					self.upload_static = not self.view_is_synced
				self.box_open = True

				# measure the box height
				box_width = 400*self.gui.scale
				offset = 20*self.gui.scale
				drop_w, text_height = self.ddt.get_text_wh(
						_("Upload lyrics for {artist} - {title}?").format(artist=upload["artistName"], title=upload["trackName"]),
						self.font,
						box_width - 2*offset +12,
						True
					)
				button_height = self.line_height/2 + 14*self.gui.scale
				checkbox_height = 40*self.gui.scale
				box_height = text_height + button_height + checkbox_height + offset



				gn = copy.deepcopy(self.colours.level_yellow)
				gn.a = round(gn.a * 0.3)
				x, y = self.window_size[0]/2, self.window_size[1]/2

				if self.shake_frames:
					shake = (1, 2, 1, 0, -1, -2, -1, 0)
					x += 3*self.gui.scale * shake[ self.shake_frames % 8 ]
					self.shake_frames -= 1
					self.queue_next_frame = True
				rect = ( x - 0.5*box_width, y - 0.5*box_height, box_width, box_height)
				self.ddt.bordered_rect( rect, self.colours.box_background, self.colours.box_text_border, round(1*self.gui.scale))
				txt = self.colours.box_button_text

				x0 = rect[0] + offset
				y0 = rect[1] + offset
				self.ddt.text( [x0,y0,4,box_width-2*offset], _("Upload lyrics for {artist} - {title}?").format(artist=upload["artistName"], title=upload["trackName"]), txt, self.font)
				y0 += text_height

				width = self.ddt.get_text_w(_("Synced"), 13)
				cancel = self.ddt.get_text_w(_("Cancel"), self.font)

				# don't allow user to check synced box if synced lyrics are unusable
				# shake the box to indicate there's a problem, then show the error if they keep going
				old_upload_synced = self.upload_synced
				if self.tauon.pref_box.toggle_square(rect[0]+offset, y0, self.upload_synced, _("Synced"), self.inp.mouse_click):
					new_upload_synced = True
					if upload["syncedLyrics"] != "failed":
						self.upload_synced = True
					else:
						self.shake_frames = 30
						self.queue_next_frame = True
				else:
					new_upload_synced = False
					self.upload_synced = False
				self.clicks += new_upload_synced != old_upload_synced and upload["syncedLyrics"] == "failed"
				if self.clicks > 2:
					self.tauon.show_message(
							_("Lyrics must be formatted correctly"),
							_("Your synced lyrics are either out of order or not fully timed."),
							_("We cannot upload broken lyrics to LRCLIB."),
							mode="error"
						)
					self.clicks = 0

				self.upload_static = self.tauon.pref_box.toggle_square(rect[0]+3*offset+width, y0, self.upload_static, _("Unsynced"), self.inp.mouse_click)
				y0 += checkbox_height - 7*self.gui.scale
				if self.button(_("Upload"), rect[0] + offset, y0, self.font, gn, self.colours.level_yellow, off=not(self.upload_synced or self.upload_static))[0]:
					if not self.upload_synced:
						upload["syncedLyrics"] = ""
					if not self.upload_static:
						upload["plainLyrics"] = ""
					self.tauon.lrclib_uploads.append(upload)
					self.tauon.thread_manager.ready("worker")
					del self.potential_uploads[self.struct_track]
				if self.button(_("Cancel"), rect[0] + rect[2] - cancel - offset, y0, self.font, tooltip=_("Delete the file."))[0] \
					or ( self.inp.mouse_click and not self.coll(rect) ):
					del self.potential_uploads[self.struct_track]
					self.queue_next_frame = True
			else:
				self.box_open = False
				self.clicks = 0

			if self.gui.panelY < self.inp.mouse_position[1] < self.window_size[1] - self.gui.panelBY:
				if self.inp.right_click:
					# track = self.pctl.playing_object()
					if track is not None:
						if self.view_is_synced:
							self.menu.activate(track)
						else:
							self.unsynced_menu.activate(track)

		# queue a frame update if the user moves over or off of any button
		self.gui.pl_update += self.queue_next_frame
		self.ddt.alpha_bg = False
		self.ddt.force_gray = False


@dataclass
class Directories:
	"""Hold directories"""

	install_directory:      Path
	svg_directory:          Path
	asset_directory:        Path
	scaled_asset_directory: Path
	locale_directory:       Path
	user_directory:         Path
	config_directory:       Path
	cache_directory:        Path
	home_directory:         Path
	music_directory:        Path
	download_directory:     Path
	n_cache_directory:      Path
	e_cache_directory:      Path
	g_cache_directory:      Path
	a_cache_directory:      Path
	r_cache_directory:      Path
	b_cache_directory:      Path

@dataclass
class Bag:
	"""Holder object for all configs"""

	mpt:                     CDLL | None
	gme:                     CDLL | None
	cf:                      Config
	colours:                 ColoursClass
	console:                 DConsole
	dirs:                    Directories
	prefs:                   Prefs
	formats:                 Formats
	renderer:                sdl3.LP_SDL_Renderer
	overlay_texture_texture: sdl3.LP_SDL_Texture
	fonts:                   Fonts
	tls_context:             ssl.SSLContext
	dev_mode:                bool
	macos:                   bool
	windows:                 bool
	phone:                   bool
	pump:                    bool
	snap_mode:               bool
	flatpak_mode:            bool
	smtc:                    bool
	draw_min_button:         bool
	draw_max_button:         bool
	last_fm_enable:          bool
	de_notify_support:       bool
	wayland:                 bool
	should_save_state:       bool
	desktop:                 str | None
	launch_prefix:           str
	platform_system:         str
	album_mode_art_size:     int
	master_count:            int
	playing_in_queue:        int
	playlist_playing:        int
	playlist_view_position:  int
	radio_playlist_viewing:  int
	selected_in_playlist:    int
	latest_db_version:       int
	volume:                  float
	log:                     LogHistoryHandler
	mac_close:               ColourRGBA
	mac_maximize:            ColourRGBA
	mac_minimize:            ColourRGBA
	track_queue:             list[int]
	logical_size:            list[int] # X Y
	window_size:             list[int] # X Y
	old_window_position:     tuple[int, int] # X Y res
	cue_list:                list[str]
	download_directories:    list[str]
	multi_playlist:          list[TauonPlaylist]
	radio_playlists:         list[RadioPlaylist]
	primary_stations:        list[RadioStation]
	p_force_queue:           list[TauonQueueItem]
	folder_image_offsets:    dict[str, int]
	gen_codes:               dict[int, str]
	master_library:          dict[int, TrackClass]
	loaded_asset_dc:         dict[str, WhiteModImageAsset | LoadImageAsset]
	default_playlist:        list[int] = field(default_factory=list[int])
	sm:                      CDLL | None = None
	nowplaying:              bool = False
	nowplaying_helper:       MacNowPlayingHelper | None = None
	song_notification:       None = None
	active_playlist_viewing: int = 0
	active_playlist_playing: int = 0
	loaded_stop_mode:        int = 0
	loaded_stop_ref:         tuple[str, str] | None = None

@dataclass
class Formats:
	"""Contains:

	* Colours used for the label icon in UI 'track info box'
	* Extensions of files to be added when importing
	"""

	colours: dict[str, ColourRGBA]
	VID:     set[str]
	MOD:     set[str]
	GME:     set[str]
	DA:      set[str]
	Archive: set[str]


def is_module_loaded(module_name: str, object_name: str = "") -> bool:
	"""Check if a module is loaded, to determine which features we should enable

	See https://stackoverflow.com/a/30483269/8962143 for more details
	"""
	if object_name:
		return module_name in sys.modules and hasattr(sys.modules[module_name], object_name)
	return module_name in sys.modules

def get_cert_path(holder: Holder) -> str:
	if holder.pyinstaller_mode:
		return os.path.join(sys._MEIPASS, "certifi", "cacert.pem")
	# Running as script
	return certifi.where()

def setup_tls(holder: Holder) -> ssl.SSLContext:
	"""TLS setup (needed for frozen installs)

	This function has to be called BEFORE modules that init TLS context are imported or otherwise do so (like pylast - see https://github.com/Taiko2k/Tauon/issues/1442)
	"""
	# Set the TLS certificate path environment variable
	cert_path = get_cert_path(holder)
	logging.debug(f"Found TLS cert file at: {cert_path}")
	os.environ["SSL_CERT_FILE"] = cert_path
	os.environ["REQUESTS_CA_BUNDLE"] = cert_path

	# Create default TLS context
	return ssl.create_default_context(cafile=get_cert_path(holder))

def whicher(target: str, flatpak_mode: bool) -> bool | str | None:
	"""Detect and launch programs outside of flatpak sandbox"""
	try:
		if flatpak_mode:
			complete = subprocess.run(
				shlex.split("flatpak-spawn --host which " + target), stdout=subprocess.PIPE,
					stderr=subprocess.PIPE, check=True)
			r = complete.stdout.decode()
			return "bin/" + target in r
		return shutil.which(target)
	except Exception:
		logging.exception("Failed to run flatpak-spawn")
		return False

def asset_loader(
	bag: Bag, loaded_asset_dc: dict[str, WhiteModImageAsset | LoadImageAsset], name: str, mod: bool = False,
) -> WhiteModImageAsset | LoadImageAsset:
	if name in loaded_asset_dc:
		return loaded_asset_dc[name]

	target = str(bag.dirs.scaled_asset_directory / name)
	if mod:
		item = WhiteModImageAsset(bag=bag, path=target, scale_name=name)
	else:
		item = LoadImageAsset(bag=bag, path=target, scale_name=name)
	loaded_asset_dc[name] = item
	return item

def no_padding() -> int:
	"""This will remove all padding"""
	return 0

def uid_gen() -> int:
	return random.randrange(1, 100000000)

def queue_item_gen(track_id: int, position: int, pl_id: int, queue_type: QueueType = QueueType.TRACK, album_stage: int = 0) -> TauonQueueItem:
	auto_stop = False
	return TauonQueueItem(track_id=track_id, position=position, playlist_id=pl_id, type=queue_type, album_stage=album_stage, uuid_int=uid_gen(), auto_stop=auto_stop)

def get_themes(dirs: Directories, deco: bool = False) -> list[tuple[str, str]] | dict[str, str]:
	themes: list[tuple[str, str]] = []  # full path, theme file name
	decos: dict[str, str] = {}
	direcs = [str(dirs.install_directory / "theme")]
	if dirs.user_directory != dirs.install_directory:
		direcs.append(str(dirs.user_directory / "theme"))

	def scan_folders(folders: list[str]) -> None:
		for folder in folders:
			if not os.path.isdir(folder):
				continue
			paths = [os.path.join(folder, f) for f in os.listdir(folder)]
			for path in paths:
				if os.path.islink(path):
					path = os.readlink(path)
				if os.path.isfile(path):
					if path[-7:] == ".ttheme":
						themes.append((path, os.path.basename(path).split(".")[0]))
					elif path[-6:] == ".tdeco":
						decos[os.path.basename(path).split(".")[0]] = path
				elif os.path.isdir(path):
					scan_folders([path])

	scan_folders(direcs)
	themes.sort()
	if deco:
		return decos
	return themes

def get_theme_number(dirs: Directories, name: str) -> int:
	if name == "Mindaro":
		return 0
	themes = get_themes(dirs)
	for i, theme in enumerate(themes):
		if theme[1] == name:
			return i + 1
	return 0

def get_theme_name(dirs: Directories, number: int) -> str:
	if number == 0:
		return "Mindaro"
	number -= 1
	themes = get_themes(dirs)
	logging.info((number, themes))
	if len(themes) > number:
		return themes[number][1]
	return ""


THEME_EDITOR_COMPONENTS: tuple[tuple[str, tuple[str, ...]], ...] = (
	(_("Window borders"), ("window_frame", "box_border", "box_check_border", "mini_mode_border", "art_box", "gallery_highlight", "box_thumb_background", )),
	(_("Top panel BG"), ("top_panel_background",)),
	(_("Playlist box BG"), ("playlist_box_background",)),
	(_("Queue panel: BG"), ("queue_background",)),
	(_("Gallery panel BG"), ("gallery_background",)),
	(_("Info panel: BG"), ("lyrics_panel_background", "side_panel_background",)),
	(_("Info panel: main text"), ("side_bar_line1", "active_lyric", )),
	(_("Info panel: 2nd text"), ("side_bar_line2", "lyrics", )),
	(_("Artist bio: BG"), ("artist_bio_background",)),
	(_("Artist bio: text"), ("artist_bio_text",)),
	(_("Bottom panel: BG"), ("bottom_panel_colour",)),
	(_("Bottom panel: text"), ("bar_title_text","time_playing",)),
	(_("List: BG"), ("playlist_panel_background",)),
	(_("List: column bar"), ("column_bar_background",)),
	(_("List: column info"), ("column_bar_text", "column_grip",)),
	# (_("List: artist"), ("artist_text",)),
	# (_("List: album"), ("album_text",)),
	# (_("List: duration"), ("bar_time",)),
	# (_("List: other fields"), ("index_text",)),
	(_("List: folder"), ("folder_title","folder_line",)),
	# (_("List: folder line"), ("folder_line",)),
	(_("List: star line"), ("star_line", "star_line_playing", )),
	(_("List: text"), ("title_text","artist_text","album_text","bar_time","index_text",)),
	(_("List: select highlight"), ("row_select_highlight",)),
	(_("List: playing text"), ("title_playing","artist_playing","album_playing","time_text","index_playing",)),
	(_("List: play highlight"), ("row_playing_highlight",)),
	(_("List: missing track"), ("playlist_text_missing",)),
	# (_("List: playing artist"), ("artist_playing",)),
	# (_("List: playing album"), ("album_playing",)),
	# (_("List: playing duration"), ("time_text",)),
	# (_("List: playing other"), ("index_playing",)),
	(_("Seek/volume: fill"), ("seek_bar_fill", "volume_bar_fill", "vis_colour", )),
	(_("Seek/volume: BG"), ("seek_bar_background", "volume_bar_background", "vis_bg", )),
	(_("Scroll bar"), ("scroll_colour",)),
	(_("Buttons: normal"), ("media_buttons_off", "mode_button_off", "status_text_normal", "corner_button", "window_button_icon_off", "window_button_x_off", "menu_icons", )),
	(_("Buttons: hover"), ("media_buttons_over", "mode_button_over", "status_text_over", "window_buttons_icon_over", "window_button_x_on", )),
	(_("Buttons: active"), ("media_buttons_active", "mode_button_active", "corner_button_active", )),
	(_("Text btn: BG"), ("window_buttons_bg", "box_button_background", "tab_background", "menu_tab",)),
	(_("Text btn: text"), ("box_button_text", "tab_text", "link_text", )),
	(_("Text btn: BG hover"), ( "box_button_background_highlight", "tab_highlight", )),
	(_("Text btn: text hover"), ("window_buttons_bg_over", "box_button_text_highlight", )),
	(_("Tab btn: BG active"), ("tab_background_active",)),
	(_("Tab btn: text active"), ("tab_text_active", )),
	(_("Dynamic accents"), ("queue_drag_indicator_colour", "pulse_colour", "queue_card_background",)),
	(_("Context menu: BG"), ("menu_background", )),
	(_("Context menu: text"), ("menu_text", )),
	(_("Context menu: invalid text"), ("menu_text_disabled",)),
	(_("Context menu: highlight"), ("menu_highlight_background",)),
	(_("Boxes: background"), ("box_background","message_box_bg",)),
	(_("Boxes: title text"), ("box_title_text", "box_text_label",)),
	(_("Boxes: body text"), ("box_text", "message_box_text",)),
	(_("Boxes: sub text"), ("box_sub_text",)),
	(_("Boxes: input text"), ("box_input_text",)),
	(_("Mini: BG"), ("mini_mode_background",)),
	(_("Mini: text 1"), ("mini_mode_text_1",)),
	(_("Mini: text 2"), ("mini_mode_text_2",)),
)


def clone_theme_colours(colours: ColoursClass) -> ColoursClass:
	clone = ColoursClass()
	for key, value in colours.__dict__.items():
		setattr(clone, key, copy.deepcopy(value))
	return clone

def get_end_folder(direc: str) -> str | None:
	for w in range(len(direc)):
		if direc[-w - 1] == "\\" or direc[-w - 1] == "/":
			return direc[-w:]
	return None

def set_path(nt: TrackClass, path: str) -> None:
	nt.fullpath = path.replace("\\", "/")
	nt.filename = os.path.basename(path)
	nt.parent_folder_path = os.path.dirname(path.replace("\\", "/"))
	nt.parent_folder_name = get_end_folder(os.path.dirname(path))
	nt.file_ext = os.path.splitext(os.path.basename(path))[1][1:].upper()

# def pumper(bag: Bag) -> None:
# 	if bag.macos:
# 		return
# 	while bag.pump:
# 		time.sleep(0.005)
# 		sdl3.SDL_PumpEvents()

def save_prefs(bag: Bag) -> None:
	cf    = bag.cf
	prefs = bag.prefs
	cf.update_value("sync-bypass-transcode", prefs.bypass_transcode)
	cf.update_value("sync-bypass-low-bitrate", prefs.smart_bypass)
	cf.update_value("radio-record-codec", prefs.radio_record_codec)

	cf.update_value("plex-username", prefs.plex_username)
	cf.update_value("plex-password", prefs.plex_password)
	cf.update_value("plex-servername", prefs.plex_servername)

	cf.update_value("subsonic-username", prefs.subsonic_user)
	cf.update_value("subsonic-password", prefs.subsonic_password)
	cf.update_value("subsonic-password-plain", prefs.subsonic_password_plain)
	cf.update_value("subsonic-server-url", prefs.subsonic_server)

	cf.update_value("jelly-username", prefs.jelly_username)
	cf.update_value("jelly-password", prefs.jelly_password)
	cf.update_value("jelly-server-url", prefs.jelly_server_url)

	cf.update_value("scrobble-jellyfin", prefs.scrobble_jellyfin)
	cf.update_value("scrobble-tidal", prefs.scrobble_tidal)
	cf.update_value("scrobble-subsonic", prefs.scrobble_subsonic)
	cf.update_value("scrobble-plex", prefs.scrobble_plex)

	cf.update_value("stream-bitrate", prefs.network_stream_bitrate)

	cf.update_value("display-language", prefs.ui_lang)
	# cf.update_value("decode-search", prefs.diacritic_search)

	# cf.update_value("use-log-volume-scale", prefs.log_vol)
	# cf.update_value("audio-backend", prefs.backend)
	cf.update_value("use-pipewire", prefs.pipewire)
	cf.update_value("seek-interval", prefs.seek_interval)
	cf.update_value("pause-fade-time", prefs.pause_fade_time)
	cf.update_value("cross-fade-time", prefs.cross_fade_time)
	cf.update_value("device-buffer-ms", prefs.device_buffer)
	cf.update_value("output-samplerate", prefs.samplerate)
	cf.update_value("resample-quality", prefs.resample)
	cf.update_value("avoid_resampling", prefs.avoid_resampling)
	# cf.update_value("fast-scrubbing", prefs.pa_fast_seek)
	cf.update_value("precache-local-files", prefs.precache)
	cf.update_value("cache-use-tmp", prefs.tmp_cache)
	cf.update_value("cache-limit", prefs.cache_limit)
	cf.update_value("network-stream", prefs.network_stream)
	cf.update_value("stream-buffer-size", prefs.stream_buffer)
	cf.update_value("always-ffmpeg", prefs.always_ffmpeg)
	cf.update_value("volume-curve", prefs.volume_power)
	cf.update_value("jump-start-dl", prefs.jump_start)
	# cf.update_value("force-mono", prefs.mono)

	# cf.update_value("gst-output", prefs.gst_output)
	# cf.update_value("gst-use-custom-output", prefs.gst_use_custom_output)

	cf.update_value("separate-multi-genre", prefs.sep_genre_multi)

	cf.update_value("tag-editor-name", prefs.tag_editor_name)
	cf.update_value("tag-editor-target", prefs.tag_editor_target)

	cf.update_value("playback-follow-cursor", prefs.playback_follow_cursor)
	cf.update_value("back-restarts", prefs.back_restarts)
	cf.update_value("resume-on-advance", prefs.resume_on_jump)
	cf.update_value("end-queue-stop", prefs.stop_end_queue)
	cf.update_value("block-suspend", prefs.block_suspend)
	cf.update_value("allow-video-formats", prefs.allow_video_formats)

	cf.update_value("ui-scale", prefs.scale_want)
	cf.update_value("auto-scale", prefs.x_scale)
	cf.update_value("tracklist-y-text-offset", prefs.tracklist_y_text_offset)
	cf.update_value("theme-name", prefs.theme_name)
	cf.update_value("transparent-style", prefs.transparent_mode)
	cf.update_value("rounded-corners", prefs.rounded_corners)
	cf.update_value("rounded-corner-radius", prefs.corner_radius)
	cf.update_value("mac-style", prefs.macstyle)
	cf.update_value("allow-art-zoom", prefs.zoom_art)

	cf.update_value("scroll-gallery-by-row", prefs.gallery_row_scroll)
	cf.update_value("scroll-gallery-distance", prefs.gallery_scroll_wheel_px)
	cf.update_value("scroll-spectrogram", prefs.spec2_scroll)
	cf.update_value("mascot-opacity", prefs.custom_bg_opacity)
	cf.update_value("synced-lyrics-time-offset", prefs.sync_lyrics_time_offset)

	cf.update_value("artist-list-prefers-album-artist", prefs.artist_list_prefer_album_artist)
	cf.update_value("side-panel-info-persists", prefs.meta_persists_stop)
	cf.update_value("side-panel-info-selected", prefs.meta_shows_selected)
	cf.update_value("side-panel-info-selected-always", prefs.meta_shows_selected_always)
	cf.update_value("mini-mode-avoid-notifications", prefs.stop_notifications_mini_mode)
	cf.update_value("hide-queue-when-empty", prefs.hide_queue)
	# cf.update_value("show-playlist-list", prefs.show_playlist_list)
	cf.update_value("enable-art-header-bar", prefs.art_in_top_panel)
	cf.update_value("always-art-header-bar", prefs.always_art_header)
	# cf.update_value("prefer-center-bg", prefs.center_bg)
	cf.update_value("side-panel-style", prefs.side_panel_layout)
	cf.update_value("side-panel-left", prefs.side_panel_left)
	cf.update_value("side-lyrics-art", prefs.show_side_lyrics_art_panel)
	cf.update_value("side-lyrics-art-on-top", prefs.lyric_metadata_panel_top)
	cf.update_value("showcase-wide-art", prefs.showcase_wide_art)
	cf.update_value("absolute-track-indices", prefs.use_absolute_track_index)
	cf.update_value("auto-hide-bottom-title", prefs.hide_bottom_title)
	cf.update_value("auto-show-playing", prefs.auto_goto_playing)
	cf.update_value("notify-include-album", prefs.notify_include_album)
	cf.update_value("show-rating-hint", prefs.rating_playtime_stars)
	cf.update_value("drag-tab-to-unpin", prefs.drag_to_unpin)

	cf.update_value("gallery-thin-borders", prefs.thin_gallery_borders)
	cf.update_value("increase-row-spacing", prefs.increase_gallery_row_spacing)
	cf.update_value("gallery-center-text", prefs.center_gallery_text)

	cf.update_value("use-custom-fonts", prefs.use_custom_fonts)
	cf.update_value("font-main-standard", prefs.linux_font)
	cf.update_value("font-main-medium", prefs.linux_font_semibold)
	cf.update_value("font-main-bold", prefs.linux_font_bold)
	cf.update_value("font-main-condensed", prefs.linux_font_condensed)
	cf.update_value("font-main-condensed-bold", prefs.linux_font_condensed_bold)

	cf.update_value("force-subpixel-text", prefs.force_subpixel_text)

	cf.update_value("double-digit-indices", prefs.dd_index)
	cf.update_value("column-album-artist-fallsback", prefs.column_aa_fallback_artist)
	cf.update_value("left-aligned-album-artist-title", prefs.left_align_album_artist_title)
	cf.update_value("tracklist-scrollbar-left", prefs.tracklist_scrollbar_left)
	cf.update_value("import-auto-sort", prefs.auto_sort)

	cf.update_value("encode-output-dir", prefs.custom_encoder_output)
	cf.update_value("sync-device-music-dir", prefs.sync_target)
	cf.update_value("add_download_directory", prefs.download_dir1)

	cf.update_value("autoscan_playlist_folder", prefs.autoscan_playlist_folder)
	cf.update_value("playlist_folder_path", prefs.playlist_folder_path)

	cf.update_value("save_lyrics_changes_to_files", prefs.save_lyrics_changes_to_files)
	cf.update_value("use_lrc_instead", prefs.save_synced_to_lrc)

	cf.update_value("synced_lyrics_editor_track_end_mode", prefs.synced_lyrics_editor_track_end_mode)

	cf.update_value("use-system-tray", prefs.use_tray)
	cf.update_value("use-gamepad", prefs.use_gamepad)
	cf.update_value("enable-remote-interface", prefs.enable_remote)
	cf.update_value("release-splash-version", prefs.release_splash_version)

	cf.update_value("enable-mpris", prefs.enable_mpris)
	cf.update_value("hide-maximize-button", prefs.force_hide_max_button)
	cf.update_value("restore-window-position", prefs.save_window_position)
	cf.update_value("mini-mode-always-on-top", prefs.mini_mode_on_top)
	cf.update_value("resume-playback-on-restart", prefs.reload_play_state)
	cf.update_value("resume-playback-on-wake", prefs.resume_play_wake)
	cf.update_value("auto-dl-artist-data", prefs.auto_dl_artist_data)

	cf.update_value("fanart.tv-cover", prefs.enable_fanart_cover)
	cf.update_value("fanart.tv-artist", prefs.enable_fanart_artist)
	cf.update_value("fanart.tv-background", prefs.enable_fanart_bg)
	cf.update_value("auto-update-playlists", prefs.always_auto_update_playlists)
	cf.update_value("write-ratings-to-tag", prefs.write_ratings)
	cf.update_value("enable-discord-rpc", prefs.discord_enable)
	cf.update_value("discord-card-layout",         prefs.discord_card_layout)
	cf.update_value("discord-member-list-display",  prefs.discord_member_list_display)
	cf.update_value("discord-clean-title",          prefs.discord_clean_title)
	cf.update_value("discord-lastfm-button",        prefs.discord_lastfm_button)
	cf.update_value("discord-show-tauon-button",    prefs.discord_show_tauon_button)
	cf.update_value("discord-keep-idle",            prefs.discord_keep_idle)
	cf.update_value("auto-search-lyrics", prefs.auto_lyrics)
	cf.update_value("shortcuts-ignore-keymap", prefs.use_scancodes)
	cf.update_value("alpha_key_activate_search", prefs.search_on_letter)
	cf.update_value("taskbar-status", prefs.taskbar_progress)

	cf.update_value("discogs-personal-access-token", prefs.discogs_pat)
	cf.update_value("listenbrainz-token", prefs.lb_token)
	cf.update_value("custom-listenbrainz-url", prefs.listenbrainz_url)

	cf.update_value("maloja-key", prefs.maloja_key)
	cf.update_value("maloja-url", prefs.maloja_url)
	cf.update_value("maloja-enable", prefs.maloja_enable)

	cf.update_value("tau-url", prefs.sat_url)

	cf.update_value("lastfm-pull-love", prefs.lastfm_pull_love)

	cf.update_value("broadcast-page-port", prefs.metadata_page_port)
	cf.update_value("chart-columns", prefs.chart_columns)
	cf.update_value("chart-rows", prefs.chart_rows)
	cf.update_value("chart-uses-text", prefs.chart_text)
	cf.update_value("chart-font", prefs.chart_font)
	cf.update_value("chart-sorts-top-played", prefs.topchart_sorts_played)

	if bag.dirs.config_directory.is_dir():
		cf.dump(str(bag.dirs.config_directory / "tauon.conf"))
	else:
		logging.error("Missing config directory")

def load_prefs(bag: Bag) -> None:
	cf    = bag.cf
	prefs = bag.prefs
	cf.reset()
	config_file = bag.dirs.config_directory / "tauon.conf"
	first_run = not config_file.is_file()
	cf.load(str(config_file))

	cf.add_comment("Tauon Music Box configuration file")
	cf.br()
	cf.add_comment(
		"This file will be regenerated while app is running. Formatting and additional comments will be lost.")
	cf.add_comment("Tip: Use TOML syntax highlighting")

	cf.br()
	cf.add_text("[audio]")

	# prefs.backend = cf.sync_add("int", "audio-backend", prefs.backend, "4: Built in backend (Phazor), 2: GStreamer")
	prefs.pipewire = cf.sync_add(
		"bool", "use-pipewire", prefs.pipewire,
		"Use Pipewire native only.")

	prefs.seek_interval = cf.sync_add(
		"int", "seek-interval", prefs.seek_interval,
		"In s. Interval to seek when using keyboard shortcut. Default is 15.")
	# prefs.pause_fade_time = cf.sync_add("int", "pause-fade-time", prefs.pause_fade_time, "In milliseconds. Default is 400. (GStreamer Only)")

	prefs.pause_fade_time = max(prefs.pause_fade_time, 100)
	prefs.pause_fade_time = min(prefs.pause_fade_time, 5000)

	prefs.cross_fade_time = cf.sync_add(
		"int", "cross-fade-time", prefs.cross_fade_time,
		"In ms. Min: 200, Max: 2000, Default: 700. Applies to track change crossfades. End of track is always gapless.")

	prefs.device_buffer = cf.sync_add("int", "device-buffer-ms", prefs.device_buffer, "Default: 80")
	#prefs.samplerate = cf.sync_add(
	#	"int", "output-samplerate", prefs.samplerate,
	#	"In hz. Default: 48000, alt: 44100. (restart app to apply change)")
	prefs.avoid_resampling = cf.sync_add(
		"bool", "avoid_resampling", prefs.avoid_resampling,
		"Only implemented for FLAC, MP3, OGG, OPUS")
	prefs.resample = cf.sync_add(
		"int", "resample-quality", prefs.resample,
		"0=best, 1=medium, 2=fast, 3=fastest. Default: 1. (applies on restart)")
	if prefs.resample < 0 or prefs.resample > 4:
		prefs.resample = 1
	# prefs.pa_fast_seek = cf.sync_add("bool", "fast-scrubbing", prefs.pa_fast_seek, "Seek without a delay but may cause audible popping")
	prefs.cache_limit = cf.sync_add(
		"int", "cache-limit", prefs.cache_limit,
		"Limit size of network audio file cache. In MB.")
	prefs.network_stream = cf.sync_add(
		"bool", "network-stream", prefs.network_stream,
		"Stream network tracks directly, buffering in memory, instead of downloading the full file first")
	prefs.stream_buffer = cf.sync_add(
		"int", "stream-buffer-size", prefs.stream_buffer,
		"Size of the in-memory file/stream buffer. In MB. Default: 50. (applies on track change)")
	prefs.tmp_cache = cf.sync_add(
		"bool", "cache-use-tmp", prefs.tmp_cache,
		"Use /tmp for cache. When enabled, above setting overridden to a small value. (applies on restart)")
	prefs.precache = cf.sync_add(
		"bool", "precache-local-files", prefs.precache,
		"Cache files from local sources too. (Useful for mounted network drives)")
	prefs.always_ffmpeg = cf.sync_add(
		"bool", "always-ffmpeg", prefs.always_ffmpeg,
		"Prefer decoding using FFmpeg. Fixes stuttering on Raspberry Pi OS.")
	prefs.volume_power = cf.sync_add(
		"int", "volume-curve", prefs.volume_power,
		"1=Linear volume control. Values above one give greater control bias over lower volume range. Default: 2")

	prefs.jump_start = cf.sync_add(
		"bool", "jump-start-dl", prefs.jump_start,
		"Start playing a network track before it has finished downloading")

	# prefs.mono = cf.sync_add("bool", "force-mono", prefs.mono, "This is a placeholder setting and currently has no effect.")

	# cf.br()
	# cf.add_text("[audio (gstreamer only)]")
	#
	# prefs.gst_output = cf.sync_add("string", "gst-output", prefs.gst_output, "GStreamer output pipeline specification. Only used with GStreamer backend.")
	# prefs.gst_use_custom_output = cf.sync_add("bool", "gst-use-custom-output", prefs.gst_use_custom_output, "Set this to true to apply any manual edits of the above string.")

	cf.br()
	cf.add_text("[locale]")
	prefs.ui_lang = cf.sync_add(
		"string", "display-language", prefs.ui_lang, "Override display language to use if "
		"available. E.g. \"en\", \"ja\", \"zh_CN\". "
		"Default: \"auto\"")
	# prefs.diacritic_search = cf.sync_add("bool", "decode-search", prefs.diacritic_search, "Allow searching of diacritics etc using ascii in search functions. (Disablng may speed up search)")
	cf.br()
	cf.add_text("[search]")
	prefs.sep_genre_multi = cf.sync_add(
		"bool", "separate-multi-genre", prefs.sep_genre_multi,
		"If true, the standard genre result will exclude results from multi-value tags. These will be included in a separate result.")

	cf.br()
	cf.add_text("[tag-editor]")
	if bag.windows:
		prefs.tag_editor_name = cf.sync_add("string", "tag-editor-name", "Picard", "Name to display in UI.")
		prefs.tag_editor_target = cf.sync_add(
			"string", "tag-editor-target",
			"C:\\Program Files\\MusicBrainz Picard\\picard.exe",
			"The path of the exe to run.")
	else:
		prefs.tag_editor_name = cf.sync_add("string", "tag-editor-name", "Picard", "Name to display in UI.")
		prefs.tag_editor_target = cf.sync_add(
			"string", "tag-editor-target", "picard",
			"The name of the binary to call.")
	prefs.save_synced_to_lrc = cf.sync_add(
		"bool", "use_lrc_instead", prefs.save_synced_to_lrc,
		"Save synced lyrics to separate .LRC files.")
	prefs.save_lyrics_changes_to_files = cf.sync_add(
		"bool", "save_lyrics_changes_to_files", prefs.save_lyrics_changes_to_files,
		"Save lyrics edits to tags when searching, clearing, pasting, or using the editor.")
	prefs.synced_lyrics_editor_track_end_mode = cf.sync_add(
		"string", "synced_lyrics_editor_track_end_mode", prefs.synced_lyrics_editor_track_end_mode,
		"What to do when you reach the end of the track in the lyrics editor. Can be either \"stop\", \"autosave\" or \"full save\"."
	)

	cf.br()
	cf.add_text("[playback]")
	prefs.playback_follow_cursor = cf.sync_add(
		"bool", "playback-follow-cursor", prefs.playback_follow_cursor,
		"When advancing, always play the track that is selected.")
	prefs.back_restarts = cf.sync_add(
		"bool", "back-restarts", prefs.back_restarts,
		"Pressing the back button restarts playing track on first press.")
	prefs.resume_on_jump = cf.sync_add(
		"bool", "resume-on-advance", prefs.resume_on_jump,
		"When paused, pressing back or next button starts playback.")
	prefs.stop_end_queue = cf.sync_add(
		"bool", "end-queue-stop", prefs.stop_end_queue,
		"Queue will always enable auto-stop on last track")
	prefs.block_suspend = cf.sync_add(
		"bool", "block-suspend", prefs.block_suspend,
		"Prevent system suspend during playback")
	prefs.allow_video_formats = cf.sync_add(
		"bool", "allow-video-formats", prefs.allow_video_formats,
		"Allow the import of MP4 and WEBM formats")
	if prefs.allow_video_formats:
		for item in bag.formats.VID:
			if item not in bag.formats.DA:
				bag.formats.DA.add(item)

	cf.br()
	cf.add_text("[HiDPI]")
	prefs.scale_want = cf.sync_add(
		"float", "ui-scale", prefs.scale_want,
		"UI scale factor. Default is 1.0, try increase if using a HiDPI display.")
	prefs.x_scale = cf.sync_add("bool", "auto-scale", prefs.x_scale, "Automatically choose above setting")
	prefs.tracklist_y_text_offset = cf.sync_add(
		"int", "tracklist-y-text-offset", prefs.tracklist_y_text_offset,
		"If you're using a UI scale, you may need to tweak this.")

	cf.br()
	cf.add_text("[ui]")

	prefs.theme_name = cf.sync_add("string", "theme-name", prefs.theme_name)
	prefs.transparent_mode = cf.sync_add("int", "transparent-style", prefs.transparent_mode, "0=opaque(default), 1=accents")
	if first_run and prefs.macos:
		# Round by default on macOS where every other window has rounded corners
		prefs.rounded_corners = True
	prefs.rounded_corners = cf.sync_add(
		"bool", "rounded-corners", prefs.rounded_corners,
		"Round the window corners. Only applies when drawing own window decorations.")
	prefs.corner_radius = cf.sync_add(
		"int", "rounded-corner-radius", prefs.corner_radius, "Window corner radius in logical pixels")
	if prefs.corner_radius < 2 or prefs.corner_radius > 30:
		prefs.corner_radius = 10
		logging.warning("Invalid value for rounded-corner-radius")
	prefs.macstyle = cf.sync_add("bool", "mac-style", prefs.macstyle, "Use macOS style window buttons")
	prefs.zoom_art = cf.sync_add("bool", "allow-art-zoom", prefs.zoom_art)
	prefs.gallery_row_scroll = cf.sync_add("bool", "scroll-gallery-by-row", False)
	prefs.gallery_scroll_wheel_px = cf.sync_add(
		"int", "scroll-gallery-distance", 90,
		"Only has effect if scroll-gallery-by-row is false.")
	prefs.spec2_scroll = cf.sync_add("bool", "scroll-spectrogram", prefs.spec2_scroll)
	prefs.custom_bg_opacity = cf.sync_add("int", "mascot-opacity", prefs.custom_bg_opacity)
	if prefs.custom_bg_opacity < 0 or prefs.custom_bg_opacity > 100:
		prefs.custom_bg_opacity = 40
		logging.warning("Invalid value for mascot-opacity")

	prefs.sync_lyrics_time_offset = cf.sync_add(
		"int", "synced-lyrics-time-offset", prefs.sync_lyrics_time_offset,
		"In milliseconds. Negative values cause each line to display earlier and vice versa.")
	prefs.artist_list_prefer_album_artist = cf.sync_add(
		"bool", "artist-list-prefers-album-artist",
		prefs.artist_list_prefer_album_artist,
		"May require restart for change to take effect.")
	prefs.meta_persists_stop = cf.sync_add(
		"bool", "side-panel-info-persists", prefs.meta_persists_stop,
		"Show album art and metadata of last played track when stopped.")
	prefs.meta_shows_selected = cf.sync_add(
		"bool", "side-panel-info-selected", prefs.meta_shows_selected,
		"Show album art and metadata of selected track when stopped. (overrides above setting)")
	prefs.meta_shows_selected_always = cf.sync_add(
		"bool", "side-panel-info-selected-always",
		prefs.meta_shows_selected_always,
		"Show album art and metadata of selected track at all times. (overrides the above 2 settings)")
	prefs.stop_notifications_mini_mode = cf.sync_add(
		"bool", "mini-mode-avoid-notifications",
		prefs.stop_notifications_mini_mode,
		"Avoid sending track change notifications when in Mini Mode")
	prefs.hide_queue = cf.sync_add("bool", "hide-queue-when-empty", prefs.hide_queue)
	# prefs.show_playlist_list = cf.sync_add("bool", "show-playlist-list", prefs.show_playlist_list)

	prefs.art_in_top_panel = cf.sync_add(
		"bool", "enable-art-header-bar", prefs.art_in_top_panel,
		"Show art in top panel when window is narrow")
	prefs.always_art_header = cf.sync_add(
		"bool", "always-art-header-bar", prefs.always_art_header,
		"Show art in top panel at any size. (Requires enable-art-header-bar)")

	# prefs.center_bg = cf.sync_add("bool", "prefer-center-bg", prefs.center_bg, "Always center art for the background art function")
	prefs.side_panel_layout = cf.sync_add("int", "side-panel-style", prefs.side_panel_layout, "0:default, 1:centered")
	prefs.side_panel_left = cf.sync_add("bool", "side-panel-left", prefs.side_panel_left)
	prefs.show_side_lyrics_art_panel = cf.sync_add("bool", "side-lyrics-art", prefs.show_side_lyrics_art_panel)
	prefs.lyric_metadata_panel_top = cf.sync_add("bool", "side-lyrics-art-on-top", prefs.lyric_metadata_panel_top)
	prefs.showcase_wide_art = cf.sync_add("bool", "showcase-wide-art", prefs.showcase_wide_art)
	prefs.use_absolute_track_index = cf.sync_add(
		"bool", "absolute-track-indices", prefs.use_absolute_track_index,
		"For playlists with titles disabled only")
	prefs.hide_bottom_title = cf.sync_add(
		"bool", "auto-hide-bottom-title", prefs.hide_bottom_title,
		"Hide title in bottom panel when already shown in side panel")
	prefs.auto_goto_playing = cf.sync_add(
		"bool", "auto-show-playing", prefs.auto_goto_playing,
		"Show playing track in current playlist on track and playlist change even if not the playing playlist")

	prefs.notify_include_album = cf.sync_add(
		"bool", "notify-include-album", prefs.notify_include_album,
		"Include album name in track change notifications")
	prefs.rating_playtime_stars = cf.sync_add(
		"bool", "show-rating-hint", prefs.rating_playtime_stars,
		"Show playtime hint in stars")

	prefs.drag_to_unpin = cf.sync_add(
		"bool", "drag-tab-to-unpin", prefs.drag_to_unpin,
		"Dragging a tab off the top-panel un-pins it")

	cf.br()
	cf.add_text("[gallery]")
	prefs.thin_gallery_borders = cf.sync_add("bool", "gallery-thin-borders", prefs.thin_gallery_borders)
	prefs.increase_gallery_row_spacing = cf.sync_add("bool", "increase-row-spacing", prefs.increase_gallery_row_spacing)
	prefs.center_gallery_text = cf.sync_add("bool", "gallery-center-text", prefs.center_gallery_text)

	cf.br()
	cf.add_text("[fonts]")
	cf.add_comment("Changes will require app restart.")
	prefs.use_custom_fonts = cf.sync_add(
		"bool", "use-custom-fonts", prefs.use_custom_fonts,
		"Setting to false will reset below settings to default on restart")
	if prefs.use_custom_fonts:
		prefs.linux_font = cf.sync_add(
			"string", "font-main-standard", prefs.linux_font,
			"Suggested alternate: Liberation Sans")
		prefs.linux_font_semibold = cf.sync_add("string", "font-main-medium", prefs.linux_font_semibold)
		prefs.linux_font_bold = cf.sync_add("string", "font-main-bold", prefs.linux_font_bold)
		prefs.linux_font_condensed = cf.sync_add("string", "font-main-condensed", prefs.linux_font_condensed)
		prefs.linux_font_condensed_bold = cf.sync_add("string", "font-main-condensed-bold", prefs.linux_font_condensed_bold)

	else:
		cf.sync_add("string", "font-main-standard", prefs.linux_font, "Suggested alternate: Liberation Sans")
		cf.sync_add("string", "font-main-medium", prefs.linux_font_semibold)
		cf.sync_add("string", "font-main-bold", prefs.linux_font_bold)
		cf.sync_add("string", "font-main-condensed", prefs.linux_font_condensed)
		cf.sync_add("string", "font-main-condensed-bold", prefs.linux_font_condensed_bold)

	# prefs.force_subpixel_text = cf.sync_add("bool", "force-subpixel-text", prefs.force_subpixel_text, "(Subpixel rendering defaults to off with Flatpak)")

	cf.br()
	cf.add_text("[tracklist]")
	prefs.dd_index = cf.sync_add("bool", "double-digit-indices", prefs.dd_index)
	prefs.column_aa_fallback_artist = cf.sync_add(
		"bool", "column-album-artist-fallsback",
		prefs.column_aa_fallback_artist,
		"'Album artist' column shows 'artist' if otherwise blank.")
	prefs.left_align_album_artist_title = cf.sync_add(
		"bool", "left-aligned-album-artist-title",
		prefs.left_align_album_artist_title,
		"Show 'Album artist' in the folder/album title. Uses colour 'column-album-artist' from theme file")
	prefs.tracklist_scrollbar_left = cf.sync_add(
		"bool", "tracklist-scrollbar-left",
		prefs.tracklist_scrollbar_left,
		"Position the tracklist scroll bar on the left side instead of the right.")
	prefs.auto_sort = cf.sync_add(
		"bool", "import-auto-sort", prefs.auto_sort,
		"This setting is deprecated and will be removed in a future version")

	cf.br()
	cf.add_text("[transcode]")
	prefs.bypass_transcode = cf.sync_add(
		"bool", "sync-bypass-transcode", prefs.bypass_transcode,
		"Don't transcode files with sync function")
	prefs.smart_bypass = cf.sync_add("bool", "sync-bypass-low-bitrate", prefs.smart_bypass,
		"Skip transcode of <=128kbs folders")
	prefs.radio_record_codec = cf.sync_add("string", "radio-record-codec", prefs.radio_record_codec,
		"Can be OPUS, OGG, FLAC, or MP3. Default: OPUS")

	cf.br()
	cf.add_text("[directories]")
	cf.add_comment("Use full paths")
	prefs.sync_target = cf.sync_add("string", "sync-device-music-dir", prefs.sync_target)
	prefs.custom_encoder_output = cf.sync_add(
		"string", "encode-output-dir", prefs.custom_encoder_output,
		"E.g. \"/home/example/music/output\". If left blank, encode-output in home music dir will be used.")
	if prefs.custom_encoder_output:
		prefs.encoder_output = Path(prefs.custom_encoder_output)
	prefs.download_dir1 = cf.sync_add(
		"string", "add_download_directory", prefs.download_dir1,
		"Add another folder to monitor in addition to home downloads and music.")
	prefs.playlist_folder_path = cf.sync_add(
		"string", "playlist_folder_path", prefs.playlist_folder_path,
		"Default storage folder for playlists")
	prefs.autoscan_playlist_folder = cf.sync_add(
		"bool", "autoscan_playlist_folder", prefs.autoscan_playlist_folder,
		"Also auto-import new playlists from folder?")
	if prefs.download_dir1 and prefs.download_dir1 not in bag.download_directories:
		if os.path.isdir(prefs.download_dir1):
			bag.download_directories.append(prefs.download_dir1)
		else:
			logging.warning("Invalid download directory in config")

	cf.br()
	cf.add_text("[app]")
	prefs.enable_remote = cf.sync_add(
		"bool", "enable-remote-interface", prefs.enable_remote,
		"For use with Tauon Music Remote for Android")
	prefs.release_splash_version = cf.sync_add(
		"string", "release-splash-version", prefs.release_splash_version,
		"Records which one-time upgrade splash has already been shown.")
	prefs.use_gamepad = cf.sync_add("bool", "use-gamepad", prefs.use_gamepad, "Use game controller for UI control, restart on change.")
	prefs.use_tray = cf.sync_add("bool", "use-system-tray", prefs.use_tray)
	prefs.force_hide_max_button = cf.sync_add("bool", "hide-maximize-button", prefs.force_hide_max_button)
	prefs.save_window_position = cf.sync_add(
		"bool", "restore-window-position", prefs.save_window_position,
		"Save and restore the last window position on desktop on open")
	prefs.mini_mode_on_top  = cf.sync_add("bool", "mini-mode-always-on-top", prefs.mini_mode_on_top)
	prefs.enable_mpris = cf.sync_add("bool", "enable-mpris", prefs.enable_mpris)
	prefs.reload_play_state = cf.sync_add("bool", "resume-playback-on-restart", prefs.reload_play_state)
	prefs.resume_play_wake = cf.sync_add("bool", "resume-playback-on-wake", prefs.resume_play_wake)
	prefs.auto_dl_artist_data = cf.sync_add(
		"bool", "auto-dl-artist-data", prefs.auto_dl_artist_data,
		"Enable automatic downloading of thumbnails in artist list")
	prefs.enable_fanart_cover = cf.sync_add("bool", "fanart.tv-cover", prefs.enable_fanart_cover)
	prefs.enable_fanart_artist = cf.sync_add("bool", "fanart.tv-artist", prefs.enable_fanart_artist)
	prefs.enable_fanart_bg = cf.sync_add("bool", "fanart.tv-background", prefs.enable_fanart_bg)
	prefs.always_auto_update_playlists = cf.sync_add(
		"bool", "auto-update-playlists",
		prefs.always_auto_update_playlists,
		"Automatically update generator playlists")
	prefs.write_ratings = cf.sync_add(
		"bool", "write-ratings-to-tag", prefs.write_ratings,
		"This writes FMPS_Rating tags on disk. Only writing to MP3, OGG and FLAC files is currently supported.")
	prefs.discord_enable = cf.sync_add(
		"bool", "enable-discord-rpc", prefs.discord_enable,
		"Show track info in running Discord application")
	prefs.discord_card_layout = cf.sync_add(
		"string", "discord-card-layout", prefs.discord_card_layout,
		"Card layout: title_artist = Song title first, artist_title = Artist first")
	prefs.discord_member_list_display = cf.sync_add(
		"string", "discord-member-list-display", prefs.discord_member_list_display,
		"Controls what is shown in Discord member list: song or artist")
	prefs.discord_clean_title = cf.sync_add(
		"bool", "discord-clean-title", prefs.discord_clean_title,
		"Remove feat/remix/version suffixes in Discord title")
	prefs.discord_lastfm_button = cf.sync_add(
		"bool", "discord-lastfm-button", prefs.discord_lastfm_button,
		"Show Track Info button in Discord rich presence")
	prefs.discord_show_tauon_button = cf.sync_add(
		"bool", "discord-show-tauon-button", prefs.discord_show_tauon_button,
		"Show Tauon website button in Discord rich presence")
	prefs.discord_keep_idle = cf.sync_add(
		"bool", "discord-keep-idle", prefs.discord_keep_idle,
		"Keep Discord rich presence visible while playback is idle")
	if prefs.discord_card_layout not in ("title_artist", "artist_title"):
		prefs.discord_card_layout = "title_artist"
	if prefs.discord_member_list_display not in ("song", "artist"):
		prefs.discord_member_list_display = "artist"
	prefs.auto_lyrics = cf.sync_add(
		"bool", "auto-search-lyrics", prefs.auto_lyrics,
		"Automatically search internet for lyrics when display is wanted")

	prefs.use_scancodes = cf.sync_add(
		"bool", "shortcuts-ignore-keymap", prefs.use_scancodes,
		"When enabled, shortcuts will map to the physical keyboard layout")
	prefs.search_on_letter = cf.sync_add("bool", "alpha_key_activate_search", prefs.search_on_letter,
		"When enabled, pressing single letter keyboard key will activate the global search")

	prefs.taskbar_progress = cf.sync_add("bool", "taskbar-status", prefs.taskbar_progress,
		"When enabled, show playback progress on taskbar (supported DE only)")

	cf.br()
	cf.add_text("[tokens]")
	temp = cf.sync_add(
		"string", "discogs-personal-access-token", prefs.discogs_pat,
		"Used for sourcing of artist thumbnails.")
	if not temp:
		prefs.discogs_pat = ""
	elif len(temp) != 40:
		logging.warning("Invalid discogs token in config")
	else:
		prefs.discogs_pat = temp

	prefs.listenbrainz_url = cf.sync_add(
		"string", "custom-listenbrainz-url", prefs.listenbrainz_url,
		"Specify a custom Listenbrainz compatible api url. E.g. \"https://example.tld/apis/listenbrainz/\" Default: Blank")
	prefs.lb_token = cf.sync_add("string", "listenbrainz-token", prefs.lb_token)

	cf.br()
	cf.add_text("[tauon_satellite]")
	prefs.sat_url = cf.sync_add("string", "tau-url", prefs.sat_url, "Exclude the port")

	cf.br()
	cf.add_text("[lastfm]")
	prefs.lastfm_pull_love = cf.sync_add(
		"bool", "lastfm-pull-love", prefs.lastfm_pull_love,
		"Overwrite local love status on scrobble")

	cf.br()
	cf.add_text("[scrobbling]")
	cf.add_comment(
		"Allow Tauon to scrobble tracks played from a streaming service. Disable for "
		"services that already scrobble on their own to avoid double scrobbles.")
	prefs.scrobble_jellyfin = cf.sync_add("bool", "scrobble-jellyfin", prefs.scrobble_jellyfin)
	prefs.scrobble_tidal = cf.sync_add("bool", "scrobble-tidal", prefs.scrobble_tidal)
	prefs.scrobble_subsonic = cf.sync_add("bool", "scrobble-subsonic", prefs.scrobble_subsonic)
	prefs.scrobble_plex = cf.sync_add("bool", "scrobble-plex", prefs.scrobble_plex)


	cf.br()
	cf.add_text("[maloja_account]")
	prefs.maloja_url = cf.sync_add(
		"string", "maloja-url", prefs.maloja_url,
		"A Maloja server URL, e.g. http://localhost:32400")
	prefs.maloja_key = cf.sync_add("string", "maloja-key", prefs.maloja_key, "One of your Maloja API keys")
	prefs.maloja_enable = cf.sync_add("bool", "maloja-enable", prefs.maloja_enable)

	cf.br()
	cf.add_text("[plex_account]")
	prefs.plex_username = cf.sync_add(
		"string", "plex-username", prefs.plex_username,
		"Probably the email address you used to make your PLEX account.")
	prefs.plex_password = cf.sync_add(
		"string", "plex-password", prefs.plex_password,
		"The password associated with your PLEX account.")
	prefs.plex_servername = cf.sync_add(
		"string", "plex-servername", prefs.plex_servername,
		"Probably your servers hostname.")

	cf.br()
	cf.add_text("[subsonic_account]")
	prefs.subsonic_user = cf.sync_add("string", "subsonic-username", prefs.subsonic_user)
	prefs.subsonic_password = cf.sync_add("string", "subsonic-password", prefs.subsonic_password)
	prefs.subsonic_password_plain = cf.sync_add("bool", "subsonic-password-plain", prefs.subsonic_password_plain)
	prefs.subsonic_server = cf.sync_add("string", "subsonic-server-url", prefs.subsonic_server)

	cf.br()
	cf.add_text("[jellyfin_account]")
	prefs.jelly_username = cf.sync_add("string", "jelly-username", prefs.jelly_username, "")
	prefs.jelly_password = cf.sync_add("string", "jelly-password", prefs.jelly_password, "")
	prefs.jelly_server_url = cf.sync_add(
		"string", "jelly-server-url", prefs.jelly_server_url,
		"The IP:Port where the jellyfin server is hosted.")
	prefs.jelly_server_url = prefs.jelly_server_url.rstrip("/")

	cf.br()
	cf.add_text("[network]")
	prefs.network_stream_bitrate = cf.sync_add(
		"int", "stream-bitrate", prefs.network_stream_bitrate,
		"Optional bitrate subsonic should transcode to (Server may need to be configured for this). Set to 0 to disable transcoding.")

	cf.br()
	cf.add_text("[listenalong]")
	prefs.metadata_page_port = cf.sync_add(
		"int", "broadcast-page-port", prefs.metadata_page_port,
		"Change applies on app restart or setting re-enable")

	cf.br()
	cf.add_text("[chart]")
	prefs.chart_columns = cf.sync_add("int", "chart-columns", prefs.chart_columns)
	prefs.chart_rows = cf.sync_add("int", "chart-rows", prefs.chart_rows)
	prefs.chart_text = cf.sync_add("bool", "chart-uses-text", prefs.chart_text)
	prefs.topchart_sorts_played = cf.sync_add("bool", "chart-sorts-top-played", prefs.topchart_sorts_played)
	prefs.chart_font = cf.sync_add(
		"string", "chart-font", prefs.chart_font,
		"Format is fontname + size. Default is Monospace 10")

def auto_scale(bag: Bag) -> None:
	prefs = bag.prefs
	old = prefs.scale_want

	if prefs.x_scale:
		prefs.scale_want = bag.window_size[0] / bag.logical_size[0]

	prefs.scale_want = round(round(prefs.scale_want / 0.05) * 0.05, 2)
	if prefs.x_scale and old != prefs.scale_want:
		logging.info("Applying scale based on buffer size")

	if prefs.scale_want == 0.95:
		prefs.scale_want = 1.0
	if prefs.scale_want == 1.05:
		prefs.scale_want = 1.0
	if prefs.scale_want == 1.95:
		prefs.scale_want = 2.0
	if prefs.scale_want == 2.05:
		prefs.scale_want = 2.0

	if old != prefs.scale_want:
		logging.info(f"Using UI scale: {prefs.scale_want}")

	prefs.scale_want = max(prefs.scale_want, 0.5)

	#if bag.window_size[0] < (560 * prefs.scale_want) * 0.9 or bag.window_size[1] < (330 * prefs.scale_want) * 0.9:
	#	logging.info("Window overscale!")
	#	self.show_message(_("Detected unsuitable UI scaling."), _("Scaling setting reset to 1x"))
	#	prefs.scale_want = 1.0

def scale_assets(tauon: Tauon, bag: Bag, gui: GuiVar, scale_want: int, force: bool = False) -> None:
	asset_directory        = bag.dirs.asset_directory
	scaled_asset_directory = bag.dirs.scaled_asset_directory
	user_directory         = bag.dirs.user_directory
	svg_directory          = bag.dirs.svg_directory
	prefs = bag.prefs

	# Include the source icon count so adding an svg invalidates the cache
	# (the version alone misses icons added between releases / in dev).
	svg_count = len([f for f in os.listdir(svg_directory) if f.endswith(".svg")])
	key = f"{tauon.n_version},{scale_want},{svg_count}"
	keyfile = scaled_asset_directory / "key"
	render = True

	# Optimisation: don't rerender if we don't need to
	if not force and scaled_asset_directory.exists() and keyfile.exists():
		with open(keyfile) as f:
			c = f.read()
			if c == key:
				render = False

	if render:
		if scaled_asset_directory.exists():
			shutil.rmtree(scaled_asset_directory)
		scaled_asset_directory.mkdir()
		logging.info("Rendering icons...")
		render_icons(str(svg_directory), str(scaled_asset_directory), scale_want)
		with open(keyfile, "w", encoding="utf-8") as f:
			f.write(key)
		logging.info("Done rendering icons")
		tauon.reload_scale(skip_render=True)


	diff_ratio = scale_want / prefs.ui_scale
	prefs.ui_scale = scale_want
	prefs.scale_want = scale_want

	# Save user values
	column_backup = gui.pl_st
	rspw = gui.pref_rspw
	grspw = gui.pref_gallery_w
	row_h = prefs.playlist_row_height

	# gui.destroy_textures()
	gui.rescale()
	gui.update_layout = True

	# Scale saved values
	gui.pl_st = column_backup
	for item in gui.pl_st:
		item[1] *= diff_ratio
	gui.pref_rspw = rspw * diff_ratio
	gui.pref_gallery_w = grspw * diff_ratio
	prefs.playlist_row_height = round(row_h * diff_ratio)
	bag.album_mode_art_size = int(bag.album_mode_art_size * diff_ratio)


def clear_icon_cache(scaled_asset_directory: Path) -> None:
	if not scaled_asset_directory.exists() and not scaled_asset_directory.is_symlink():
		return
	logging.info("Clearing scaled icon cache for database upgrade")
	if scaled_asset_directory.is_dir() and not scaled_asset_directory.is_symlink():
		shutil.rmtree(scaled_asset_directory)
		return
	scaled_asset_directory.unlink()


def get_global_mouse() -> tuple[float, float]:
	i_y = pointer(c_float(0))
	i_x = pointer(c_float(0))
	sdl3.SDL_GetGlobalMouseState(i_x, i_y)
	return i_x.contents.value, i_y.contents.value

def get_window_position(t_window: sdl3.LP_SDL_Window) -> tuple[int, int]:
	i_y = pointer(c_int(0))
	i_x = pointer(c_int(0))
	sdl3.SDL_GetWindowPosition(t_window, i_x, i_y)
	return i_x.contents.value, i_y.contents.value

def use_id3(tags: ID3, nt: TrackClass) -> None:
	def natural_get(tag: ID3, track: TrackClass, frame: str, attr: str) -> str | None:
		frames = tag.getall(frame)
		if frames and frames[0].text:
			if track is None:
				return str(frames[0].text[0])
			setattr(track, attr, str(frames[0].text[0]))
		elif track is None:
			return ""
		else:
			setattr(track, attr, "")
		return None

	tag = tags

	natural_get(tags, nt, "TIT2", "title")
	natural_get(tags, nt, "TPE1", "artist")
	natural_get(tags, nt, "TPE2", "album_artist")
	natural_get(tags, nt, "TCON", "genre")  # content type
	natural_get(tags, nt, "TALB", "album")
	natural_get(tags, nt, "TDRC", "date")
	natural_get(tags, nt, "TCOM", "composer")
	natural_get(tags, nt, "COMM", "comment")

	process_odat(nt, natural_get(tags, None, "TDOR", None))

	frames = tag.getall("POPM")
	rating = 0
	if frames:
		for frame in frames:
			if frame.rating:
				rating = frame.rating
				nt.POPM = frame.rating

	if len(nt.comment) > 4 and nt.comment[2] == "+":
		nt.comment = ""
	if nt.comment[0:3] == "000":
		nt.comment = ""

	frames = tag.getall("USLT")
	if frames:
		nt.lyrics = frames[0].text
		if 0 < len(nt.lyrics) < 150:
			if "unavailable" in nt.lyrics or ".com" in nt.lyrics or "www." in nt.lyrics:
				nt.lyrics = ""

	# frames = tag.getall("SYLT")
	# if frames:


	frames = tag.getall("TPE1")
	if frames:
		d = []
		for frame in frames:
			for t in frame.text:
				d.append(t)
		if len(d) > 1:
			nt.artists = d
			nt.artist = "; ".join(d)

	frames = tag.getall("TCON")
	if frames:
		d = []
		for frame in frames:
			for t in frame.text:
				d.append(t)
		if len(d) > 1:
			nt.genres = d
		nt.genre = " / ".join(d)

	track_no = natural_get(tags, None, "TRCK", None)
	nt.track_total = ""
	nt.track_number = ""
	if track_no and track_no != "null":
		if "/" in track_no:
			a, b = track_no.split("/")
			nt.track_number = a
			nt.track_total = b
		else:
			nt.track_number = track_no

	disc = natural_get(tags, None, "TPOS", None)  # set ? or ?/?
	nt.disc_total = ""
	nt.disc_number = ""
	if disc:
		if "/" in disc:
			a, b = disc.split("/")
			nt.disc_number = a
			nt.disc_total = b
		else:
			nt.disc_number = disc

	tx = tags.getall("UFID")
	if tx:
		for item in tx:
			if item.owner == "http://musicbrainz.org":
				nt.musicbrainz_recordingid = item.data.decode()

	tx = tags.getall("TSOP")
	if tx:
		nt.artist_sort = tx[0].text[0]

	tx = tags.getall("TXXX")
	if tx:
		for item in tx:
			if item.desc == "MusicBrainz Release Track Id":
				nt.musicbrainz_trackid = item.text[0]
			if item.desc == "MusicBrainz Album Id":
				nt.musicbrainz_albumid = item.text[0]
			if item.desc == "MusicBrainz Release Group Id":
				nt.musicbrainz_releasegroupid = item.text[0]
			if item.desc == "MusicBrainz Artist Id":
				artist_id_list: list[str] = []
				for uuid in item.text:
					split_uuids = uuid.split("/") # UUIDs can be split by a special character
					for split_uuid in split_uuids:
						artist_id_list.append(split_uuid)
				nt.musicbrainz_artistids = artist_id_list

			try:
				desc = item.desc.lower()
				if desc == "replaygain_track_gain":
					nt.replaygain_track_gain = float(item.text[0].strip(" dB"))
				if desc == "replaygain_track_peak":
					nt.replaygain_track_peak = float(item.text[0])
				if desc == "replaygain_album_gain":
					nt.replaygain_album_gain = float(item.text[0].strip(" dB"))
				if desc == "replaygain_album_peak":
					nt.replaygain_album_peak = float(item.text[0])
			except Exception:
				logging.exception("Tag Scan: Read Replay Gain MP3 error")
				logging.debug(nt.fullpath)

			if item.desc == "FMPS_RATING":
				nt.FMPS_Rating = float(item.text[0])

def encode_track_name(track_object: TrackClass) -> str:
	if track_object.is_cue or not track_object.filename:
		out_line = str(track_object.track_number) + ". "
		out_line += track_object.artist + " - " + track_object.title
		return filename_safe(out_line)
	return os.path.splitext(track_object.filename)[0]

def encode_folder_name(track_object: TrackClass) -> str:
	folder_name = track_object.artist + " - " + track_object.album

	if folder_name == " - ":
		folder_name = track_object.parent_folder_name

	folder_name = filename_safe(folder_name).strip()

	if not folder_name:
		folder_name = str(track_object.index)

	if "cd" not in folder_name.lower() or "disc" not in folder_name.lower():
		if track_object.disc_total not in ("", "0", 0, "1", 1) or (
				str(track_object.disc_number).isdigit() and int(track_object.disc_number) > 1):
			folder_name += " CD" + str(track_object.disc_number)

	return folder_name

def coll_point(l: list[int], r: list[int]) -> bool:
	# rect point collision detection
	return r[0] < l[0] <= r[0] + r[2] and r[1] <= l[1] <= r[1] + r[3]

def find_synced_lyric_data(track: TrackClass, just_check: bool = False, reload: bool = False) -> list[str] | bool | None:
	"""Return list of strings if lyrics match LRC format, otherwise return None.
	just_check returns True if current track.lyrics fit LRC format.
	reload will try to OVERWRITE track.synced with new data.

	See https://en.wikipedia.org/wiki/LRC_(file_format)
	"""
	if not just_check:
		if not reload and track.synced:
			if lyrics_are_synced(track.synced):
				return track.synced.splitlines()
			logging.warning(
				f"Synced lyrics for {track.filename} do not look like LRC. Reclassifying them as static lyrics."
			)
			if not track.lyrics:
				track.lyrics = track.synced
			track.synced = ""
			return None
		if track.is_network:
			return None

	# Check if internal track lyrics are synced lyrics
	if track.lyrics:
		if lyrics_are_synced(track.lyrics):
			if not just_check:
				track.synced = track.lyrics
				return track.lyrics.splitlines()
			return True
		if just_check:
			return False


	# Check if we have a .LRC file
	direc = Path(track.parent_folder_path)
	if not direc.is_dir():
		logging.warning(f"Could not find directory: {track.parent_folder_path}")
		return None

	name = os.path.splitext(track.filename)[0]

	# Case-insensitive file check
	matched_file = next(
		(
			f for f in direc.iterdir()
			if f.is_file()
			and f.stem == name
			and f.suffix.lower() == ".lrc"
		),
		None,
	)

	if matched_file:
		try:
			with matched_file.open(encoding="utf-8") as f:
				data = f.readlines()
		except Exception:
			logging.exception("Read lyrics file error")
			return None
		track.synced = "\n".join(data)
		return data

	return None

def close_all_menus() -> None:
	for menu in Menu.instances:
		menu.active = False
	Menu.active = False

def ser_gimage(track_object: TrackClass) -> None:
	if track_object.artist and track_object.album:
		line = "https://www.google.com/search?tbm=isch&q=" + urllib.parse.quote(
			track_object.artist + " " + track_object.album)
		webbrowser.open(line, new=2, autoraise=True)

def unique_template(string: str) -> bool:
	return "<t>" in string or \
		"<title>" in string or \
		"<n>" in string or \
		"<number>" in string or \
		"<tracknumber>" in string or \
		"<tn>" in string or \
		"<sn>" in string or \
		"<singlenumber>" in string or \
		"<s>" in string or "%t" in string or "%tn" in string

def re_template_word(word: str, tr: TrackClass) -> str:
	if word == "aa" or word == "albumartist":

		if tr.album_artist:
			return tr.album_artist
		return tr.artist

	if word == "a" or word == "artist":
		return tr.artist

	if word == "t" or word == "title":
		return tr.title

	if word == "n" or word == "number" or word == "tracknumber" or word == "tn":
		if len(str(tr.track_number)) < 2:
			return "0" + str(tr.track_number)
		return str(tr.track_number)

	if word == "sn" or word == "singlenumber" or word == "singletracknumber" or word == "s":
		return str(tr.track_number)

	if word == "d" or word == "date" or word == "year":
		return str(tr.date)

	if word == "b" or "album" in word:
		return str(tr.album)

	if word == "g" or word == "genre":
		return tr.genre

	if word == "x" or "ext" in word or "file" in word:
		return tr.file_ext.lower()

	if word == "ux" or "upper" in word:
		return tr.file_ext.upper()

	if word == "c" or "composer" in word:
		return tr.composer

	if "comment" in word:
		return tr.comment.replace("\n", "").replace("\r", "")
	return ""

def parse_template2(string: str, track_object: TrackClass, strict: bool = False) -> str:
	temp = ""
	out = ""

	mode = 0

	for c in string:
		if mode == 0:
			if c == "<":
				mode = 1
			else:
				out += c

		elif c == ">":
			test = re_template_word(temp, track_object)
			if strict:
				assert test
			out += test

			mode = 0
			temp = ""

		else:
			temp += c

	if "<und" in string:
		out = out.replace(" ", "_")
	return parse_template(out, track_object, strict=strict)

def parse_template(string: str, track_object: TrackClass, up_ext: bool = False, strict: bool = False) -> str:
	set = 0
	underscore = False
	output = ""

	while set < len(string):
		if string[set] == "%" and set < len(string) - 1:
			set += 1
			if string[set] == "n":
				if len(str(track_object.track_number)) < 2:
					output += "0"
				if strict:
					assert str(track_object.track_number)
				output += str(track_object.track_number)
			elif string[set] == "a":
				if up_ext and track_object.album_artist:  # Context of renaming a folder
					output += track_object.album_artist
				else:
					if strict:
						assert track_object.artist
					output += track_object.artist
			elif string[set] == "t":
				if strict:
					assert track_object.title
				output += track_object.title
			elif string[set] == "c":
				if strict:
					assert track_object.composer
				output += track_object.composer
			elif string[set] == "d":
				if strict:
					assert track_object.date
				output += track_object.date
			elif string[set] == "b":
				if strict:
					assert track_object.album
				output += track_object.album
			elif string[set] == "x":
				if up_ext:
					output += track_object.file_ext.upper()
				else:
					output += "." + track_object.file_ext.lower()
			elif string[set] == "u":
				underscore = True
		else:
			output += string[set]
		set += 1

	output = output.rstrip(" -").lstrip(" -")

	if underscore:
		output = output.replace(" ", "_")

	# Attempt to ensure the output text is filename safe
	return filename_safe(output)

def year_s(plt: list[tuple[list[int], str, str]]) -> list[int]:
	sorted_temp = sorted(plt, key=lambda x: x[1])
	temp: list[int] = []

	for album in sorted_temp:
		temp += album[0]
	return temp

def parse_generator(string: str) -> tuple[list[str], list[str], bool]:
	cmds: list[str] = []
	quotes: list[str] = []
	current = ""
	q_string = ""
	inquote = False
	for cha in string:
		if not inquote and cha == " ":
			if current:
				cmds.append(current)
				quotes.append(q_string)
			q_string = ""
			current = ""
			continue
		if cha == "\"":
			inquote ^= True

		current += cha

		if inquote and cha != "\"":
			q_string += cha

	if current:
		cmds.append(current)
		quotes.append(q_string)

	return cmds, quotes, inquote

def auto_get_sync_targets() -> list[str]:
	search_paths = [
		"/run/user/*/gvfs/*/*/[Mm]usic",
		"/run/media/*/*/[Mm]usic"]
	result_paths = []
	for item in search_paths:
		result_paths.extend(glob.glob(item))
	return result_paths

def csv_string(item: str) -> str:
	item = str(item)
	item.replace("\"", "\"\"")
	return f"\"{item}\""

def add_pl_tag(text: str) -> str:
	return f" <{text}>"

def directory_size(path: str) -> int:
	total = 0
	for dirpath, dirname, filenames in os.walk(path):
		for file in filenames:
			path = os.path.join(dirpath, file)
			total += os.path.getsize(path)
	return total

def recode(text: str, enc: str) -> str:
	return text.encode("Latin-1", "ignore").decode(enc, "ignore")

def copy_to_clipboard(text: str) -> None:
	sdl3.SDL_SetClipboardText(text.encode(errors="surrogateescape"))

def copy_from_clipboard() -> str:
	try:
		return sdl3.SDL_GetClipboardText().decode()
	except UnicodeDecodeError:
		logging.exception("Clipboard text decode error")
		return ""
	except Exception:
		logging.exception("Unknown clipboard text decode error")
		return ""

def field_copy(text_field) -> None:
	text_field.copy()

def field_paste(text_field) -> None:
	text_field.paste()

def field_clear(text_field) -> None:
	text_field.clear()

def worker3(tauon: Tauon) -> None:
	while True:
		# time.sleep(0.04)

		# if tauon.thread_manager.exit_worker3:
		#     tauon.thread_manager.exit_worker3 = False
		#     return
		# time.sleep(1)

		tauon.gall_ren.worker_render()

def worker4(tauon: Tauon) -> None:
	gui = tauon.gui
	prefs = tauon.prefs
	pctl = tauon.pctl
	gui.style_worker_timer.set()
	while True:
		if prefs.art_bg or (gui.mode == GuiMode.MINI and prefs.mini_mode_mode == MiniModeMode.SLATE):
			tauon.style_overlay.worker()

		time.sleep(0.01)
		if pctl.playing_state != PlayingState.STOPPED and pctl.playing_time < 5:
			gui.style_worker_timer.set()
		if gui.style_worker_timer.get() > 5:
			return

def worker2(tauon: Tauon) -> None:
	search_over = tauon.search_over
	while True:
		tauon.worker2_lock.acquire()
		if search_over.search_text.text and not (len(search_over.search_text.text) == 1 and ord(search_over.search_text.text[0]) < 128):
			search_over.searched_text = search_over.search_text.text
			search_over.sip = True
			tauon.gui.update += 1

			search_over.results = tauon._build_search_results(
				search_over.search_text.text,
				all_folders=search_over.all_folders,
				cancel_check=lambda: search_over.searched_text != search_over.search_text.text,
			)
			search_over.sip = False
			search_over.on = 0
			tauon.gui.update += 1
			search_over.on = 0
			search_over.force_select = 0
			#logging.info(perf_timer.get())

def worker1(tauon: Tauon) -> None:
	bag   = tauon.bag
	gme   = tauon.gme
	gui   = tauon.gui
	pctl  = tauon.pctl
	prefs = tauon.prefs
	loaded_paths_cache = {}
	loaded_cue_cache = {}
	tauon.added = []

	def get_quoted_from_line(line: str) -> str:
		"""Extract quoted or unquoted string from a line

		e.g., 'FILE "01 - Track01.wav" WAVE' or 'TITLE Track01' or "PERFORMER 'Artist Name'"
		"""
		parts = line.split(None, 1)
		if len(parts) < 2:
			return ""

		content = parts[1].strip()

		if content.startswith('"'):
			end = content.find('"', 1)
			return content[1:end] if end != -1 else content[1:]
		if content.startswith("'"):
			end = content.find("'", 1)
			return content[1:end] if end != -1 else content[1:]
		# If not quoted, return the first word
		return content.split()[0]

	def add_from_cue(path: str) -> int | None:
		if not tauon.windows:  # Windows terminal doesn't like unicode
			logging.info(f"Reading CUE file: {path}")

		try:
			try:
				with open(path, encoding="utf_8") as f:
					content = f.readlines()
					logging.info("-- Reading as UTF-8")
			except Exception:
				logging.exception("Failed opening file as UTF-8")
				try:
					with open(path, encoding="utf_16") as f:
						content = f.readlines()
						logging.info("-- Reading as UTF-16")
				except Exception:
					logging.exception("Failed opening file as UTF-16")
					try:
						j = False
						try:
							with open(path, encoding="shiftjis") as f:
								content = f.readlines()
								for line in content:
									for c in j_chars:
										if c in line:
											j = True
											logging.info("-- Reading as SHIFT-JIS")
											break
						except Exception:
							logging.exception("Failed opening file as shiftjis")
						if not j:
							with open(path, encoding="windows-1251") as f:
								content = f.readlines()
							logging.info("-- Fallback encoding read as windows-1251")

					except Exception:
						logging.exception("Abort: Can't detect encoding of CUE file")
						return 1

			f.close()

			# We want to detect if this is a cue sheet that points to either a single file with subtracks, or multiple
			# files with multiple subtracks, but not multiple files that are individual tracks
			# i.e, is there really any splitting going on

			files = 0
			files_with_subtracks = 0
			subtrack_count = 0
			for line in content:
				if line.startswith("FILE "):
					files += 1
					if subtrack_count > 2:  # A hack way to avoid non-compliant EAC CUE sheet
						files_with_subtracks += 1
					subtrack_count = 0
				elif line.strip().startswith("TRACK "):
					subtrack_count += 1
			if subtrack_count > 2:
				files_with_subtracks += 1

			if files == 1 or files_with_subtracks > 1:
				pass
			else:
				return 1

			cue_performer = ""
			cue_date = ""
			cue_album = ""
			cue_genre = ""
			cue_main_performer = ""
			cue_songwriter = ""
			cue_disc = 0
			cue_disc_total = 0

			cd = []
			cds = []

			file_name = ""
			file_path = ""

			in_header = True

			i = -1
			while True:
				i += 1

				if i > len(content) - 1:
					break

				line = content[i].strip()

				if in_header:
					line = line.removeprefix("REM ")

					if line.startswith("TITLE "):
						cue_album = get_quoted_from_line(line)
					if line.startswith("PERFORMER "):
						cue_performer = get_quoted_from_line(line)
					if line.startswith("MAIN PERFORMER "):
						cue_main_performer = get_quoted_from_line(line)
					if line.startswith("SONGWRITER "):
						cue_songwriter = get_quoted_from_line(line)
					if line.startswith("GENRE "):
						cue_genre = get_quoted_from_line(line)
					if line.startswith("DATE "):
						cue_date = get_quoted_from_line(line)
					if line.startswith("DISCNUMBER "):
						cue_disc = get_quoted_from_line(line)
					if line.startswith("TOTALDISCS "):
						cue_disc_total = get_quoted_from_line(line)

					if line.startswith("FILE "):
						in_header = False
					else:
						continue

				if line.startswith("FILE "):
					if cd:
						cds.append(cd)
						cd = []

					file_name = get_quoted_from_line(line)
					file_path = os.path.join(os.path.dirname(path), file_name)

					if not os.path.isfile(file_path):
						if files == 1:
							logging.info("-- The referenced source file wasn't found. Searching for matching file name...")
							for item in os.listdir(os.path.dirname(path)):
								if os.path.splitext(item)[0] == os.path.splitext(os.path.basename(path))[0]:
									if ".cue" not in item.lower() and item.split(".")[-1].lower() in bag.formats.DA:
										file_name = item
										file_path = os.path.join(os.path.dirname(path), file_name)
										logging.info(f"-- Source found at: {file_path}")
										break
							else:
								logging.error(f"-- Abort: Source file not found: {file_path}")
								return 1
						else:
							logging.error(f"-- Abort: Source file not found: {file_path}")
							return 1

				if line.startswith("TRACK "):
					line = line[6:]
					line = line.removesuffix("AUDIO")

					c = loaded_cue_cache.get((file_path.replace("\\", "/"), int(line.strip())))
					if c is not None:
						nt = c
					else:
						nt = TrackClass()
						nt.index = pctl.master_count
						pctl.master_count += 1

					nt.fullpath = file_path
					nt.filename = file_name
					nt.parent_folder_path = os.path.dirname(file_path.replace("\\", "/"))
					nt.parent_folder_name = os.path.splitext(os.path.basename(file_path))[0]
					nt.file_ext = os.path.splitext(file_name)[1][1:].upper()
					nt.is_cue = True

					nt.album_artist = cue_main_performer
					if not cue_main_performer:
						nt.album_artist = cue_performer
					nt.artist = cue_performer
					nt.composer = cue_songwriter
					nt.genre = cue_genre
					nt.album = cue_album
					nt.date = cue_date.replace('"', "")
					nt.track_number = int(line.strip())
					if nt.track_number == 1:
						nt.size = os.path.getsize(nt.fullpath)
					nt.parent_size = os.path.getsize(nt.fullpath)

					while True:
						i += 1
						if i > len(content) - 1 or content[i].startswith("FILE ") or content[i].strip().startswith(
								"TRACK"):
							break

						line = content[i]
						line = line.strip()

						if line.startswith("TITLE"):
							nt.title = get_quoted_from_line(line)
						if line.startswith("PERFORMER"):
							nt.artist = get_quoted_from_line(line)
						if line.startswith("SONGWRITER"):
							nt.composer = get_quoted_from_line(line)
						if line.startswith("INDEX 01 ") and ":" in line:
							line = line[9:]
							times = line.split(":")
							nt.start_time = int(times[0]) * 60 + int(times[1]) + int(times[2]) / 100

					i -= 1
					cd.append(nt)

			if cd:
				cds.append(cd)

			for cdn, cd in enumerate(cds):
				last_end = None
				end_track = TrackClass()
				end_track.fullpath = cd[-1].fullpath
				tauon.tag_scan(end_track)

				# Remove target track if already imported
				for i in reversed(range(len(tauon.added))):
					if pctl.get_track(tauon.added[i]).fullpath == end_track.fullpath:
						del tauon.added[i]

				# Update with proper length
				for track in reversed(cd):
					if last_end == None:
						last_end = end_track.length

					track.length = last_end - track.start_time
					track.samplerate = end_track.samplerate
					track.bitrate = end_track.bitrate
					track.bit_depth = end_track.bit_depth
					track.parent_length = end_track.length
					last_end = track.start_time

					# inherit missing metadata
					if not track.date:
						track.date = end_track.date
					if not track.album_artist:
						track.album_artist = end_track.album_artist
					if not track.album:
						track.album = end_track.album
					if not track.artist:
						track.artist = end_track.artist
					if not track.genre:
						track.genre = end_track.genre
					if not track.comment:
						track.comment = end_track.comment
					if not track.composer:
						track.composer = end_track.composer

					if cue_disc:
						track.disc_number = cue_disc
					elif len(cds) <= 1:
						track.disc_number = ""
					else:
						track.disc_number = str(cdn + 1)

					if cue_disc_total:
						track.disc_total = cue_disc_total
					elif len(cds) <= 1:
						track.disc_total = ""
					else:
						track.disc_total = str(len(cds))


			# Add all tracks for import to playlist
			for cd in cds:
				for track in cd:
					pctl.master_library[track.index] = track
					if track.fullpath not in bag.cue_list:
						bag.cue_list.append(track.fullpath)
					loaded_paths_cache[track.fullpath] = track.index
					tauon.added.append(track.index)

		except Exception:
			logging.exception("Internal error processing CUE file")

	def pl_folder_autoscan() -> None:
		"""Rescan designated playlist folder for new playlists and import them"""
		if prefs.autoscan_playlist_folder:
			new_playlists = []
			for root, dirs, files in os.walk( prefs.playlist_folder_path ):
				for name in files:
					if Path( name.lower() ).suffix in [".m3u",".m3u8",".xspf"]:
						structure = ( os.path.join(root, name), name )
						new_playlists.append( structure )
			for filepath in new_playlists:
				can_import = True
				for already_existing_playlist in pctl.multi_playlist:
					if already_existing_playlist.playlist_file == filepath[0]:
						can_import = False
						logging.info(f"skipping {filepath[1]}: already loaded as {already_existing_playlist.title}")
				if can_import:
					add_file(filepath[0])
					logging.info(f"will import {filepath[0]}")

	def add_file(path: str, force_scan: bool = False, show_errors: bool = False) -> int | None:
		"""Import file from path - playlists, audio, zips etc"""
		try:
			Path(path).stat().st_size
		except FileNotFoundError:
			logging.error(f"File to import missing: {path}")
			if show_errors:
				tauon.show_message(
					_("Files missing or inaccessible"),
					_("If you're using Flatpak, you should probably follow this:"),
					"https://github.com/Taiko2k/Tauon/wiki/Flatpak-Extra-Steps#gui-way-using-flatseal",
					mode="link")
			return 0
		except PermissionError:
			logging.error(f"Bad permissions to import: {path}")
			if show_errors:
				tauon.show_message(
					_("Bad permissions"),
					_("We don't have permissions to access {file}").format(file=path),
					mode="error"
				)
			return 0
		except Exception:
			logging.exception(f"Unknown error importing: {path}")
			if show_errors:
				tauon.show_message(
					_("Can't import that"),
					_("We can't import the file {path} and we're not sure why.").format(path=path),
					mode="error"
				)

		for pl, playlist in enumerate( pctl.multi_playlist ):
			if path == playlist.playlist_file:
				if show_errors:
					temp = prefs.always_auto_update_playlists
					prefs.always_auto_update_playlists = True
					pctl.switch_playlist(pl)
					prefs.always_auto_update_playlists = temp
					# make sure that the file gets reloaded regardless of settings
				else:
					logging.error(f"File already associated with playlist \"{playlist.title}\"")
				return 0

		if os.path.splitext(path)[1][1:] in {"CUE", "cue"}:
			add_from_cue(path)
			return 0

		if path.lower().endswith(".xspf"):
			logging.info(f"Found XSPF file at: {path}")
			tauon.load_xspf(path)
			return 0

		if path.lower().endswith(".milk"):
			if milky_ready:
				tauon.milky.projectm.load_next = Path(path)

		if path.lower().endswith(".m3u") or path.lower().endswith(".m3u8"):
			tauon.load_m3u(path)
			return 0

		if path.endswith(".pls"):
			tauon.load_pls(path)
			return 0

		if os.path.splitext(path)[1][1:].lower() not in bag.formats.DA:
			if os.path.splitext(path)[1][1:].lower() in bag.formats.Archive:
				if not prefs.auto_extract:
					tauon.show_message(
						_("You attempted to drop an archive."),
						_('However the "extract archive" function is not enabled.'), mode="info")
				else:
					type = os.path.splitext(path)[1][1:].lower()
					split = os.path.splitext(path)
					target_dir = split[0]
					if prefs.extract_to_music and tauon.music_directory is not None:
						target_dir = os.path.join(str(tauon.music_directory), os.path.basename(target_dir))
					#logging.info(os.path.getsize(path))
					if os.path.getsize(path) > 4e+9:
						logging.warning("Archive file is large!")
						tauon.show_message(_("Skipping oversize zip file (>4GB)"))
						return 1
					if not os.path.isdir(target_dir) and not os.path.isfile(target_dir):
						if type == "zip":
							try:
								b = gui.to_got
								gui.to_got = "ex"
								gui.update += 1
								zip_ref = zipfile.ZipFile(path, "r")

								zip_ref.extractall(target_dir)
								zip_ref.close()
							except RuntimeError as e:
								logging.exception("Zip error")
								gui.to_got = b
								if "encrypted" in e:
									tauon.show_message(
										_("Failed to extract zip archive."),
										_("The archive is encrypted. You'll need to extract it manually with the password."),
										mode="warning")
								else:
									tauon.show_message(
										_("Failed to extract zip archive."),
										_("Maybe archive is corrupted? Does disk have enough space and have write permission?"),
										mode="warning")
								return 1
							except Exception:
								logging.exception("Zip error 2")
								gui.to_got = b
								tauon.show_message(
									_("Failed to extract zip archive."),
									_("Maybe archive is corrupted? Does disk have enough space and have write permission?"),
									mode="warning")
								return 1

						elif type == "rar":
							b = gui.to_got
							try:
								gui.to_got = "ex"
								gui.update += 1
								line = tauon.launch_prefix + "unrar x -y -p- " + shlex.quote(path) + " " + shlex.quote(
									target_dir) + os.sep
								result = subprocess.run(shlex.split(line), check=True)
								logging.info(result)
							except Exception:
								logging.exception("Failed to extract rar archive.")
								gui.to_got = b
								tauon.show_message(_("Failed to extract rar archive."), mode="warning")

								return 1

						elif type == "7z":
							b = gui.to_got
							try:
								gui.to_got = "ex"
								gui.update += 1
								line = tauon.launch_prefix + "7z x -y " + shlex.quote(path) + " -o" + shlex.quote(
									target_dir) + os.sep
								result = subprocess.run(shlex.split(line), check=True)
								logging.info(result)
							except Exception:
								logging.exception("Failed to extract 7z archive.")
								gui.to_got = b
								tauon.show_message(_("Failed to extract 7z archive."), mode="warning")

								return 1

						upper = os.path.dirname(target_dir)
						cont = os.listdir(target_dir)
						new = upper + "/temporaryfolderd"
						error = False
						if len(cont) == 1 and os.path.isdir(split[0] + "/" + cont[0]):
							logging.info("one thing")
							os.rename(target_dir, new)
							try:
								shutil.move(new + "/" + cont[0], upper)
							except Exception:
								logging.exception("Could not move file")
								error = True
							shutil.rmtree(new)
							logging.info(new)
							target_dir = upper + "/" + cont[0]
							if not os.path.isdir(target_dir):
								logging.error("Extract error, expected directory not found")

						if True and not error and prefs.auto_del_zip:
							logging.info(f"Moving archive file to trash: {path}")
							try:
								send2trash(path)
							except Exception:
								logging.exception("Could not move archive to trash")
								tauon.show_message(_("Could not move archive to trash"), path, mode="info")

						gui.to_got = b
						gets(target_dir)
						tauon.quick_import_done.append(target_dir)
					# gets(target_dir)

			return 1

		gui.to_got += 1
		gui.update = 1

		path = path.replace("\\", "/")

		if path in loaded_paths_cache:
			de = loaded_paths_cache[path]

			if pctl.master_library[de].fullpath in bag.cue_list:
				logging.info("File has an associated .cue file... Skipping")
				return None

			if pctl.master_library[de].file_ext.lower() in bag.formats.GME:
				# Skip cache for subtrack formats
				pass
			else:
				tauon.added.append(de)
				return None

		time.sleep(0.002)

		# audio = auto.File(path)

		nt = TrackClass()

		nt.index = pctl.master_count
		set_path(nt, path)

		def commit_track(nt: TrackClass) -> None:
			pctl.master_library[pctl.master_count] = nt
			tauon.added.append(pctl.master_count)

			if prefs.auto_sort or force_scan:
				tauon.tag_scan(nt)
			else:
				tauon.after_scan.append(nt)
				tauon.thread_manager.ready("worker")

			pctl.master_count += 1

		# nt = tauon.tag_scan(nt)
		if nt.cue_sheet:
			tauon.tag_scan(nt)
			tauon.cue_scan(nt.cue_sheet, nt)
			del nt
		elif nt.file_ext.lower() in bag.formats.GME and gme:
			emu = ctypes.c_void_p()
			err = gme.gme_open_file(nt.fullpath.encode("utf-8"), ctypes.byref(emu), -1)
			if not err:
				n = gme.gme_track_count(emu)
				for i in range(n):
					nt = TrackClass()
					set_path(nt, path)
					nt.index = pctl.master_count
					nt.subtrack = i
					commit_track(nt)

				gme.gme_delete(emu)
		else:
			commit_track(nt)

		# bm.get("fill entry")
		if gui.auto_play_import:
			pctl.jump(pctl.master_count - 1)
			gui.auto_play_import = False
		return None

	def pre_get(direc: str) -> None:
		"""Count the approx number of files to be imported"""
		gui.to_get = 0
		for root, dirs, files in os.walk(direc):
			gui.to_get += len(files)
			if gui.im_cancel:
				return
			gui.update = 3

	def gets(direc: str, force_scan: bool = False) -> None:
		if os.path.basename(direc) == "__MACOSX":
			return

		try:
			items_in_dir = os.listdir(direc)
			if tauon.use_natsort:
				items_in_dir = natsort.os_sorted(items_in_dir)
			else:
				items_in_dir.sort()
		except PermissionError:
			logging.exception("Permission error accessing one or more files")
			tauon.show_message(_("Permission error accessing one or more files"), mode="warning")
			return
		except Exception:
			logging.exception("Unknown error accessing one or more files")
			return

		for q in range(len(items_in_dir)):
			if items_in_dir[q][0] == ".":
				continue
			if os.path.isdir(os.path.join(direc, items_in_dir[q])):
				gets(os.path.join(direc, items_in_dir[q]))
			if gui.im_cancel:
				return

		for q in range(len(items_in_dir)):
			if items_in_dir[q][0] == ".":
				continue
			if os.path.isdir(os.path.join(direc, items_in_dir[q])) is False:

				if os.path.splitext(items_in_dir[q])[1][1:].lower() in bag.formats.DA:
					if len(items_in_dir[q]) > 2 and items_in_dir[q][0:2] == "._":
						continue

					add_file(os.path.join(direc, items_in_dir[q]).replace("\\", "/"), force_scan)
				elif os.path.splitext(items_in_dir[q])[1][1:] in {"CUE", "cue"}:
					add_from_cue(os.path.join(direc, items_in_dir[q]).replace("\\", "/"))

			if gui.im_cancel:
				return

	def cache_paths() -> tuple[dict, dict]:
		dic = {}
		dic2 = {}
		for key, value in pctl.master_library.items():
			if value.is_network:
				continue
			dic[value.fullpath.replace("\\", "/")] = key
			if value.is_cue:
				dic2[(value.fullpath.replace("\\", "/"), value.track_number)] = value
		return dic, dic2

	def upload_to_lrclib( upload: dict[ str, str | float ] ) -> bool:
		p, t = get_lrclib_challenge(tauon.t_agent)
		if p and t:
			logging.info("LRCLIB upload: step 1 (of 3) complete")
		else:
			tauon.lrclib_uploads = []
			tauon.show_message(
				_("Connection error"),
				_("We can't upload your lyrics because we can't connect to the server."),
				_("Are you offline?"),
				mode="error"
			)
			return False
		if tauon.windows:
			binary = str( tauon.install_directory / "lrclib-solver.exe" )
		else:
			binary = str( tauon.install_directory / "lrclib-solver" )

		try:
			nonce = subprocess.run(
				[binary, p, t],
				capture_output=True,
				text=True,
				check=True
			)
		except FileNotFoundError:
			tauon.lrclib_uploads = [] # fatal error should cancel all further uploads
			tauon.show_message(
				_("Something went wrong"),
				_("The following file is missing:"),
				binary,
				mode="error"
			)
			return False
		except PermissionError:
			tauon.lrclib_uploads = []
			tauon.show_message(
				_("Something went wrong"),
				_("We don't have the proper permissions to run the file:"),
				binary,
				mode="error"
			)
			return False
		except Exception:
			tauon.lrclib_uploads = []
			tauon.show_message(
				_("Something went wrong"),
				_("We can't upload your lyrics, and we don't know why."),
				mode="error"
			)
			logging.exception("Couldn't upload lyrics")
			return False
		logging.info("LRCLIB upload: step 2 (of 3) complete")
		publish_token = p + ":" + nonce.stdout.strip()
		h = {
			"User-Agent": tauon.t_agent,
			"X-Publish-Token": publish_token
		}
		r = requests.post("https://lrclib.net/api/publish", headers=h, json=upload, timeout=10)
		if r.status_code == 201:
			logging.info("LRCLIB upload: step 3 (of 3) complete")
		else:
			tauon.lrclib_uploads = []
			tauon.show_message(
				_("Something went wrong"),
				_("Lyrics upload failed with error code: ") + str(r.status_code),
				mode="error"
			)
		return r.status_code == 201


	#logging.info(pctl.master_library)

	active_timer = Timer()
	while True:
		if not tauon.after_scan:
			time.sleep(0.1)

		if tauon.after_scan \
		or tauon.playlist_autoscan \
		or tauon.load_orders \
		or tauon.artist_list_box.load \
		or tauon.artist_list_box.to_fetch \
		or tauon.gui.regen_single_id \
		or tauon.gui.regen_single > -1 \
		or tauon.pctl.after_import_flag \
		or tauon.worker_save_state \
		or tauon.move_jobs \
		or tauon.cm_clean_db \
		or tauon.transcode_list \
		or tauon.to_scan \
		or tauon.lrclib_uploads \
		or tauon.loaderCommandReady:
			active_timer.set()
		elif active_timer.get() > 5:
			return

		if tauon.after_scan:
			i = 0
			while tauon.after_scan:
				i += 1

				if i > 123:
					break

				tauon.tag_scan(tauon.after_scan[0])

				gui.update = 2
				gui.pl_update = 1
				# time.sleep(0.001)
				if pctl.running:
					del tauon.after_scan[0]
				else:
					break

			gui.album_artist_dict.clear()

		if tauon.playlist_autoscan:
			pl_folder_autoscan()
			tauon.playlist_autoscan = False

		tauon.artist_list_box.worker()

		# Update smart playlists
		if gui.regen_single_id is not None:
			tauon.regenerate_playlist(pl=-1, silent=True, id=gui.regen_single_id)
			gui.regen_single_id = None

		# Update smart playlists
		if gui.regen_single > -1:
			target = gui.regen_single
			gui.regen_single = -1
			tauon.regenerate_playlist(target, silent=True)

		if pctl.after_import_flag and not tauon.after_scan and not tauon.search_over.active and not pctl.loading_in_progress:
			pctl.after_import_flag = False

			for i, plist in enumerate(pctl.multi_playlist):
				if pctl.pl_to_id(i) in pctl.gen_codes:
					code = pctl.gen_codes[pctl.pl_to_id(i)]
					try:
						if tauon.check_auto_update_okay(code, pl=i):
							if not tauon.pl_is_locked(i):
								logging.info(f"Reloading smart playlist: {plist.title}")
								tauon.regenerate_playlist(i, silent=True)
								time.sleep(0.02)
					except Exception:
						logging.exception("Failed to handle playlist")

			tauon.tree_view_box.clear_all()

		if tauon.worker_save_state and \
				not gui.pl_pulse and \
				not pctl.loading_in_progress and \
				not tauon.to_scan and not tauon.after_scan and \
				not tauon.plex.scanning and \
				not tauon.jellyfin.scanning and \
				not tauon.cm_clean_db and \
				not tauon.lastfm.scanning_friends and \
				not tauon.move_in_progress and \
				(gui.lowered or not window_is_focused(tauon.t_window) or not gui.mouse_in_window):
			tauon.save_state()
			bag.cue_list.clear()
			tauon.worker_save_state = False

		for i, upload in enumerate(tauon.lrclib_uploads):
			if upload_to_lrclib( upload ):
				del tauon.lrclib_uploads[i]
				gui.update += 1
			else:
				tauon.lrclib_uploads.reverse() # if one is broken you can still try the others

		# Folder moving
		if len(tauon.move_jobs) > 0:
			gui.update += 1
			tauon.move_in_progress = True
			job = tauon.move_jobs[0]
			del tauon.move_jobs[0]

			if job[0].strip("\\/") == job[1].strip("\\/"):
				tauon.show_message(_("Folder copy error."), _("The target and source are the same."), mode="info")
				gui.update += 1
				tauon.move_in_progress = False
				continue

			try:
				shutil.copytree(job[0], job[1])
			except Exception:
				logging.exception("Failed to copy directory")
				tauon.move_in_progress = False
				gui.update += 1
				tauon.show_message(_("The folder copy has failed!"), _("Some files may have been written."), mode="warning")
				continue

			if job[2] is True:
				try:
					shutil.rmtree(job[0])

				except Exception:
					logging.exception("Failed to delete directory")
					tauon.show_message(_("Something has gone horribly wrong!"), _("Could not delete {name}").format(name=job[0]), mode="error")
					gui.update += 1
					tauon.move_in_progress = False
					return

				tauon.show_message(_("Folder move complete."), _("Folder name: {name}").format(name=job[3]), mode="done")
			else:
				tauon.show_message(_("Folder copy complete."), _("Folder name: {name}").format(name=job[3]), mode="done")

			tauon.move_in_progress = False
			tauon.load_orders.append(job[4])
			gui.update += 1

		# Clean database
		if tauon.cm_clean_db is True:
			items_removed = 0

			# old_db = copy.deepcopy(pctl.master_library)
			gui.to_got = 0
			gui.to_get = len(pctl.master_library)
			tauon.search_over.results.clear()

			keys = set(pctl.master_library.keys())
			for index in keys:
				time.sleep(0.0001)
				track = pctl.master_library[index]
				gui.to_got += 1

				if gui.to_got % 100 == 0:
					gui.update = 1

				if (prefs.remove_network_tracks is False and not track.is_network and not os.path.isfile(
						track.fullpath)) or \
						(prefs.remove_network_tracks is True and track.is_network):

					pctl.purge_track(index)
					items_removed += 1

			tauon.cm_clean_db = False
			tauon.show_message(
				_("Cleaning complete."),
				_("{N} items were removed from the database.").format(N=str(items_removed)), mode="done")
			if prefs.album_mode:
				tauon.reload_albums(True)
			if gui.combo_mode:
				tauon.reload_albums()

			gui.update = 1
			gui.pl_update = 1
			pctl.notify_database_changed()

			tauon.search_dia_string_cache.clear()
			tauon.search_string_cache.clear()
			tauon.search_field_cache.clear()
			tauon.search_dia_field_cache.clear()
			tauon.search_over.results.clear()

			pctl.notify_database_changed()

		# FOLDER ENC
		if tauon.transcode_list:
			try:
				tauon.transcode_state = ""
				gui.update += 1

				folder_items = tauon.transcode_list[0]

				ref_track_object = pctl.master_library[folder_items[0]]
				ref_album = ref_track_object.album

				# Generate a folder name based on artist and album of first track in batch
				folder_name = encode_folder_name(ref_track_object)

				# If folder contains tracks from multiple albums, use original folder name instead
				for item in folder_items:
					test_object = pctl.master_library[item]
					if test_object.album != ref_album:
						folder_name = ref_track_object.parent_folder_name
						break

				logging.info(f"Transcoding folder: {folder_name}")

				# Remove any existing matching folder
				if (prefs.encoder_output / folder_name).is_dir():
					shutil.rmtree(prefs.encoder_output / folder_name)

				# Create new empty folder to output tracks to
				(prefs.encoder_output / folder_name).mkdir(parents=True)

				full_wav_out_p = prefs.encoder_output / "output.wav"
				full_target_out_p = prefs.encoder_output / ("output." + prefs.transcode_codec)
				if full_wav_out_p.is_file():
					full_wav_out_p.unlink()
				if full_target_out_p.is_file():
					full_target_out_p.unlink()

				cache_dir = tmp_cache_dir()
				if not os.path.isdir(cache_dir):
					os.makedirs(cache_dir)

				if prefs.transcode_codec in ("opus", "ogg", "flac", "mp3"):
					cores = os.cpu_count()

					total = len(folder_items)
					gui.transcoding_batch_total = total
					gui.transcoding_batch_done = 0
					dones = []

					q = 0
					while True:
						if tauon.core_use < cores and q < len(folder_items):
							agg = [[folder_items[q], Path(folder_name)]]
							if agg not in dones:
								tauon.core_use += 1
								dones.append(agg)
								loaderThread = threading.Thread(target=tauon.transcode_single, args=agg)
								loaderThread.daemon = True
								loaderThread.start()

							q += 1
							gui.update += 1
						time.sleep(0.05)
						if gui.tc_cancel:
							while tauon.core_use > 0:
								time.sleep(1)
							break
						if q == len(folder_items) and tauon.core_use == 0:
							gui.update += 1
							break
				else:
					logging.error("Codec error")

				output_dir = prefs.encoder_output / folder_name
				if prefs.transcode_inplace:
					try:
						output_dir.unlink()
					except Exception:
						logging.exception("Encode folder not removed")
					tauon.reload_metadata(folder_items[0])
				else:
					tauon.album_art_gen.save_thumb(pctl.get_track(folder_items[0]), (1080, 1080), str(output_dir / "cover"))

				#logging.info(tauon.transcode_list[0])

				del tauon.transcode_list[0]
				tauon.transcode_state = ""
				gui.update += 1
			except Exception:
				logging.exception("Transcode failed")
				tauon.transcode_state = "Transcode Error"
				time.sleep(0.2)
				tauon.show_message(_("Transcode failed."), _("An error was encountered."), mode="error")
				gui.update += 1
				time.sleep(0.1)
				del tauon.transcode_list[0]

			if len(tauon.transcode_list) == 0:
				if gui.tc_cancel:
					gui.tc_cancel = False
					tauon.show_message(
						_("The transcode was canceled before completion."),
						_("Incomplete files will remain."),
						mode="warning")
				else:
					line = _("Press F9 to show output.")
					if prefs.transcode_codec == "flac":
						line = _("Note that any associated output picture is a thumbnail and not an exact copy.")
					if not gui.sync_progress:
						if not gui.message_box:
							tauon.show_message(_("Encoding complete."), line, mode="done")
						if tauon.de_notify_support:
							tauon.g_tc_notify.show()

		if tauon.to_scan:
			network_warn: bool = False
			while tauon.to_scan:
				track = tauon.to_scan[0]
				def clear_search_cache(track: int) -> None:
					# clear cached search data (user can search by lyrics immediately)
					tauon.search_string_cache.pop(track, None)
					tauon.search_dia_string_cache.pop(track, None)
					tauon.search_field_cache.pop(track, None)
					tauon.search_dia_field_cache.pop(track, None)
				star = tauon.star_store.full_get(track)
				tauon.star_store.remove(track)
				clear_search_cache(track)
				rescanned_track = tauon.tag_scan(pctl.master_library[track])
				if rescanned_track is None:
					del tauon.to_scan[0]
					gui.update += 1
					network_warn = True
					continue
				pctl.master_library[track] = rescanned_track
				tauon.star_store.merge(track, star)
				tauon.lastfm.sync_pull_love(pctl.master_library[track])
				del tauon.to_scan[0]
				gui.update += 1
			gui.album_artist_dict.clear()
			pctl.notify_database_changed()
			gui.pl_update += 1
			if network_warn:
				tauon.show_message(_("Some tracks could not be rescanned"), _("Rescanning network tracks is unsupported, please reimport your playlist instead."))

		if tauon.loaderCommandReady is True:
			for order in tauon.load_orders:
				if order.stage == 1:
					if tauon.loaderCommand == LoaderCommand.FOLDER:
						gui.to_get = 0
						gui.to_got = 0
						loaded_paths_cache, loaded_cue_cache = cache_paths()
						# pre_get(order.target)
						if order.force_scan:
							gets(order.target, force_scan=True)
						else:
							gets(order.target)
					elif tauon.loaderCommand == LoaderCommand.FILE:
						loaded_paths_cache, loaded_cue_cache = cache_paths()
						add_file(order.target, show_errors=True)
						# i think this meaans if it's manually dragged in

					if gui.im_cancel:
						gui.im_cancel = False
						gui.to_get = 0
						gui.to_got = 0
						tauon.load_orders.clear()
						tauon.added = []
						tauon.loaderCommand = LoaderCommand.DONE
						tauon.loaderCommandReady = False
						break

					tauon.loaderCommand = LoaderCommand.DONE
					#logging.info("LOAD ORDER")
					order.tracks = tauon.added

					# Double check for cue dupes
					for i in reversed(range(len(order.tracks))):
						if pctl.master_library[order.tracks[i]].fullpath in bag.cue_list:
							if pctl.master_library[order.tracks[i]].is_cue is False:
								del order.tracks[i]

					tauon.added = []
					order.stage = 2
					tauon.loaderCommandReady = False
					#logging.info("DONE LOADING")
					break

def visit_radio_site(station: RadioStation) -> None:
	if station.website_url:
		webbrowser.open(station.website_url, new=2, autoraise=True)

def visit_radio_station(item: tuple[int, RadioStation]) -> None:
	visit_radio_site(item[1])

def window_is_focused(t_window: sdl3.LP_SDL_Window) -> bool:
	"""Thread safe?"""
	return bool(sdl3.SDL_GetWindowFlags(t_window) & sdl3.SDL_WINDOW_INPUT_FOCUS)

def menu_is_open() -> bool:
	for menu in Menu.instances:
		if menu.active:
			return True
	return False

#def load_savefile(latest_db_version: float, user_directory: Path, bag: Bag, prefs: Prefs, gui: GuiVar) -> tuple[list[TrackClass], dict, dict]:
#	return after_scan, search_string_cache, search_dia_string_cache

#def setup_menus(tauon: Tauon) -> tuple[MenuIcon, MenuIcon, MenuIcon]:
#	bag   = tauon.bag
#	gui   = tauon.gui
#	inp   = tauon.inp
#	pctl  = tauon.pctl
#	prefs = tauon.prefs


#def main_loop(tauon: Tauon) -> None:
#	bag            = tauon.bag
#	ddt            = tauon.ddt
#	gui            = tauon.gui
#	inp            = tauon.inp
#	dirs           = tauon.dirs
#	pctl           = tauon.pctl
#	prefs          = tauon.prefs
#	colours        = tauon.colours
#	radiobox       = tauon.radiobox
#	renderer       = tauon.renderer
#	t_window       = tauon.t_window
#	pref_box       = tauon.pref_box
#	keymaps        = tauon.gui.keymaps
#	user_directory = tauon.dirs.user_directory
#	window_size    = tauon.window_size
#	logical_size   = tauon.logical_size
#	playlist_menu         = tauon.playlist_menu
#	radio_entry_menu      = tauon.radio_entry_menu
#	showcase_menu         = tauon.showcase_menu
#	center_info_menu      = tauon.center_info_menu
#	gallery_menu          = tauon.gallery_menu
#	artist_info_menu      = tauon.artist_info_menu
#	repeat_menu           = tauon.repeat_menu
#	shuffle_menu          = tauon.shuffle_menu
#	artist_list_menu      = tauon.artist_list_menu
#	lightning_menu        = tauon.lightning_menu
#	lsp_menu              = tauon.lsp_menu
#	folder_tree_menu      = tauon.folder_tree_menu
#	folder_tree_stem_menu = tauon.folder_tree_stem_menu
#	radio_context_menu    = tauon.radio_context_menu
#	tab_menu              = tauon.tab_menu
#	extra_tab_menu        = tauon.extra_tab_menu
#	track_menu            = tauon.track_menu
#	selection_menu        = tauon.selection_menu
#	folder_menu           = tauon.folder_menu
#	picture_menu          = tauon.picture_menu
#	mode_menu             = tauon.mode_menu
#	extra_menu            = tauon.extra_menu

def main(holder: Holder) -> None:
	global milky_ready

	t_window               = holder.t_window
	renderer               = holder.renderer
	logical_size           = holder.logical_size
	window_size            = holder.window_size
	maximized              = holder.maximized
	scale                  = holder.scale
	window_opacity         = holder.window_opacity
	draw_border            = holder.draw_border
	transfer_args_and_exit = holder.transfer_args_and_exit
	old_window_position    = holder.old_window_position
	install_directory      = holder.install_directory
	user_directory         = holder.user_directory
	pyinstaller_mode       = holder.pyinstaller_mode
	phone                  = holder.phone
	window_default_size    = holder.window_default_size
	window_title           = holder.window_title
	fs_mode                = holder.fs_mode
	t_title                = holder.t_title
	n_version              = holder.n_version
	t_version              = holder.t_version
	t_id                   = holder.t_id
	t_agent                = holder.t_agent
	dev_mode               = holder.dev_mode
	log                    = holder.log
	logging.info(f"Window size: {window_size}; Logical size: {logical_size}")
	renderer_name = get_renderer_name(renderer)
	if renderer_name is not None:
		logging.info(f"SDL renderer: {renderer_name}")
	if not renderer_name_supports_milkdrop(renderer_name):
		logging.warning("SDL renderer is not OpenGL; disabling Milkdrop visualizer")
		milky_ready = False

	tls_context = setup_tls(holder)
	last_fm_enable = is_module_loaded("pylast")
	if last_fm_enable:
		# pyLast needs to be reimported AFTER setup_tls(), else pyinstaller breaks
		importlib.reload(pylast)

	discord_allow = is_module_loaded("pypresence", "ActivityType")
	#ctypes = sys.modules.get("ctypes")  # Fetch from loaded modules

	if sys.platform == "win32" and windows:
		font_folder = str(install_directory / "fonts")
		if os.path.isdir(font_folder):
			logging.info(f"Fonts directory:           {font_folder}")

			fc = ctypes.cdll.LoadLibrary("libfontconfig-1.dll")
			fc.FcConfigReference.restype = ctypes.c_void_p
			fc.FcConfigReference.argtypes = (ctypes.c_void_p,)
			fc.FcConfigAppFontAddDir.argtypes = (ctypes.c_void_p, ctypes.c_char_p)
			config = ctypes.c_void_p()
			config.contents = fc.FcConfigGetCurrent()
			fc.FcConfigAppFontAddDir(config.value, font_folder.encode())

	# Detect what desktop environment we are in to enable specific features
	desktop = os.environ.get("XDG_CURRENT_DESKTOP")
	# de_notify_support = desktop == 'GNOME' or desktop == 'KDE'
	de_notify_support = False
	draw_min_button = True
	draw_max_button = True
	left_window_control = False

	detect_macstyle = False
	gtk_settings: Gtk.Settings | None = None
	mac_close = ColourRGBA(253, 70, 70, 255)
	mac_maximize = ColourRGBA(254, 176, 36, 255)
	mac_minimize = ColourRGBA(42, 189, 49, 255)
	try:
		# TODO(Martin): Bump to 4.0 - https://github.com/Taiko2k/Tauon/issues/1316
		gi.require_version("Gtk", "3.0")
		from gi.repository import Gtk

		gtk_settings = Gtk.Settings().get_default()
		if "minimize" not in str(gtk_settings.get_property("gtk-decoration-layout")):
			draw_min_button = False
		if "maximize" not in str(gtk_settings.get_property("gtk-decoration-layout")):
			draw_max_button = False
		if "close" in str(gtk_settings.get_property("gtk-decoration-layout")).split(":")[0]:
			left_window_control = True
		gtk_theme = str(gtk_settings.get_property("gtk-theme-name")).lower()
		#logging.info(f"GTK theme is: {gtk_theme}")
		for k, v in mac_styles.items():
			if k in gtk_theme:
				detect_macstyle = True
				if v is not None:
					mac_close = v[0]
					mac_maximize = v[1]
					mac_minimize = v[2]

	except Exception:
		logging.exception("Error accessing GTK settings")

	# Set data folders (portable mode)
	config_directory = user_directory
	cache_directory = user_directory / "cache"
	home_directory = os.path.join(os.path.expanduser("~"))

	asset_directory = install_directory / "assets"
	svg_directory = install_directory / "assets" / "svg"
	scaled_asset_directory = user_directory / "scaled-icons"

	music_directory = Path("~").expanduser() / "Music"
	if not music_directory.is_dir():
		music_directory = Path("~").expanduser() / "music"

	download_directory = Path("~").expanduser() / "Downloads"

	# Detect if we are installed or running portable
	install_mode = False
	flatpak_mode = False
	snap_mode = False
	if str(install_directory).startswith(("/opt/", "/usr/", "/app/", "/snap/", "/nix/store/")):
		install_mode = True
		if str(install_directory)[:6] == "/snap/":
			snap_mode = True
		if str(install_directory)[:5] == "/app/":
			# Flatpak mode
			logging.info("Detected running as Flatpak")

			# [old / no longer used] Symlink fontconfig from host system as workaround for poor font rendering
			if os.path.exists(os.path.join(home_directory, ".var/app/com.github.taiko2k.tauonmb/config")):

				host_fcfg = os.path.join(home_directory, ".config/fontconfig/")
				flatpak_fcfg = os.path.join(home_directory, ".var/app/com.github.taiko2k.tauonmb/config/fontconfig")

				if os.path.exists(host_fcfg):

					# if os.path.isdir(flatpak_fcfg) and not os.path.islink(flatpak_fcfg):
					#	 shutil.rmtree(flatpak_fcfg)
					if os.path.islink(flatpak_fcfg):
						logging.info("-- Symlink to fonconfig exists, removing")
						os.unlink(flatpak_fcfg)
					# else:
					#	 logging.info("-- Symlinking user fonconfig")
					#	 #os.symlink(host_fcfg, flatpak_fcfg)

			flatpak_mode = True

	logging.info(f"Platform: {sys.platform}")

	if pyinstaller_mode:
		logging.info("Pyinstaller mode")

	# If we're installed, use home data locations
	if (install_mode) or macos or windows:
		cache_directory  = Path(GLib.get_user_cache_dir()) / "TauonMusicBox"
		#user_directory   = Path(GLib.get_user_data_dir()) / "TauonMusicBox"
		config_directory = user_directory

	#	if not user_directory.is_dir():
	#		os.makedirs(user_directory)

		if not config_directory.is_dir():
			os.makedirs(config_directory)

		if snap_mode:
			logging.info("Installed as Snap")
		elif flatpak_mode:
			logging.info("Installed as Flatpak")
		else:
			logging.info("Running from installed location")

		if not (user_directory / "encoder").is_dir():
			os.makedirs(user_directory / "encoder")

	else:
		logging.info("Running in portable mode")
		config_directory = user_directory

	milkdrop_preset_load_log = user_directory / "milkdrop-preset-load.log"
	if milkdrop_preset_load_log.exists():
		try:
			milkdrop_preset_load_log.unlink()
			logging.info("Removed previous Milkdrop preset load log: %s", milkdrop_preset_load_log)
		except Exception:
			logging.exception("Failed to remove previous Milkdrop preset load log: %s", milkdrop_preset_load_log)

	if not (user_directory / "state.p").is_file() and cache_directory.is_dir():
		logging.info("Clearing old cache directory")
		logging.info(cache_directory)
		shutil.rmtree(str(cache_directory))

	n_cache_dir = cache_directory / "network"
	e_cache_dir = cache_directory / "export"
	g_cache_dir = cache_directory / "gallery"
	a_cache_dir = cache_directory / "artist"
	r_cache_dir = cache_directory / "radio-thumbs"
	b_cache_dir = user_directory  / "artist-backgrounds"

	if not os.path.isdir(n_cache_dir):
		os.makedirs(n_cache_dir)
	if not os.path.isdir(e_cache_dir):
		os.makedirs(e_cache_dir)
	if not os.path.isdir(g_cache_dir):
		os.makedirs(g_cache_dir)
	if not os.path.isdir(a_cache_dir):
		os.makedirs(a_cache_dir)
	if not os.path.isdir(b_cache_dir):
		os.makedirs(b_cache_dir)
	if not os.path.isdir(r_cache_dir):
		os.makedirs(r_cache_dir)

	if not (user_directory / "artist-pictures").is_dir():
		os.makedirs(user_directory / "artist-pictures")

	if not (user_directory / "theme").is_dir():
		os.makedirs(user_directory / "theme")

	if platform_system == "Linux":
		system_config_directory = Path(GLib.get_user_config_dir())
		xdg_dir_file = system_config_directory / "user-dirs.dirs"

		if xdg_dir_file.is_file():
			with xdg_dir_file.open() as f:
				for line in f:
					if line.startswith("XDG_MUSIC_DIR="):
						music_directory = Path(os.path.expandvars(line.split("=")[1].strip().replace('"', ""))).expanduser()
						logging.debug(f"Found XDG-Music:     {music_directory}     in {xdg_dir_file}")
					if line.startswith("XDG_DOWNLOAD_DIR="):
						target = Path(os.path.expandvars(line.split("=")[1].strip().replace('"', ""))).expanduser()
						if Path(target).is_dir():
							download_directory = target
						logging.debug(f"Found XDG-Downloads: {download_directory} in {xdg_dir_file}")


	if os.getenv("XDG_MUSIC_DIR"):
		music_directory = Path(os.getenv("XDG_MUSIC_DIR"))
		logging.debug(f"Override music to: {music_directory}")

	if os.getenv("XDG_DOWNLOAD_DIR"):
		download_directory = Path(os.getenv("XDG_DOWNLOAD_DIR"))
		logging.debug(f"Override downloads to: {download_directory}")

	if music_directory:
		music_directory = Path(os.path.expandvars(music_directory))
	if download_directory:
		download_directory = Path(os.path.expandvars(download_directory))

	if not music_directory.is_dir():
		music_directory = None

	locale_directory = install_directory / "locale"
	if flatpak_mode:
		locale_directory = Path("/app/share/locale")
	#elif str(install_directory).startswith(("/opt/", "/usr/")):
	#	locale_directory = Path("/usr/share/locale")

	dirs = Directories(
		install_directory=install_directory,
		svg_directory=svg_directory,
		asset_directory=asset_directory,
		scaled_asset_directory=scaled_asset_directory,
		locale_directory=locale_directory,
		user_directory=user_directory,
		config_directory=config_directory,
		cache_directory=cache_directory,
		home_directory=home_directory,
		music_directory=music_directory,
		download_directory=download_directory,
		n_cache_directory=n_cache_dir,
		e_cache_directory=e_cache_dir,
		g_cache_directory=g_cache_dir,
		a_cache_directory=a_cache_dir,
		r_cache_directory=r_cache_dir,
		b_cache_directory=b_cache_dir,
	)

	logging.info(f"Install directory:         {install_directory}")
	#logging.info(f"SVG directory:             {svg_directory}")
	logging.info(f"Asset directory:           {asset_directory}")
	#logging.info(f"Scaled Asset Directory:    {scaled_asset_directory}")
	if locale_directory.exists():
		logging.info(f"Locale directory:          {locale_directory}")
	else:
		logging.error(f"Locale directory MISSING:  {locale_directory}")
	logging.info(f"Userdata directory:        {user_directory}")
	logging.info(f"Config directory:          {config_directory}")
	logging.info(f"Cache directory:           {cache_directory}")
	logging.info(f"Home directory:            {home_directory}")
	logging.info(f"Music directory:           {music_directory}")
	logging.info(f"Downloads directory:       {download_directory}")

	launch_prefix = ""
	if flatpak_mode:
		launch_prefix = "flatpak-spawn --host "

	if not macos:
		icon = sdl3.IMG_Load(str(asset_directory / "icon-64.png").encode())
		sdl3.SDL_SetWindowIcon(t_window, icon)

	if not phone:
		if window_size[0] != logical_size[0]:
			sdl3.SDL_SetWindowMinimumSize(t_window, 560, 330)
		else:
			sdl3.SDL_SetWindowMinimumSize(t_window, round(560 * scale), round(330 * scale))

	max_window_tex = 1000
	if window_size[0] > max_window_tex or window_size[1] > max_window_tex:
		while window_size[0] > max_window_tex:
			max_window_tex += 1000
		while window_size[1] > max_window_tex:
			max_window_tex += 1000

	main_texture = sdl3.SDL_CreateTexture(
		renderer, sdl3.SDL_PIXELFORMAT_ARGB8888, sdl3.SDL_TEXTUREACCESS_TARGET, max_window_tex,
		max_window_tex)
	main_texture_overlay_temp = sdl3.SDL_CreateTexture(
		renderer, sdl3.SDL_PIXELFORMAT_ARGB8888, sdl3.SDL_TEXTUREACCESS_TARGET,
		max_window_tex, max_window_tex)

	overlay_texture_texture = sdl3.SDL_CreateTexture(renderer, sdl3.SDL_PIXELFORMAT_ARGB8888, sdl3.SDL_TEXTUREACCESS_TARGET, 300, 300)
	sdl3.SDL_SetTextureBlendMode(overlay_texture_texture, sdl3.SDL_BLENDMODE_BLEND)
	sdl3.SDL_SetRenderTarget(renderer, overlay_texture_texture)
	sdl3.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0)
	sdl3.SDL_RenderClear(renderer)
	sdl3.SDL_SetRenderTarget(renderer, None)

	tracklist_texture = sdl3.SDL_CreateTexture(
		renderer, sdl3.SDL_PIXELFORMAT_ARGB8888, sdl3.SDL_TEXTUREACCESS_TARGET, max_window_tex,
		max_window_tex)
	tracklist_texture_rect = sdl3.SDL_FRect(0, 0, max_window_tex, max_window_tex)
	sdl3.SDL_SetTextureBlendMode(tracklist_texture, sdl3.SDL_BLENDMODE_BLEND)

	sdl3.SDL_SetRenderTarget(renderer, None)

	# Paint main texture
	sdl3.SDL_SetRenderTarget(renderer, main_texture)
	sdl3.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255)

	sdl3.SDL_SetRenderTarget(renderer, main_texture_overlay_temp)
	sdl3.SDL_SetTextureBlendMode(main_texture_overlay_temp, sdl3.SDL_BLENDMODE_BLEND)
	sdl3.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255)
	sdl3.SDL_RenderClear(renderer)



	# sdl3.SDL_SetRenderTarget(renderer, None)
	# sdl3.SDL_SetRenderDrawColor(renderer, 7, 7, 7, 255)
	# sdl3.SDL_RenderClear(renderer)
	# #sdl3.SDL_RenderPresent(renderer)

	# sdl3.SDL_SetWindowOpacity(t_window, window_opacity)

	loaded_asset_dc: dict[str, WhiteModImageAsset | LoadImageAsset] = {}
	# loading_image = asset_loader(bag, bag.loaded_asset_dc, "loading.png")

	if maximized:
		i_x = pointer(c_int(0))
		i_y = pointer(c_int(0))

		time.sleep(0.02)
		sdl3.SDL_PumpEvents()
		sdl3.SDL_GetWindowSize(t_window, i_x, i_y)
		logical_size[0] = i_x.contents.value
		logical_size[1] = i_y.contents.value
		sdl3.SDL_GetWindowSizeInPixels(t_window, i_x, i_y)
		window_size[0] = i_x.contents.value
		window_size[1] = i_y.contents.value

	# loading_image.render(window_size[0] // 2 - loading_image.w // 2, window_size[1] // 2 - loading_image.h // 2)
	# SDL_RenderPresent(renderer)

	if install_directory != config_directory and not (config_directory / "input.txt").is_file():
		logging.warning("Input config file is missing, first run? Copying input.txt template from templates directory")
		#logging.warning(install_directory)
		#logging.warning(config_directory)
		shutil.copy(install_directory / "templates" / "input.txt", config_directory)

	if snap_mode:
		discord_allow = False

	musicbrainzngs.set_useragent("TauonMusicBox", n_version, "https://github.com/Taiko2k/Tauon")

	# Detect locale for translations
	try:
		py_locale.setlocale(py_locale.LC_ALL, "")
	except Exception:
		logging.exception("SET LOCALE ERROR")

	wayland = True
	if os.environ.get("SDL_VIDEODRIVER") != "wayland":
		wayland = False
		os.environ["GDK_BACKEND"] = "x11"

	vis_update = False


	# Player Variables----------------------------------------------------------------------------
	Archive_Formats = {"zip"}

	if whicher("unrar", flatpak_mode):
		Archive_Formats.add("rar")

	if whicher("7z", flatpak_mode):
		Archive_Formats.add("7z")

	MOD_Formats = {"xm", "mod", "s3m", "it", "mptm", "umx", "okt", "mtm", "669", "far", "wow", "dmf", "med", "mt2", "ult"}
	GME_Formats = {"ay", "gbs", "gym", "hes", "kss", "nsf", "nsfe", "sap", "spc", "vgm", "vgz"}
	formats = Formats(
		colours = {
			"MP3":   ColourRGBA(255, 130, 80,  255),  # Burnt orange
			"FLAC":  ColourRGBA(156, 249, 79,  255),  # Bright lime green
			"M4A":   ColourRGBA(81,  220, 225, 255),  # Soft cyan
			"AIFF":  ColourRGBA(81,  220, 225, 255),  # Soft cyan
			"OGG":   ColourRGBA(244, 244, 78,  255),  # Light yellow
			"OGA":   ColourRGBA(244, 244, 78,  255),  # Light yellow
			"WMA":   ColourRGBA(213, 79,  247, 255),  # Magenta
			"APE":   ColourRGBA(247, 79,  79,  255),  # Deep pink
			"TTA":   ColourRGBA(94,  78,  244, 255),  # Purple
			"OPUS":  ColourRGBA(247, 79,  146, 255),  # Pink
			"AAC":   ColourRGBA(79,  247, 168, 255),  # Teal
			"WV":    ColourRGBA(229, 23,  18,  255),  # Deep red
			"PLEX":  ColourRGBA(229, 160, 13,  255),  # Orange-brown
			"TAU":   ColourRGBA(111, 98,  190, 255),  # Lavender
			"SUB":   ColourRGBA(235, 140, 20,  255),  # Golden yellow
			"TIDAL": ColourRGBA(0,   0,   0,   255),  # Black
			"JELY":  ColourRGBA(190, 100, 210, 255),  # Fuchsia
			"XM":    ColourRGBA(50,  50,  50,  255),  # Grey
			"MOD":   ColourRGBA(50,  50,  50,  255),  # Grey
			"S3M":   ColourRGBA(50,  50,  50,  255),  # Grey
			"IT":    ColourRGBA(50,  50,  50,  255),  # Grey
			"MPTM":  ColourRGBA(50,  50,  50,  255),  # Grey
			"AY":    ColourRGBA(237, 212, 255, 255),  # Pastel purple
			"GBS":   ColourRGBA(255, 165, 0,   255),  # Vibrant orange
			"GYM":   ColourRGBA(0,   191, 255, 255),  # Bright blue
			"HES":   ColourRGBA(176, 224, 230, 255),  # Light blue-green
			"KSS":   ColourRGBA(255, 255, 153, 255),  # Bright yellow
			"NSF":   ColourRGBA(255, 140, 0,   255),  # Deep orange
			"NSFE":  ColourRGBA(255, 140, 0,   255),  # Deep orange
			"SAP":   ColourRGBA(152, 255, 152, 255),  # Light green
			"SPC":   ColourRGBA(255, 128, 0,   255),  # Bright orange
			"VGM":   ColourRGBA(0,   128, 255, 255),  # Deep blue
			"VGZ":   ColourRGBA(0,   128, 255, 255),  # Deep blue
		},
		VID = {"mp4", "webm"},
		MOD = MOD_Formats,
		GME = GME_Formats,
		DA = {
			"mp3", "wav", "opus", "flac", "ape", "aiff",
			"m4a", "m4b", "ogg", "oga", "aac", "tta", "wv", "wma",
		} | MOD_Formats | GME_Formats,
		Archive = Archive_Formats,
	)

	# Library and loader Variables--------------------------------------------------------
	db_version: float = 0.0
	latest_db_version: float = 79

	rename_files_previous = ""
	rename_folder_previous = ""

	radio_playlists: list[RadioPlaylist] = [RadioPlaylist(uid=uid_gen(), name="Default", stations=[])]

	fonts = Fonts()
	colours = ColoursClass()
	colours.post_config()

	mpt: CDLL | None = None
	p = ctypes.util.find_library("openmpt") # Linux
	p = p or ctypes.util.find_library("libopenmpt-0") # Windows
	try:
		if p:
			mpt = ctypes.cdll.LoadLibrary(p)
		elif windows:
			mpt = ctypes.cdll.LoadLibrary("libopenmpt-0.dll")
		else:
			mpt = ctypes.cdll.LoadLibrary("libopenmpt.so.0")

		mpt.openmpt_module_create_from_memory.restype = c_void_p
		mpt.openmpt_module_get_metadata.restype = c_char_p
		mpt.openmpt_module_get_duration_seconds.restype = c_double
	except Exception:
		logging.exception("Failed to load libopenmpt!")

	gme: CDLL | None = None
	p = ctypes.util.find_library("gme") # Linux
	p = p or ctypes.util.find_library("libgme") # Windows
	try:
		if p:
			gme = ctypes.cdll.LoadLibrary(p)
		elif windows:
			gme = ctypes.cdll.LoadLibrary("libgme.dll")
		else:
			gme = ctypes.cdll.LoadLibrary("libgme.so.0")

		gme.gme_free_info.argtypes = [ctypes.POINTER(GMETrackInfo)]
		gme.gme_track_info.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.POINTER(GMETrackInfo)), ctypes.c_int]
		gme.gme_track_info.restype = ctypes.c_char_p
		gme.gme_open_file.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_void_p), ctypes.c_int]
		gme.gme_open_file.restype = ctypes.c_char_p
	except Exception:
		logging.exception("Cannot find libgme")

	force_subpixel_text = False
	if gtk_settings and gtk_settings.get_property("gtk-xft-rgba") == "rgb":
		force_subpixel_text = True
	encoder_output = user_directory / "encoder" if music_directory is None else music_directory / "encode-output"
	power_save = False
	if macos or phone:
		power_save = True

	# TODO(Taiko): This is legacy. New settings are added straight to the save list (need to overhaul)
	view_prefs = {
		"split-line": True,
		"update-title": False,
		"star-lines": False,
		"side-panel": True,
		"dim-art": False,
		"pl-follow": False,
		"scroll-enable": True,
		"smooth-scroll-enable": False,
		"smooth-scroll-speed": 1.0,
	}

	prefs = Prefs(
		view_prefs=view_prefs,
		power_save=power_save,
		encoder_output=encoder_output,
		force_subpixel_text=force_subpixel_text,
		macos=macos,
		macstyle=macos or detect_macstyle,
		left_window_control=macos or left_window_control,
		phone=phone,
		discord_allow=discord_allow,
		desktop=desktop,
		window_opacity=window_opacity,
		ui_scale=scale,
	)
	prefs.theme = get_theme_number(dirs, prefs.theme_name)

	bag = Bag(
		cf=Config(),
		dev_mode=dev_mode,
		gme=gme,
		mpt=mpt,
		colours=colours,
		console=DConsole(),
		dirs=dirs,
		prefs=prefs,
		fonts=fonts,
		formats=formats,
		renderer=renderer,
		#sdl_syswminfo=sss,
		pump=True,
		wayland=wayland,
		# de_notify_support = desktop == 'GNOME' or desktop == 'KDE'
		de_notify_support=False,
		log=log,
		draw_min_button=draw_min_button,
		draw_max_button=draw_max_button,
		download_directories=[],
		overlay_texture_texture=overlay_texture_texture,
		smtc=False,
		macos=macos,
		mac_close=mac_close,
		mac_maximize=mac_maximize,
		mac_minimize=mac_minimize,
		windows=windows,
		phone=phone,
		should_save_state=True,
		old_window_position=old_window_position,
		desktop=desktop,
		platform_system=platform_system,
		last_fm_enable=last_fm_enable,
		launch_prefix=launch_prefix,
		latest_db_version=latest_db_version,
		flatpak_mode=flatpak_mode,
		snap_mode=snap_mode,
		master_count=0,
		playing_in_queue=0,
		playlist_playing=-1,
		playlist_view_position=0,
		selected_in_playlist=-1,
		album_mode_art_size=int(200 * scale),
		primary_stations=[],
		tls_context=tls_context,
		track_queue=[],
		volume=75,
		multi_playlist=[],
		cue_list=[],
		p_force_queue=[],
		logical_size=logical_size,
		window_size=window_size,
		gen_codes={},
		master_library={},
		loaded_asset_dc=loaded_asset_dc,
		radio_playlist_viewing=0,
		radio_playlists=radio_playlists,
		folder_image_offsets={},
	)
	del radio_playlists

	# If scaled-icons directory exists, use it even for initial loading
	if (user_directory / "scaled-icons").exists() and bag.prefs.ui_scale != 1:
		bag.dirs.scaled_asset_directory = user_directory / "scaled-icons"

	gui = GuiVar(
		bag=bag,
		tracklist_texture_rect=tracklist_texture_rect,
		tracklist_texture=tracklist_texture,
		main_texture_overlay_temp=main_texture_overlay_temp,
		main_texture=main_texture,
		max_window_tex=max_window_tex,
	)
	del max_window_tex

	inp = gui.inp
	keymaps = gui.keymaps
	# GUI Variables -------------------------------------------------------------------------------------------
	# Variables now go in the gui, pctl, input and prefs class instances. The following just haven't been moved yet
	# -----------------------------------------------------
	# STATE LOADING
	# Loading of program data from previous run
	gbc.disable()

	if (user_directory / "lyrics_substitutions.json").is_file():
		try:
			with (user_directory / "lyrics_substitutions.json").open() as f:
				prefs.lyrics_subs = json.load(f)
		except FileNotFoundError:
			logging.error("No existing lyrics_substitutions.json file")  # noqa: TRY400
		except Exception:
			logging.exception("Unknown error loading lyrics_substitutions.json")

	perf_timer = Timer()
	perf_timer.set()

	bag.primary_stations.append(RadioStation(
		title="SomaFM Groove Salad",
		stream_url="https://ice3.somafm.com/groovesalad-128-mp3",
		country="USA",
		website_url="https://somafm.com/groovesalad",
		icon="https://somafm.com/logos/120/groovesalad120.png"))

	bag.primary_stations.append(RadioStation(
		title="SomaFM PopTron",
		stream_url="https://ice3.somafm.com/poptron-128-mp3",
		country="USA",
		website_url="https://somafm.com/poptron/",
		icon="https://somafm.com/logos/120/poptron120.jpg"))

	bag.primary_stations.append(RadioStation(
		title="SomaFM Vaporwaves",
		stream_url="https://ice4.somafm.com/vaporwaves-128-mp3",
		country="USA",
		website_url="https://somafm.com/vaporwaves",
		icon="https://somafm.com/img3/vaporwaves400.png"))

	bag.primary_stations.append(RadioStation(
		title="DKFM Shoegaze Radio",
		stream_url="https://kathy.torontocast.com:2005/stream",
		country="Canada",
		website_url="https://decayfm.com",
		icon="https://cdn-profiles.tunein.com/s193842/images/logod.png"))

	for station in bag.primary_stations:
		bag.radio_playlists[0].stations.append(station)

	# shoot_pump = threading.Thread(target=pumper, args=(bag,))
	# shoot_pump.daemon = True
	# shoot_pump.start()

	#after_scan, search_string_cache, search_dia_string_cache = load_savefile(latest_db_version, user_directory, bag, prefs, gui)
	after_scan: list[TrackClass] = []
	search_string_cache          = {}
	search_dia_string_cache      = {}
	state_path1 = user_directory / "state.p"
	state_path2 = user_directory / "state.p.backup"
	# Legacy TrackClass.misc dicts pulled from pre-v79 saves, keyed by track
	# index. Distributed into the new __slots__ fields by the v79 migration.
	legacy_track_misc: dict[int, dict] = {}
	for t in range(2):
		#	 os.path.getsize(user_directory / "state.p") < 100
		try:
			if t == 0:
				if not state_path1.is_file():
					continue
				with state_path1.open("rb") as file:
					save = pickle.load(file)
			if t == 1:
				if not state_path2.is_file():
					logging.warning("State database file is missing, first run? Will create one anew!")
					break
				logging.warning("Loading backup state.p!")
				with state_path2.open("rb") as file:
					save = pickle.load(file)

			# def tt():
			#	 while True:
			#		 logging.info(state_file.tell())
			#		 time.sleep(0.01)
			# shooter(tt)

			db_version = save[17]
			if db_version != latest_db_version:
				if db_version > latest_db_version:
					logging.critical(f"Loaded DB version: '{db_version}' is newer than latest known DB version '{latest_db_version}', refusing to load!\nAre you running an out of date Tauon version using Configuration directory from a newer one?")
					sys.exit(42)
				logging.warning(f"Loaded older DB version: {db_version}")
			if len(save) > 63 and save[63] is not None:
				prefs.ui_scale = save[63]
				# prefs.ui_scale = 1.3
				# gui.__init__()

			if len(save) > 0 and save[0] is not None:
				bag.master_library = save[0]
				# try: # TODO(Taiko): remove me before release!
				# 	from watchpoints import watch
				# 	def logchange3(frame, elem, exec_info):
				# 		logging.warning(f"Master library was modified! @ {exec_info}")
				#
				# 	watch(bag.master_library, callback=logchange3)
				# except Exception:
				# 	logging.exception("Module Watchpoints not found")
			bag.master_count = save[1]
			# try: # TODO(Taiko): remove me before release!
			# 	from watchpoints import watch
			# 	def logchange2(frame, elem, exec_info):
			# 		logging.warning(f"Master count was modified! @ {exec_info}")
			#
			# 	watch(bag.master_count, callback=logchange2)
			# except Exception:
			# 	logging.exception("Module Watchpoints not found")

			bag.playlist_playing = save[2]
			bag.active_playlist_viewing = save[3]
			bag.playlist_view_position = save[4]
			if len(save) > 5 and save[5] is not None:
				if db_version > 68:
					bag.multi_playlist = []
					tauonplaylist_jar = save[5]
					for i, d in enumerate(tauonplaylist_jar):
						p = TauonPlaylist(**d)
						bag.multi_playlist.append(p)

						# try:  # TODO(Taiko): remove me before release!
						# 	from watchpoints import watch
						# 	def logchange(frame, elem, exec_info):
						# 		logging.warning(f"A playlist was modified! @ {exec_info}")
						# 	watch(p.playlist_ids, callback=logchange)
						# except Exception:
						# 	logging.exception("Module Watchpoints not found")

						if i == bag.active_playlist_viewing:
							bag.default_playlist = p.playlist_ids
				else:
					bag.multi_playlist = save[5]
			bag.volume = save[6]
			bag.track_queue = save[7]
			bag.playing_in_queue = save[8]
			# bag.default_playlist = save[9]  # value is now set above
			# bag.playlist_playing = save[10]
			# cue_list = save[11]
			# radio_field_text = save[12]
			prefs.theme = save[13]
			bag.folder_image_offsets = save[14]
			# lfm_username = save[15]
			# lfm_hash = save[16]
			prefs.view_prefs = save[18]
			# window_size = save[19]
			gui.save_size = copy.copy(save[19])
			gui.rspw = save[20]
			# savetime = save[21]
			gui.vis_want = save[22]
			bag.selected_in_playlist = save[23]
			if len(save) > 24 and save[24] is not None:
				bag.album_mode_art_size = save[24]
			if len(save) > 25 and save[25] is not None:
				draw_border = save[25]
			if len(save) > 26 and save[26] is not None:
				prefs.enable_web = save[26]
			if len(save) > 27 and save[27] is not None:
				prefs.allow_remote = save[27]
			if len(save) > 28 and save[28] is not None:
				prefs.expose_web = save[28]
			# save[29] stored prefs.enable_transcode in older versions; transcode folder is now always shown
			if len(save) > 30 and save[30] is not None:
				prefs.show_rym = save[30]
			# if len(save) > 31 and save[31] is not None:
			#	 combo_mode_art_size = save[31]
			if len(save) > 32 and save[32] is not None:
				gui.maximized = save[32]
			if len(save) > 33 and save[33] is not None:
				prefs.prefer_bottom_title = save[33]
			if len(save) > 34 and save[34] is not None:
				gui.display_time_mode = save[34]
			# if len(save) > 35 and save[35] is not None:
			#	 prefs.transcode_mode = save[35]
			if len(save) > 36 and save[36] is not None:
				prefs.transcode_codec = save[36]
			if len(save) > 37 and save[37] is not None:
				prefs.transcode_bitrate = save[37]
			# if len(save) > 38 and save[38] is not None:
			#	 prefs.line_style = save[38]
			# if len(save) > 39 and save[39] is not None:
			#	 prefs.cache_gallery = save[39]
			if len(save) > 40 and save[40] is not None:
				prefs.playlist_font_size = save[40]
			if len(save) > 41 and save[41] is not None:
				prefs.use_title = save[41]
			if len(save) > 42 and save[42] is not None:
				gui.pl_st = save[42]
			# if len(save) > 43 and save[43] is not None:
			#	 gui.set_mode = save[43]
			#	 gui.set_bar = gui.set_mode
			if len(save) > 45 and save[45] is not None:
				prefs.playlist_row_height = save[45]
			if len(save) > 46 and save[46] is not None:
				prefs.show_wiki = save[46]
			if len(save) > 47 and save[47] is not None:
				prefs.auto_extract = save[47]
			if len(save) > 48 and save[48] is not None:
				prefs.colour_from_image = save[48]
			if len(save) > 49 and save[49] is not None:
				gui.set_bar = save[49]
			if len(save) > 50 and save[50] is not None:
				gui.gallery_show_text = save[50]
			if len(save) > 51 and save[51] is not None:
				gui.bb_show_art = save[51]
			# if len(save) > 52 and save[52] is not None:
			#	 gui.show_stars = save[52]
			if len(save) > 53 and save[53] is not None:
				prefs.auto_lfm = save[53]
			if len(save) > 54 and save[54] is not None:
				prefs.scrobble_mark = save[54]
			if len(save) > 55 and save[55] is not None:
				prefs.replay_gain = save[55]
			# if len(save) > 56 and save[56] is not None:
			#	 prefs.radio_page_lyrics = save[56]
			if len(save) > 57 and save[57] is not None:
				prefs.show_gimage = save[57]
			if len(save) > 58 and save[58] is not None:
				prefs.end_setting = save[58]
			if len(save) > 59 and save[59] is not None:
				prefs.show_gen = save[59]
			# if len(save) > 60 and save[60] is not None:
			#	 url_saves = save[60]
			if len(save) > 61 and save[61] is not None:
				prefs.auto_del_zip = save[61]
			if len(save) > 62 and save[62] is not None:
				gui.level_meter_colour_mode = save[62]
			if len(save) > 64 and save[64] is not None:
				prefs.show_lyrics_side = save[64]
			# if len(save) > 65 and save[65] is not None:
			#	 prefs.last_device = save[65]
			if len(save) > 66 and save[66] is not None:
				gui.restart_album_mode = save[66]
			if len(save) > 67 and save[67] is not None:
				gui.album_playlist_width = save[67]
			if len(save) > 68 and save[68] is not None:
				prefs.transcode_opus_as = save[68]
			if len(save) > 69 and save[69] is not None:
				gui.star_mode = save[69]
				if gui.star_mode == "star":
					gui.star_mode = "none"
					prefs.rating_playtime_stars = True
			if len(save) > 70 and save[70] is not None:
				gui.rsp = save[70]
			if len(save) > 71 and save[71] is not None:
				gui.lsp = save[71]
			if len(save) > 72 and save[72] is not None:
				gui.rspw = save[72]
			if len(save) > 73 and save[73] is not None:
				gui.pref_gallery_w = save[73]
			if len(save) > 74 and save[74] is not None:
				gui.pref_rspw = save[74]
			if len(save) > 75 and save[75] is not None:
				gui.show_hearts = save[75]
			if len(save) > 76 and save[76] is not None:
				prefs.monitor_downloads = save[76]
			if len(save) > 77 and save[77] is not None:
				gui.artist_info_panel = save[77]
			if len(save) > 78 and save[78] is not None:
				prefs.extract_to_music = save[78]
			if len(save) > 79 and save[79] is not None:
				prefs.enable_lb = save[79]
			# if len(save) > 80 and save[80] is not None:
			#	 prefs.lb_token = save[80]
			#	 if prefs.lb_token is None:
			#		 prefs.lb_token = ""
			if len(save) > 81 and save[81] is not None:
				rename_files_previous = save[81]
			if len(save) > 82 and save[82] is not None:
				rename_folder_previous = save[82]
			if len(save) > 83 and save[83] is not None:
				prefs.use_jump_crossfade = save[83]
			if len(save) > 84 and save[84] is not None:
				prefs.use_transition_crossfade = save[84]
			if len(save) > 85 and save[85] is not None:
				prefs.show_notifications = save[85]
			# if len(save) > 86 and save[86] is not None:
			#	 prefs.true_shuffle = save[86]
			if len(save) > 87 and save[87] is not None:
				gui.remember_library_mode = save[87]
			# if len(save) > 88 and save[88] is not None:
			#	 prefs.show_queue = save[88]
			# if len(save) > 89 and save[89] is not None:
			#	 prefs.show_transfer = save[89]
			if len(save) > 90 and save[90] is not None:
				if db_version > 68:
					tauonqueueitem_jar = save[90]
					for d in tauonqueueitem_jar:
						nt = TauonQueueItem(**d)
						bag.p_force_queue.append(nt)
				else:
					bag.p_force_queue = save[90]
			if len(save) > 91 and save[91] is not None:
				prefs.use_pause_fade = save[91]
			if len(save) > 92 and save[92] is not None:
				prefs.append_total_time = save[92]
			if len(save) > 93 and save[93] is not None:
				prefs.backend = save[93]  # moved to config file
			if len(save) > 94 and save[94] is not None:
				prefs.album_shuffle_mode = save[94]
			if len(save) > 95 and save[95] is not None:
				prefs.album_repeat_mode = save[95]
			# if len(save) > 96 and save[96] is not None:
			#	prefs.finish_current = save[96]
			if len(save) > 97 and save[97] is not None:
				prefs.reload_state = save[97]
			# if len(save) > 98 and save[98] is not None:
			#	prefs.reload_play_state = save[98]
			if len(save) > 99 and save[99] is not None:
				prefs.last_fm_token = save[99]
			if len(save) > 100 and save[100] is not None:
				prefs.last_fm_username = save[100]
			# if len(save) > 101 and save[101] is not None:
			#	prefs.use_card_style = save[101]
			# if len(save) > 102 and save[102] is not None:
			#	prefs.auto_lyrics = save[102]
			if len(save) > 103 and save[103] is not None:
				prefs.auto_lyrics_checked = save[103]
			if len(save) > 104 and save[104] is not None:
				prefs.show_side_art = save[104]
			if len(save) > 105 and save[105] is not None:
				prefs.window_opacity = save[105]
			if len(save) > 106 and save[106] is not None:
				prefs.gallery_single_click = save[106]
			if len(save) > 107 and save[107] is not None:
				prefs.tabs_on_top = save[107]
			if len(save) > 108 and save[108] is not None:
				prefs.showcase_vis = save[108]
			if len(save) > 109 and save[109] is not None:
				prefs.spec2_colour_mode = save[109]
			# if len(save) > 110 and save[110] is not None:
			#	prefs.device_buffer = save[110]
			if len(save) > 111 and save[111] is not None:
				prefs.use_eq = save[111]
			if len(save) > 112 and save[112] is not None:
				prefs.eq = save[112]
			if len(save) > 113 and save[113] is not None:
				prefs.bio_large = save[113]
			if len(save) > 114 and save[114] is not None:
				prefs.discord_show = save[114]
			if len(save) > 115 and save[115] is not None:
				prefs.min_to_tray = save[115]
			if len(save) > 116 and save[116] is not None:
				prefs.guitar_chords = save[116]
			if len(save) > 117 and save[117] is not None:
				prefs.playback_follow_cursor = save[117]
			if len(save) > 118 and save[118] is not None:
				prefs.art_bg = save[118]
			if len(save) > 119 and save[119] is not None:
				prefs.random_mode = save[119]
			if len(save) > 120 and save[120] is not None:
				prefs.repeat_mode = save[120]
			if len(save) > 121 and save[121] is not None:
				prefs.art_bg_stronger = save[121]
			if len(save) > 122 and save[122] is not None:
				prefs.art_bg_always_blur = save[122]
			if len(save) > 123 and save[123] is not None:
				prefs.failed_artists = save[123]
			if len(save) > 124 and save[124] is not None:
				prefs.artist_list = save[124]
			if len(save) > 125 and save[125] is not None:
				prefs.auto_sort = save[125]
			if len(save) > 126 and save[126] is not None:
				prefs.lyrics_enables = save[126]
			if len(save) > 127 and save[127] is not None:
				prefs.fanart_notify = save[127]
			if len(save) > 129 and save[129] is not None:
				prefs.discogs_pat = save[129]
			if len(save) > 130 and save[130] is not None:
				prefs.mini_mode_mode = save[130]
			if len(save) > 131 and save[131] is not None:
				after_scan = save[131]
			if len(save) > 132 and save[132] is not None:
				gui.gallery_positions = save[132]
			if len(save) > 133 and save[133] is not None:
				prefs.chart_bg = save[133]
			if len(save) > 134 and save[134] is not None:
				prefs.left_panel_mode = save[134]
			if len(save) > 135 and save[135] is not None:
				gui.last_left_panel_mode = save[135]
			# if len(save) > 136 and save[136] is not None:
			#	prefs.gst_device = save[136]
			if len(save) > 137 and save[137] is not None:
				search_string_cache = save[137]
			if len(save) > 138 and save[138] is not None:
				search_dia_string_cache = save[138]
			if len(save) > 139 and save[139] is not None:
				bag.gen_codes = save[139]
			if len(save) > 140 and save[140] is not None:
				gui.show_ratings = save[140]
			if len(save) > 141 and save[141] is not None:
				gui.show_album_ratings = save[141]
			if len(save) > 142 and save[142] is not None:
				prefs.radio_urls = save[142]
			if len(save) > 143 and save[143] is not None:
				gui.restore_showcase_view = save[143]
			if len(save) > 144 and save[144] is not None:
				gui.saved_prime_tab = save[144]
			if len(save) > 145 and save[145] is not None:
				gui.saved_prime_direction = save[145]
			if len(save) > 146 and save[146] is not None:
				prefs.sync_playlist = save[146]
			if len(save) > 149 and save[149] is not None:
				prefs.show_band = save[149]
			if len(save) > 150 and save[150] is not None:
				prefs.download_playlist = save[150]
			if len(save) > 152 and save[152] is not None:
				prefs.auto_rec = save[152]
			if len(save) > 154 and save[154] is not None:
				prefs.use_libre_fm = save[154]
			if len(save) > 155 and save[155] is not None:
				prefs.old_playlist_box_position = save[155]
			if len(save) > 156 and save[156] is not None:
				prefs.artist_list_sort_mode = save[156]
			if len(save) > 157 and save[157] is not None:
				prefs.phazor_device_selected = save[157]
			if len(save) > 158 and save[158] is not None:
				prefs.failed_background_artists = save[158]
			if len(save) > 159 and save[159] is not None:
				prefs.bg_flips = save[159]
			if len(save) > 160 and save[160] is not None:
				prefs.tray_show_title = save[160]
			if len(save) > 161 and save[161] is not None:
				prefs.artist_list_style = save[161]
			if len(save) > 162 and save[162] is not None:
				trackclass_jar = save[162]
				collect_legacy_misc = 0 < db_version <= 78  # noqa: PLR2004
				for d in trackclass_jar:
					nt = TrackClass()
					for k, v in d.items():
						try:
							setattr(nt, k, v)
						except AttributeError:
							pass
					# Pre-v79 saves stored extended metadata in a "misc" dict,
					# which is no longer a TrackClass attribute. Hold onto it so
					# the v79 migration can spread it into the new fields.
					if collect_legacy_misc and "misc" in d:
						legacy_track_misc[nt.index] = d["misc"]
					intern_track_strings(nt)
					bag.master_library[nt.index] = nt
			if len(save) > 163 and save[163] is not None:
				prefs.premium = save[163]
			if len(save) > 164 and save[164] is not None:
				gui.restore_radio_view = save[164]
			if len(save) > 165 and save[165] is not None:
				if db_version > 69:
					bag.radio_playlists = []
					radioplaylist_jar = save[165]
					for d in radioplaylist_jar:
						nt = RadioPlaylist(**d)
						bag.radio_playlists.append(nt)
				else:
					bag.radio_playlists = save[165]
			if len(save) > 166 and save[166] is not None:
				bag.radio_playlist_viewing = save[166]
			if len(save) > 167 and save[167] is not None:
				prefs.radio_thumb_bans = save[167]
			if len(save) > 168 and save[168] is not None:
				prefs.playlist_exports = save[168]
			if len(save) > 169 and save[169] is not None:
				prefs.show_chromecast = save[169]
			if len(save) > 170 and save[170] is not None:
				prefs.cache_list = save[170]
			if len(save) > 171 and save[171] is not None:
				prefs.shuffle_lock = save[171]
			if len(save) > 172 and save[172] is not None:
				prefs.album_shuffle_lock_mode = save[172]
			if len(save) > 173 and save[173] is not None:
				gui.was_radio = save[173]
			if len(save) > 176 and save[176] is not None:
				prefs.artist_list_threshold = save[176]
			if len(save) > 177 and save[177] is not None:
				prefs.tray_theme = save[177]
			if len(save) > 178 and save[178] is not None:
				prefs.row_title_format = save[178]
			if len(save) > 179 and save[179] is not None:
				prefs.row_title_genre = save[179]
			if len(save) > 180 and save[180] is not None:
				prefs.row_title_separator_type = save[180]
			if len(save) > 181 and save[181] is not None:
				prefs.replay_preamp = save[181]
			if len(save) > 182 and save[182] is not None:
				prefs.gallery_combine_disc = save[182]
			if len(save) > 183 and save[183] is not None:
				bag.active_playlist_playing = save[183]
			if len(save) > 184 and save[184] is not None:
				prefs.milk = save[184]
			if len(save) > 185 and save[185] is not None:
				prefs.auto_milk = save[185]
			if len(save) > 186 and save[186] is not None:
				# Stored as str since v77 (pre-77 saves pickled a Path); Path()
				# normalises either form to the runtime type.
				prefs.loaded_preset = Path(save[186])
			if len(save) > 187 and save[187] is not None:
				bag.loaded_stop_mode = save[187]
			if len(save) > 188 and save[188] is not None:
				bag.loaded_stop_ref = save[188]
			if len(save) > 189 and save[189] is not None:
				prefs.start_in_tray = save[189]
			if len(save) > 190 and save[190] is not None:
				# Resume in the Custom Layout view. The layout itself loads
				# lazily (ensure_slot -> load_slots) on the first render.
				gui.custom_mode = save[190]
			if len(save) > 191 and save[191] is not None:
				prefs.spectrogram_colour = save[191]
			if len(save) > 192 and save[192] is not None:
				gui.pl_st_left = save[192]
			if len(save) > 193 and save[193] is not None:
				prefs.milk_cut_out = save[193]
			if len(save) > 194 and save[194] is not None:
				prefs.milk_favorite_presets = save[194]

			del save
			break

		except IndexError:
			logging.exception("Index error")
			break
		except Exception:
			logging.exception("Failed to load save file")

	core_timer = Timer()
	core_timer.set()
	logging.info(f"Database loaded in {round(perf_timer.get(), 3)} seconds.")

	perf_timer.set()
	keys = set(bag.master_library.keys())
	for pl in bag.multi_playlist:
		if db_version > 68 or db_version == 0:
			keys -= set(pl.playlist_ids)
		else:
			keys -= set(pl[2])
	if len(keys) > 5000:
		gui.suggest_clean_db = True
	# logging.info(f"Database scanned in {round(perf_timer.get(), 3)} seconds.")

	# bag.pump = False
	# shoot_pump.join()

	# temporary
	if window_size is None:
		window_size = window_default_size
		gui.rspw = 200

	# The queue and its position are persisted separately. An interrupted save
	# or a queue edited during shutdown can leave the position out of range.
	# Keep an empty queue at its neutral position instead of turning it into -1.
	if bag.track_queue:
		bag.playing_in_queue = max(0, min(bag.playing_in_queue, len(bag.track_queue) - 1))
	else:
		bag.playing_in_queue = 0

	shoot = threading.Thread(target=keymaps.load)
	shoot.daemon = True
	shoot.start()

	# Loading Config -----------------


	if download_directory.is_dir():
		bag.download_directories.append(str(download_directory))

	if music_directory is not None and music_directory.is_dir():
		bag.download_directories.append(str(music_directory))

	load_prefs(bag)
	show_upgrade_splash = False
	if prefs.release_splash_version != NagBox.SPLASH_VERSION:
		show_upgrade_splash = db_version > 0
		prefs.release_splash_version = NagBox.SPLASH_VERSION
	save_prefs(bag)

	# Temporary
	if 0 < db_version <= 34:
		prefs.theme_name = get_theme_name(dirs, prefs.theme)
	if 0 < db_version <= 66:
		prefs.device_buffer = 80
	if 0 < db_version <= 53:
		logging.info("Resetting fonts to defaults")
		prefs.linux_font = "Noto Sans"
		prefs.linux_font_semibold = "Noto Sans Medium"
		prefs.linux_font_bold = "Noto Sans Bold"
		save_prefs(bag)

	# Auto detect lang
	lang: list[str] | None = None
	if prefs.ui_lang != "auto" or prefs.ui_lang == "":
		# Force set lang
		lang = [prefs.ui_lang]

	f = gettext.find("tauon", localedir=str(locale_directory), languages=lang)
	if f:
		translation = gettext.translation("tauon", localedir=str(locale_directory), languages=lang)
		translation.install()
		builtins._ = translation.gettext

		logging.info(f"Translation file for '{lang}' loaded")
	elif lang:
		logging.error(f"No translation file available for '{lang}'")

	# ----

	# sss = SDL_SysWMinfo()
	# SDL_GetWindowWMInfo(t_window, sss)

	if prefs.use_gamepad:
		sdl3.SDL_InitSubSystem(sdl3.SDL_INIT_GAMEPAD)

	if bag.windows and win_ver >= 10:
		#logging.info(sss.info.win.window)
		SMTC_path = install_directory / "lib" / "TauonSMTC.dll"
		if SMTC_path.exists():
			try:
				bag.sm = ctypes.cdll.LoadLibrary(str(SMTC_path))

				def SMTC_button_callback(button: int) -> None:
					logging.debug(f"SMTC sent key ID: {button}")
					if button == 1:
						inp.media_key = "Play"
					if button == 2:
						inp.media_key = "Pause"
					if button == 3:
						inp.media_key = "Next"
					if button == 4:
						inp.media_key = "Previous"
					if button == 5:
						inp.media_key = "Stop"
					gui.update += 1
					tauon.wake()

				close_callback = ctypes.WINFUNCTYPE(ctypes.c_void_p, ctypes.c_int)(SMTC_button_callback)
				bag.smtc = bag.sm.init(close_callback) == 0
			except Exception:
				logging.exception("Failed to load TauonSMTC.dll - Media keys will not work!")
		else:
			logging.warning("Failed to load TauonSMTC.dll - Media keys will not work!")

	if bag.macos:
		# macOS Now Playing helper (native app) - communicates over stdin/stdout JSON lines.
		helper_exe: Path | None = None
		candidate_paths: list[Path] = []
		env_helper = os.environ.get("TAUON_NOWPLAYING_HELPER", "").strip()
		if env_helper:
			candidate_paths.append(Path(env_helper))
		candidate_paths.extend(
			[
				install_directory
				/ "lib"
				/ "TauonNowPlaying.app"
				/ "Contents"
				/ "MacOS"
				/ "TauonNowPlaying",
				# Development tree default (src/nowplaying/build)
				Path(__file__).resolve().parents[2]
				/ "nowplaying"
				/ "build"
				/ "TauonNowPlaying.app"
				/ "Contents"
				/ "MacOS"
				/ "TauonNowPlaying",
			]
		)

		for candidate in candidate_paths:
			try:
				if candidate.exists() and candidate.is_file() and os.access(candidate, os.X_OK):
					helper_exe = candidate
					break
			except Exception:
				logging.debug(f"Failed validating Now Playing helper candidate: {candidate}")

		if helper_exe is None:
			logging.debug("Now Playing helper not found; macOS media keys disabled")
		else:
			def _mac_media_key(name: str) -> None:
				logging.debug(f"NowPlaying sent key: {name}")
				# Reuse existing media key input handling.
				if name in {"PlayPause", "Play"}:
					inp.media_key = "Play"
				elif name == "Pause":
					inp.media_key = "Pause"
				elif name == "Next":
					inp.media_key = "Next"
				elif name == "Previous":
					inp.media_key = "Previous"
				elif name == "Stop":
					inp.media_key = "Stop"
				else:
					return
				gui.update += 1
				tauon.wake()

			def _mac_seek_abs(seconds: float) -> None:
				try:
					tauon.pctl.seek_time(float(seconds))
				except Exception:
					logging.exception("Failed to seek via macOS Now Playing")
				else:
					gui.update += 1
					tauon.wake()

			def _mac_seek_rel(delta: float) -> None:
				try:
					tauon.pctl.seek_time(tauon.pctl.playing_time + float(delta))
				except Exception:
					logging.exception("Failed to seek relative via macOS Now Playing")
				else:
					gui.update += 1
					tauon.wake()

			bag.nowplaying_helper = MacNowPlayingHelper(
				executable=helper_exe,
				on_media_key=_mac_media_key,
				on_seek=_mac_seek_abs,
				on_seek_relative=_mac_seek_rel,
			)
			bag.nowplaying = bool(bag.nowplaying_helper.start())
			if bag.nowplaying:
				logging.info(f"Now Playing helper started: {helper_exe}")
			else:
				bag.nowplaying_helper = None

	try:
		prefs.update_title  = prefs.view_prefs["update-title"]
		prefs.prefer_side   = prefs.view_prefs["side-panel"]
		prefs.dim_art       = False  # view_prefs['dim-art']
		#pl_follow          = view_prefs['pl-follow']
		prefs.scroll_enable = prefs.view_prefs["scroll-enable"]
		prefs.smooth_scroll_enable = prefs.view_prefs.get("smooth-scroll-enable", False)
		prefs.smooth_scroll_speed = float(prefs.view_prefs.get("smooth-scroll-speed", 1.0))
		prefs.smooth_scroll_speed = min(max(prefs.smooth_scroll_speed, 0.25), 10.0)
		if "break-enable" in prefs.view_prefs:
			prefs.break_enable = prefs.view_prefs["break-enable"]
		else:
			logging.warning("break-enable not found in view_prefs[] when trying to load settings! First run?")
		#custom_line_mode  = view_prefs['custom-line']
		#thick_lines       = view_prefs['thick-lines']
		if "append-date" in prefs.view_prefs:
			prefs.append_date = prefs.view_prefs["append-date"]
		else:
			logging.warning("append-date not found in view_prefs[] when trying to load settings! First run?")
	except KeyError:
		logging.exception("Failed to load settings - pref not found!")
	except Exception:
		logging.exception("Failed to load settings!")

	if prefs.prefer_side is False:
		gui.rsp = False

	mpt: CDLL | None = None
	p = ctypes.util.find_library("openmpt") # Linux
	p = p or ctypes.util.find_library("libopenmpt-0") # Windows
	try:
		if p:
			mpt = ctypes.cdll.LoadLibrary(p)
		elif windows:
			mpt = ctypes.cdll.LoadLibrary("libopenmpt-0.dll")
		else:
			mpt = ctypes.cdll.LoadLibrary("libopenmpt.so.0")

		mpt.openmpt_module_create_from_memory.restype = c_void_p
		mpt.openmpt_module_get_metadata.restype = c_char_p
		mpt.openmpt_module_get_duration_seconds.restype = c_double
	except Exception:
		logging.exception("Failed to load libopenmpt!")

	gme: CDLL | None = None
	p = ctypes.util.find_library("gme") # Linux
	p = p or ctypes.util.find_library("libgme") # Windows
	try:
		if p:
			gme = ctypes.cdll.LoadLibrary(p)
		elif windows:
			gme = ctypes.cdll.LoadLibrary("libgme.dll")
		else:
			gme = ctypes.cdll.LoadLibrary("libgme.so.0")

		gme.gme_free_info.argtypes = [ctypes.POINTER(GMETrackInfo)]
		gme.gme_track_info.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.POINTER(GMETrackInfo)), ctypes.c_int]
		gme.gme_track_info.restype = ctypes.c_char_p
		gme.gme_open_file.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_void_p), ctypes.c_int]
		gme.gme_open_file.restype = ctypes.c_char_p

	except Exception:
		logging.exception("Cannot find libgme")

	tauon = Tauon(
		holder=holder,
		bag=bag,
		gui=gui,
	)
	if show_upgrade_splash:
		prefs.show_nag = True

	try:
		if tauon.prefs.discord_enable and tauon.prefs.discord_allow:
			try:
				tauon._signal_discord()
			except Exception:
				tauon.hit_discord()
	except Exception:
		logging.exception("Failed to start Discord RPC at startup")

	if tauon.windows:
		if not (tauon.install_directory / "lrclib-solver.exe").exists():
			logging.warning("lrclib-solver not found, uploading lyrics to LRCLIB will not be possible")
	else:
		if not (tauon.install_directory / "lrclib-solver").exists():
			logging.warning("lrclib-solver not found, uploading lyrics to LRCLIB will not be possible")

	if db_version > 0 and db_version < latest_db_version:
		clear_icon_cache(bag.dirs.scaled_asset_directory)

	auto_scale(bag)
	scale_assets(tauon, bag, gui, prefs.scale_want)

	tauon.after_scan              = after_scan
	tauon.search_string_cache     = search_string_cache
	tauon.search_dia_string_cache = search_dia_string_cache
	signal.signal(signal.SIGINT, tauon.signal_handler)
	pctl = tauon.pctl
	try:
		pctl.stop_mode = StopMode(bag.loaded_stop_mode)
	except ValueError:
		logging.warning(f"Invalid saved stop_mode value: {bag.loaded_stop_mode}")
		pctl.stop_mode = StopMode.OFF
	pctl.stop_ref = bag.loaded_stop_ref if pctl.stop_mode != StopMode.OFF else None
	if bag.multi_playlist:
		pctl.multi_playlist = bag.multi_playlist
		pctl.default_playlist = bag.default_playlist
	else:
		pctl.multi_playlist = [tauon.pl_gen(notify=False)]
		pctl.default_playlist = pctl.multi_playlist[0].playlist_ids
	notify_database_changed = pctl.notify_database_changed

	lastfm = tauon.lastfm
	lb = tauon.lb

	star_path1 = user_directory / "star.p"
	star_path2 = user_directory / "star.p.backup"
	star_size1 = 0
	star_size2 = 0
	to_load = star_path1
	if star_path1.is_file():
		star_size1 = star_path1.stat().st_size
	if star_path2.is_file():
		star_size2 = star_path2.stat().st_size
	if star_size2 > star_size1:
		logging.warning("Loading backup star.p as it was bigger than regular file!")
		to_load = star_path2
	if star_size1 == 0 and star_size2 == 0:
		logging.warning("Star database file is missing, first run? Will create one anew!")
	else:
		try:
			with to_load.open("rb") as file:
				tauon.star_store.db = pickle.load(file)
				# Test if we truly have StarRecord in the DB file
				# If we have something else, it's likely an older DB format,
				# in which case we try migrating it
				for key, old_record in tauon.star_store.db.items():
					if not isinstance(old_record, StarRecord):
						migrate_star_store_71(tauon)

		except Exception:
			logging.exception("Unknown error loading star.p file")

	album_star_path = user_directory / "album-star.p"
	if album_star_path.is_file():
		try:
			with album_star_path.open("rb") as file:
				tauon.album_star_store.db = pickle.load(file)
		except Exception:
			logging.exception("Unknown error loading album-star.p file")
	else:
		logging.warning("Album star database file is missing, first run? Will create one anew!")

	# Run upgrades if we're behind the current DB standard
	if db_version > 0 and db_version < latest_db_version:
		logging.warning(f"Current DB version {db_version} was lower than latest {latest_db_version}, running migrations!")
		try:
			pctl.master_library, pctl.multi_playlist, pctl.force_queue, prefs.theme, prefs, gui, pctl.gen_codes, pctl.radio_playlists = database_migrate(
				tauon=tauon,
				db_version=db_version,
				master_library=pctl.master_library,
				legacy_track_misc=legacy_track_misc,
				install_mode=install_mode,
				multi_playlist=pctl.multi_playlist,
				install_directory=install_directory,
				a_cache_dir=a_cache_dir,
				cache_directory=cache_directory,
				config_directory=config_directory,
				user_directory=user_directory,
				gui=gui,
				gen_codes=pctl.gen_codes,
				prefs=prefs,
				radio_playlists=pctl.radio_playlists,
				theme=prefs.theme,
				p_force_queue=pctl.force_queue,
			)
			# Immediately write down migrations to prevent later crashes from throwing things out of alignment
			tauon.save_state()
		except ValueError:
			logging.exception("That should not happen")
			sys.exit(42)
		except Exception:
			logging.exception("Unknown error running database migration!")
			sys.exit(42)

	if not macos and not tauon.windows:
		try:
			Notify.init("Tauon Music Box")
			tauon.g_tc_notify = Notify.Notification.new(
				"Tauon Music Box",
				"Transcoding has finished.")
			value = GLib.Variant("s", t_id)
			tauon.g_tc_notify.set_hint("desktop-entry", value)

			tauon.g_tc_notify.add_action(
				"action_click",
				"Open Output Folder",
				tauon.g_open_encode_out,
				None,
			)
			tauon.de_notify_support = True
		except Exception:
			logging.exception("Failed init notifications")

		if tauon.de_notify_support:
			tauon.song_notification = Notify.Notification.new("Next track notification")
			value = GLib.Variant("s", t_id)
			tauon.song_notification.set_hint("desktop-entry", value)

	# TODO(Martin): Get rid of this and define it properly
	tauon.deco.get_themes = get_themes
	tauon.deco.renderer = renderer

	if prefs.backend != Backend.PHAZOR:
		prefs.backend = Backend.PHAZOR

	chrome_loaded = is_module_loaded("tauon.t_modules.t_chrome", "Chrome")
	if chrome_loaded:
		tauon.chrome = Chrome(tauon)

	if not macos and not tauon.windows:
		try:
			gnome_thread = threading.Thread(target=tauon.gnome.main)
			gnome_thread.daemon = True
			gnome_thread.start()
		except Exception:
			logging.exception("Could not start Dbus thread")

	if sys.platform == "win32":
		if win_ver < 10:
			logging.warning("Unsupported Windows version older than W10, hooking media keys the old way without SMTC!")
			import keyboard

			def key_callback(event) -> None:
				if event.event_type == "down":
					if event.scan_code == -179:
						inp.media_key = "Play"
					elif event.scan_code == -178:
						inp.media_key = "Stop"
					elif event.scan_code == -177:
						inp.media_key = "Previous"
					elif event.scan_code == -176:
						inp.media_key = "Next"
					gui.update += 1
					tauon.wake()

			keyboard.hook_key(-179, key_callback)
			keyboard.hook_key(-178, key_callback)
			keyboard.hook_key(-177, key_callback)
			keyboard.hook_key(-176, key_callback)

	# -------------------------------------------------------------------------------------------
	# initiate SDL3 --------------------------------------------------------------------C-IS-----

	if not tauon.windows and "XCURSOR_THEME" in os.environ and "XCURSOR_SIZE" in os.environ:
		try:
			try:
				xcu = ctypes.cdll.LoadLibrary("libXcursor.so.1")
			except Exception:
				logging.exception("Failed to load libXcursor.so, will try libXcursor.so")
				xcu = ctypes.cdll.LoadLibrary("libXcursor.so")
			xcu.XcursorLibraryLoadImage.restype = ctypes.POINTER(XcursorImage)

			def get_xcursor(name: str) -> sdl3.LP_SDL_Cursor:
				if "XCURSOR_THEME" not in os.environ:
					raise ValueError("Missing XCURSOR_THEME in env")
				if "XCURSOR_SIZE" not in os.environ:
					raise ValueError("Missing XCURSOR_SIZE in env")
				xcursor_theme = os.environ["XCURSOR_THEME"]
				xcursor_size = os.environ["XCURSOR_SIZE"]
				c1 = xcu.XcursorLibraryLoadImage(c_char_p(name.encode()), c_char_p(xcursor_theme.encode()), c_int(int(xcursor_size))).contents
				sdl3.SDL_surface = sdl3.SDL_CreateSurfaceFrom(c1.width, c1.height, sdl3.SDL_PIXELFORMAT_ARGB8888, c1.pixels, c1.width * 4)
				cursor = sdl3.SDL_CreateColorCursor(sdl3.SDL_surface, round(c1.xhot), round(c1.yhot))
				xcu.XcursorImageDestroy(ctypes.byref(c1))
				sdl3.SDL_DestroySurface(sdl3.SDL_surface)
				return cursor

			cursor_br_corner = get_xcursor("se-resize")
			cursor_right_side = get_xcursor("right_side")
			cursor_top_side = get_xcursor("top_side")
			cursor_left_side = get_xcursor("left_side")
			cursor_bottom_side = get_xcursor("bottom_side")

			if sdl3.SDL_GetCurrentVideoDriver() == b"wayland":
				cursor_standard = get_xcursor("left_ptr")
				cursor_text = get_xcursor("xterm")
				cursor_shift = get_xcursor("sb_h_double_arrow")
				cursor_hand = get_xcursor("hand2")
				sdl3.SDL_SetCursor(cursor_standard)

		except Exception:
			logging.exception("Error loading xcursor")


	if not maximized and gui.maximized:
		sdl3.SDL_MaximizeWindow(t_window)

	# logging.error(sdl3.SDL_GetError())

	props = sdl3.SDL_GetWindowProperties(t_window)

	if tauon.windows:
		gui.window_id = sdl3.SDL_GetPointerProperty(props, sdl3.SDL_PROP_WINDOW_WIN32_HWND_POINTER, None)
		#gui.window_id = sss.info.win.window

	ddt = tauon.ddt
	ddt.scale = gui.scale
	ddt.force_subpixel_text = prefs.force_subpixel_text

	tauon.prime_fonts()

	text_box_canvas_rect = sdl3.SDL_FRect(0, 0, round(2000 * gui.scale), round(40 * gui.scale))
	text_box_canvas_hide_rect = sdl3.SDL_FRect(0, 0, round(2000 * gui.scale), round(40 * gui.scale))
	text_box_canvas = sdl3.SDL_CreateTexture(
		renderer, sdl3.SDL_PIXELFORMAT_ARGB8888, sdl3.SDL_TEXTUREACCESS_TARGET, round(text_box_canvas_rect.w), round(text_box_canvas_rect.h))
	sdl3.SDL_SetTextureBlendMode(text_box_canvas, sdl3.SDL_BLENDMODE_BLEND)

	tauon.rename_files.text = prefs.rename_tracks_template
	if rename_files_previous:
		tauon.rename_files.text = rename_files_previous

	tauon.rename_folder.text = prefs.rename_folder_template
	if rename_folder_previous:
		tauon.rename_folder.text = rename_folder_previous

	# gui.scroll_hide_box = (0, gui.panelY, 28, window_size[1] - gui.panelBY - gui.panelY)

	#cctest = ColourPulse2(tauon)

	#setup_menus(tauon)
	playlist_menu         = tauon.playlist_menu
	radio_entry_menu      = tauon.radio_entry_menu
	showcase_menu         = tauon.showcase_menu
	center_info_menu      = tauon.center_info_menu
	gallery_menu          = tauon.gallery_menu
	artist_info_menu      = tauon.artist_info_menu
	repeat_menu           = tauon.repeat_menu
	shuffle_menu          = tauon.shuffle_menu
	artist_list_menu      = tauon.artist_list_menu
	lightning_menu        = tauon.lightning_menu
	lsp_menu              = tauon.lsp_menu
	folder_tree_menu      = tauon.folder_tree_menu
	folder_tree_stem_menu = tauon.folder_tree_stem_menu
	radio_context_menu    = tauon.radio_context_menu
	tab_menu              = tauon.tab_menu
	extra_tab_menu        = tauon.extra_tab_menu
	track_menu            = tauon.track_menu
	selection_menu        = tauon.selection_menu
	folder_menu           = tauon.folder_menu
	picture_menu          = tauon.picture_menu
	milky_menu            = tauon.milky_menu
	mode_menu             = tauon.mode_menu
	extra_menu            = tauon.extra_menu

	# . Menu entry: A side panel view layout
	lsp_menu.add(MenuItem(_("Playlists + Queue"), tauon.enable_playlist_list, disable_test=tauon.lsp_menu_test_playlist))
	lsp_menu.add(MenuItem(_("Queue"), tauon.enable_queue_panel, disable_test=tauon.lsp_menu_test_queue))
	# . Menu entry: Side panel view layout showing a list of artists with thumbnails
	lsp_menu.add(MenuItem(_("Artist List"), tauon.enable_artist_list, disable_test=tauon.lsp_menu_test_artist))
	# . Menu entry: A side panel view layout. Alternative name: Folder Tree
	lsp_menu.add(MenuItem(_("Folder Navigator"), tauon.enable_folder_list, disable_test=tauon.lsp_menu_test_tree))

	# Custom Layout edit context menu (native Menu system). Built once; its items
	# act on tauon.custom.menu_target, set when the menu is activated on right
	# click in edit mode.
	cm = tauon.custom
	if cm.menu is None:
		cl_menu = Menu(tauon, 155)
		cm.menu = cl_menu

		cl_menu.add_sub(_("Add Vertical Stack"), 60)
		_cl_sub_v = cl_menu.sub_number - 1
		for _n in CL_STACK_COUNTS:
			cl_menu.add_to_sub(_cl_sub_v, MenuItem(str(_n), cm._menu_add_stack, args=("v", _n)))

		cl_menu.add_sub(_("Add Horizontal Stack"), 60)
		_cl_sub_h = cl_menu.sub_number - 1
		for _n in CL_STACK_COUNTS:
			cl_menu.add_to_sub(_cl_sub_h, MenuItem(str(_n), cm._menu_add_stack, args=("h", _n)))

		cl_menu.add_sub(_("Add…"), 180)
		_cl_sub_add = cl_menu.sub_number - 1
		# Label this submenu "Replace…" when the target segment already has a
		# widget, "Add…" when it's an empty slot.
		def _add_or_replace_label() -> Decorator:
			text = _("Replace…") if cm._t_has_widget() else _("Add…")
			return Decorator(cl_menu.colours.menu_text, cl_menu.colours.menu_background, text)
		cl_menu.items[-1].render_func = _add_or_replace_label
		for _spec in CL_WIDGET_SPECS:
			cl_menu.add_to_sub(_cl_sub_add, MenuItem(
				_spec.name, cm._menu_add_widget, args=_spec.kind,
				disable_test=(lambda k=_spec.kind: cm.kind_disabled(k))))

		cl_menu.add(MenuItem(_("Remove"), cm._menu_remove_widget, show_test=cm._t_has_widget))
		cl_menu.add(MenuItem(_("Remove Stack"), cm._menu_remove_stack))
		cl_menu.br()
		cl_menu.add_sub(_("Layout…"), 175)
		_cl_sub_layout = cl_menu.sub_number - 1
		cl_menu.add_to_sub(_cl_sub_layout, MenuItem(_("Lock Vertical"), cm._menu_lock_v, show_test=cm._t_unlocked_v, no_exit=True))
		cl_menu.add_to_sub(_cl_sub_layout, MenuItem(_("Unlock Vertical"), cm._menu_lock_v, show_test=cm._t_locked_v, no_exit=True))
		cl_menu.add_to_sub(_cl_sub_layout, MenuItem(_("Lock Horizontal"), cm._menu_lock_h, show_test=cm._t_unlocked_h, no_exit=True))
		cl_menu.add_to_sub(_cl_sub_layout, MenuItem(_("Unlock Horizontal"), cm._menu_lock_h, show_test=cm._t_locked_h, no_exit=True))
		cl_menu.add_to_sub(_cl_sub_layout, MenuItem(_("Inset Square"), cm._menu_lock_aspect, show_test=cm._t_aspect_off, no_exit=True))
		cl_menu.add_to_sub(_cl_sub_layout, MenuItem(_("Remove Inset Square"), cm._menu_lock_aspect, show_test=cm._t_aspect_on, no_exit=True))
		cl_menu.add_to_sub(_cl_sub_layout, MenuItem(_("Square Max"), cm._menu_square_max, show_test=cm._t_square_off, no_exit=True))
		cl_menu.add_to_sub(_cl_sub_layout, MenuItem(_("Remove Square Max"), cm._menu_square_max, show_test=cm._t_square_on, no_exit=True))
		cl_menu.add_incrementor_to_sub(_cl_sub_layout, _("Gutter"), cm._menu_gutter_value, cm._menu_gutter_minus, cm._menu_gutter_plus)
		cl_menu.add_incrementor_to_sub(_cl_sub_layout, _("Padding"), cm._menu_padding_value, cm._menu_padding_minus, cm._menu_padding_plus)
		cl_menu.add_to_sub(_cl_sub_layout, MenuItem(_("Border"), cm._menu_border, show_test=cm._t_border_off, no_exit=True))
		cl_menu.add_to_sub(_cl_sub_layout, MenuItem(_("Remove Border"), cm._menu_border, show_test=cm._t_border_on, no_exit=True))
		cl_menu.add_to_sub(_cl_sub_layout, MenuItem(_("Make Stack Resizable"), cm._menu_stack_resizable, show_test=cm._t_stack_resizable_off, no_exit=True))
		cl_menu.add_to_sub(_cl_sub_layout, MenuItem(_("Make Stack Not Resizable"), cm._menu_stack_resizable, show_test=cm._t_stack_resizable_on, no_exit=True))
		cl_menu.br()
		cl_menu.add_sub(_("Load Template…"), 185)
		_cl_sub_t = cl_menu.sub_number - 1
		for _name in CL_TEMPLATES:
			cl_menu.add_to_sub(_cl_sub_t, MenuItem(_name, cm._menu_template, args=_name))
		cl_menu.add(MenuItem(_("Rename Layout…"), cm._menu_rename))
		cl_menu.add(MenuItem(_("Delete Slot"), cm._menu_delete_slot))
		cl_menu.add(MenuItem(_("New Slot"), cm._menu_new_slot))

	# Corner layout menu: opened by the corner layout/edit button (drawn after
	# the panel button by the TopPanel normally; by the custom engine while in
	# custom mode, where the panel button is hidden). Mirrors the View Switcher
	# options (same labels as its tooltips), plus the custom-layout edit toggle.
	layout_menu = Menu(tauon, 200, show_icons=True)
	tauon.layout_menu = layout_menu

	def _layout_menu_pick(name: str) -> Callable[[], None]:
		def cb() -> None:
			# Same dispatch as the View Switcher buttons: any option other than
			# Custom Layout exits custom mode first.
			if name != "custom_layout" and gui.custom_mode:
				tauon.custom.exit_mode()
			getattr(tauon.view_box, name)(True)
		return cb

	def _layout_menu_icon_colour(name: str, colour: ColourRGBA) -> Callable[[], ColourRGBA | None]:
		# Hold the icon's accent colour while its layout is the active view
		# (heart-icon pattern) — same detection the View Switcher buttons use:
		# test(False) plus the custom-mode override (in custom mode only the
		# Custom Layout entry counts as on).
		def cb() -> ColourRGBA | None:
			on = getattr(tauon.view_box, name)(False) and not gui.custom_mode
			if on:
				if colours.lm:
					return ColourRGBA(63, 63, 63, 255)  # match ViewBox light-mode "high"
				return colour
			return None
		return cb

	for _vb_name, _vb_label, _vb_icon, _vb_xoff, _vb_colour in (
		("side_normal", _("Tracks + Art"), "tracks+side-menu.png", 0, ColourRGBA(76, 183, 229, 255)),
		("side_reversed", _("Tracks + Art (Reversed)"), "tracks+side-menu-reversed.png", 0, ColourRGBA(76, 183, 229, 255)),
		("gallery1", _("Tracks + Gallery"), "gallery1-menu.png", 0, ColourRGBA(76, 137, 229, 255)),
		("tracks", _("Tracks"), "tracks-menu.png", 1, ColourRGBA(76, 229, 229, 255)),
		("lyrics", _("Art + Lyrics"), "lyrics-menu.png", 1, ColourRGBA(107, 76, 229, 255)),
		("radio", _("Radio"), "radio-view-menu.png", 1, ColourRGBA(92, 86, 255, 255)),
	):
		_vb_menu_icon = MenuIcon(asset_loader(bag, bag.loaded_asset_dc, _vb_icon, True))
		_vb_menu_icon.colour = _vb_colour
		_vb_menu_icon.colour_callback = _layout_menu_icon_colour(_vb_name, _vb_colour)
		_vb_menu_icon.xoff = _vb_xoff
		_vb_menu_icon.yoff = 1
		layout_menu.add(MenuItem(_vb_label, _layout_menu_pick(_vb_name), icon=_vb_menu_icon))

	# Custom layout slots: one entry per slot (any number), named by the slot
	# (rename / template loads set names; unnamed empty slots read "Empty
	# Slot"), same glyph with a cycled per-slot accent. Picking the
	# already-active slot toggles custom mode off; picking another switches to
	# it. The slot section is rebuilt (tauon.rebuild_layout_menu) whenever
	# slots are created, deleted or renamed.
	def _layout_menu_pick_custom(slot: int) -> Callable[[], None]:
		def cb() -> None:
			if gui.custom_mode and tauon.custom.active_slot == slot:
				tauon.custom.exit_mode()
			else:
				tauon.custom.enter(slot)
		return cb

	def _layout_menu_custom_colour(slot: int, colour: ColourRGBA) -> Callable[[], ColourRGBA | None]:
		def cb() -> ColourRGBA | None:
			if gui.custom_mode and tauon.custom.active_slot == slot:
				if colours.lm:
					return ColourRGBA(63, 63, 63, 255)
				return colour
			return None
		return cb

	def _edit_mode_deco() -> Decorator:
		# The menu only opens while not in edit mode (the corner button exits edit
		# mode directly), so this entry always reads "Enter Edit Mode".
		text = _("Exit Edit Mode") if gui.custom_edit else _("Enter Edit Mode")
		return Decorator(colours.menu_text, colours.menu_background, text)

	_layout_menu_head = list(layout_menu.items)  # the fixed preset entries above
	_slot_accents = (
		ColourRGBA(170, 225, 90, 255),   # lime
		ColourRGBA(230, 85, 210, 255),   # magenta
		ColourRGBA(255, 160, 70, 255),   # orange
		ColourRGBA(85, 205, 235, 255),   # cyan
		ColourRGBA(255, 110, 120, 255),  # coral
		ColourRGBA(235, 200, 80, 255),   # gold
		ColourRGBA(165, 105, 255, 255),  # violet
		ColourRGBA(100, 145, 255, 255),  # blue
	)

	def rebuild_layout_menu() -> None:
		layout_menu.items[:] = _layout_menu_head
		for _slot in range(len(tauon.custom.slots)):
			_slot_colour = _slot_accents[_slot % len(_slot_accents)]
			_slot_icon = MenuIcon(asset_loader(bag, bag.loaded_asset_dc, "custom-layout-menu.png", True))
			_slot_icon.colour = _slot_colour
			_slot_icon.colour_callback = _layout_menu_custom_colour(_slot, _slot_colour)
			_slot_icon.xoff = 1
			_slot_icon.yoff = 1
			layout_menu.add(MenuItem(
				tauon.custom.slot_title(_slot), _layout_menu_pick_custom(_slot), icon=_slot_icon))
		layout_menu.br()
		layout_menu.add(MenuItem(_("Enter Edit Mode"), tauon.custom.toggle_edit, _edit_mode_deco,
			disable_test=lambda: not gui.custom_mode))

	tauon.rebuild_layout_menu = rebuild_layout_menu
	# Slot names/count are needed for the entries, so load the slots now
	# (cheap; also restores the last-active slot early).
	if not tauon.custom._loaded:
		tauon.custom.load_slots()
	rebuild_layout_menu()

	# Right-click menu for the Spectrogram widget: colour presets.
	spectrogram_menu = Menu(tauon, 150)
	tauon.spectrogram_menu = spectrogram_menu

	# Top-level menu items are invoked as func() with no arguments (MenuItem
	# ``args`` only applies to submenu items), so bind the preset index in a
	# closure per item.
	def _spectro_set_colour(index: int) -> Callable[[], None]:
		def cb() -> None:
			prefs.spectrogram_colour = index
			gui.update += 1
		return cb

	def _spectro_preset_check(index: int) -> Callable[[], bool]:
		return lambda: prefs.spectrogram_colour == index

	for _i, _sp in enumerate(CL_SPECTRO_PRESETS):
		spectrogram_menu.add(MenuItem(
			_sp[0], _spectro_set_colour(_i), check_test=_spectro_preset_check(_i)))

	# Right-click (background) menu for the Album Grid widget. The incrementor
	# callbacks are classmethods reading GridGalleryWidget.menu_target, which
	# the widget sets when it opens the menu.
	gallery_grid_menu = Menu(tauon, 190)
	tauon.gallery_grid_menu = gallery_grid_menu
	gallery_grid_menu.add_incrementor(
		_("Albums per row"),
		GridGalleryWidget.menu_per_row_value,
		GridGalleryWidget.menu_per_row_minus,
		GridGalleryWidget.menu_per_row_plus)
	gallery_grid_menu.add_incrementor(
		_("Spacing"),
		GridGalleryWidget.menu_spacing_value,
		GridGalleryWidget.menu_spacing_minus,
		GridGalleryWidget.menu_spacing_plus)
	gallery_grid_menu.add_incrementor(
		_("Row spacing"),
		GridGalleryWidget.menu_v_spacing_value,
		GridGalleryWidget.menu_v_spacing_minus,
		GridGalleryWidget.menu_v_spacing_plus)

	def _grid_titles_deco() -> Decorator:
		text = _("Hide Titles") if GridGalleryWidget.menu_titles_value() else _("Show Titles")
		return Decorator(colours.menu_text, colours.menu_background, text)

	gallery_grid_menu.add(MenuItem(_("Hide Titles"), GridGalleryWidget.menu_toggle_titles, _grid_titles_deco))

	# Right-click (background) menu for the gallery — the preset album view and
	# the Gallery: Classic widget (the Compact grid has its own menu). Holds the
	# gallery settings, moved here from Settings > View.
	gallery_settings_menu = Menu(tauon, 210)
	tauon.gallery_settings_menu = gallery_settings_menu

	def _gal_size_value(ref=None) -> int:
		return int(tauon.album_mode_art_size)

	def _gal_size_step(direction: int) -> Callable[..., None]:
		def cb(ref=None) -> None:
			new = min(400, max(70, int(tauon.album_mode_art_size) + 10 * direction))
			if new != tauon.album_mode_art_size:
				tauon.img_slide_update_gall(new)
				gui.update += 1
		return cb

	gallery_settings_menu.add_incrementor(
		_("Thumbnail size"), _gal_size_value, _gal_size_step(-1), _gal_size_step(1))

	def _gal_toggle_titles(ref=None) -> None:
		tauon.toggle_galler_text()

	gallery_settings_menu.add(MenuItem(_("Show titles"), _gal_toggle_titles,
		check_test=lambda: gui.gallery_show_text))

	def _gal_toggle_center(ref=None) -> None:
		prefs.center_gallery_text ^= True
		gui.update += 1

	gallery_settings_menu.add(MenuItem(_("Center title text"), _gal_toggle_center,
		check_test=lambda: prefs.center_gallery_text))

	def _gal_toggle_click(ref=None) -> None:
		tauon.toggle_gallery_click()

	gallery_settings_menu.add(MenuItem(_("Single click to play"), _gal_toggle_click,
		check_test=lambda: tauon.toggle_gallery_click(1)))

	def _gal_toggle_row_scroll(ref=None) -> None:
		prefs.gallery_row_scroll ^= True

	gallery_settings_menu.add(MenuItem(_("Scroll by row"), _gal_toggle_row_scroll,
		check_test=lambda: prefs.gallery_row_scroll))

	def _gal_toggle_combine(ref=None) -> None:
		tauon.toggle_gallery_combine()

	gallery_settings_menu.add(MenuItem(_("Combine multi-discs"), _gal_toggle_combine,
		check_test=lambda: tauon.toggle_gallery_combine(1)))

	# Mirror the shared gallery display toggles onto the Gallery: Compact menu.
	# These are global prefs (not per-instance), so the same callbacks apply.
	gallery_grid_menu.add(MenuItem(_("Center title text"), _gal_toggle_center,
		check_test=lambda: prefs.center_gallery_text))
	gallery_grid_menu.add(MenuItem(_("Single click to play"), _gal_toggle_click,
		check_test=lambda: tauon.toggle_gallery_click(1)))
	gallery_grid_menu.add(MenuItem(_("Scroll by row"), _gal_toggle_row_scroll,
		check_test=lambda: prefs.gallery_row_scroll))
	gallery_grid_menu.add(MenuItem(_("Combine multi-discs"), _gal_toggle_combine,
		check_test=lambda: tauon.toggle_gallery_combine(1)))

	def _gal_toggle_thin(ref=None) -> None:
		tauon.toggle_gallery_thin()

	gallery_settings_menu.add(MenuItem(_("Prefer thinner padding"), _gal_toggle_thin,
		check_test=lambda: tauon.toggle_gallery_thin(1),
		show_test=lambda ref=None: tauon.album_mode_art_size < 160))

	repeat_menu.add(MenuItem(_("Repeat OFF"), tauon.menu_repeat_off))
	repeat_menu.add(MenuItem(_("Repeat Track"), tauon.menu_set_repeat))
	repeat_menu.add(MenuItem(_("Repeat Album"), tauon.menu_album_repeat))
	repeat_menu.add(MenuItem(_("A/B Repeat"), tauon.menu_ab_repeat, tauon.menu_ab_repeat_deco))

	shuffle_menu.add(MenuItem(_("Shuffle Lockdown"), tauon.toggle_shuffle_layout))
	shuffle_menu.add(MenuItem(_("Shuffle Lockdown Albums"), tauon.toggle_shuffle_layout_albums))
	shuffle_menu.br()
	shuffle_menu.add(MenuItem(_("Shuffle OFF"), tauon.menu_shuffle_off))
	shuffle_menu.add(MenuItem(_("Shuffle Tracks"), tauon.menu_set_random))
	shuffle_menu.add(MenuItem(_("Random Albums"), tauon.menu_album_random))

	artist_info_menu.add(MenuItem(_("Close Panel"), tauon.artist_info_panel_close))

	gui.filter_icon.colour = ColourRGBA(43, 213, 255, 255)
	gui.filter_icon.xoff = 1

	gui.folder_icon.colour = ColourRGBA(244, 220, 66, 255)
	gui.info_icon.colour = ColourRGBA(61, 247, 163, 255)

	folder_tree_stem_menu.add(MenuItem(_("Open Folder"), tauon.open_folder_stem, pass_ref=True, icon=gui.folder_icon))
	folder_tree_menu.add(MenuItem(_("Open Folder"), tauon.menu_open_folder, pass_ref=True, pass_ref_deco=True, icon=gui.folder_icon, disable_test=tauon.menu_open_folder_disable_test))

	lightning_menu.add(MenuItem(_("Filter to New Playlist"), tauon.tag_to_new_playlist, pass_ref=True, icon=gui.filter_icon))
	folder_tree_menu.add(MenuItem(_("Filter to New Playlist"), tauon.folder_to_new_playlist_by_track_id, pass_ref=True, icon=gui.filter_icon))
	folder_tree_stem_menu.add(MenuItem(_("Filter to New Playlist"), tauon.stem_to_new_playlist, pass_ref=True, icon=gui.filter_icon))
	folder_tree_stem_menu.add(MenuItem(_("Rescan Folder"), tauon.re_import3, pass_ref=True))
	folder_tree_menu.add(MenuItem(_("Rescan Folder"), tauon.re_import4, pass_ref=True))
	lightning_menu.add(MenuItem(_("Move Playing Folder Here"), tauon.move_playing_folder_to_tag, pass_ref=True))

	folder_tree_stem_menu.add(MenuItem(_("Move Playing Folder Here"), tauon.move_playing_folder_to_tree_stem, pass_ref=True))

	folder_tree_stem_menu.br()

	folder_tree_stem_menu.add(MenuItem(_("Collapse All"), tauon.collapse_tree, tauon.collapse_tree_deco))

	folder_tree_stem_menu.add(MenuItem(_("lock"), tauon.lock_folder_tree, tauon.lock_folder_tree_deco))
	# folder_tree_menu.add("lock", lock_folder_tree, tauon.lock_folder_tree_deco)

	gallery_menu.add(MenuItem(_("Open Folder"), tauon.menu_open_folder, pass_ref=True, pass_ref_deco=True, icon=gui.folder_icon, disable_test=tauon.menu_open_folder_disable_test))
	gallery_menu.add(MenuItem(_("Show in Playlist"), tauon.show_in_playlist))
	gallery_menu.add_sub(_("Image…"), 160)
	gallery_menu.add(MenuItem(_("Add Album to Queue"), tauon.menu_add_album_to_queue, pass_ref=True))
	gallery_menu.add(MenuItem(_("Enqueue Album Next"), tauon.add_album_to_queue_fc, pass_ref=True))

	tauon.cancel_menu.add(MenuItem(_("Cancel"), tauon.cancel_import))

	def add_showcase_lyrics_items(menu: Menu) -> None:
		"""Add the shared lyrics items (fresh MenuItems per menu)"""
		menu.add(MenuItem(_("Search for Lyrics"), tauon.get_lyric_wiki, tauon.search_lyrics_deco, pass_ref=True, pass_ref_deco=True))
		menu.add(MenuItem(_("Search GuitarParty"), tauon.guitar_chords.search_guitarparty, pass_ref=True, show_test=tauon.chord_lyrics_paste_show_test))
		menu.add(MenuItem(_("Paste Chord Lyrics"), tauon.guitar_chords.paste_chord_lyrics, pass_ref=True, show_test=tauon.chord_lyrics_paste_show_test))
		menu.add(MenuItem(_("Clear Chord Lyrics"), tauon.guitar_chords.clear_chord_lyrics, pass_ref=True, show_test=tauon.chord_lyrics_paste_show_test))

		menu.add(MenuItem(_("Show Lyrics"), tauon.toggle_lyrics, tauon.toggle_lyrics_deco, pass_ref=True, pass_ref_deco=True,
			check_test=tauon.toggle_lyrics_check))
		menu.add(MenuItem(_("Prefer Synced"), tauon.toggle_synced_lyrics, tauon.toggle_synced_lyrics_deco, pass_ref=True, pass_ref_deco=True,
			check_test=lambda: tauon.prefs.prefer_synced_lyrics))
		menu.add(MenuItem(_("Lyrics Editor"), tauon.enter_timed_lyrics_edit, tauon.edit_lyrics_deco, pass_ref=True, pass_ref_deco=True))
		misc_sub = menu.sub_number
		menu.add_sub(_("Misc…"), 150)
		menu.add_to_sub(misc_sub, MenuItem(_("Substitute Search..."), tauon.show_sub_search, pass_ref=True))
		menu.add_to_sub(misc_sub, MenuItem(_("Paste Lyrics"), tauon.paste_lyrics, tauon.paste_lyrics_deco, pass_ref=True))
		menu.add_to_sub(misc_sub, MenuItem(_("Copy Lyrics"), tauon.copy_lyrics, tauon.copy_lyrics_deco, pass_ref=True, pass_ref_deco=True))
		menu.add_to_sub(misc_sub, MenuItem(_("Clear Lyrics"), tauon.clear_lyrics, tauon.clear_lyrics_deco, pass_ref=True, pass_ref_deco=True))
		menu.add_to_sub(misc_sub, MenuItem(_("Clear Synced Lyrics"), tauon.clear_synced_lyrics, pass_ref=True, disable_test=tauon.clear_synced_lyrics_disable_test, pass_ref_deco=True))
		menu.add_to_sub(misc_sub, MenuItem(_("Toggle art panel"), tauon.toggle_side_art, tauon.toggle_side_art_deco, show_test=tauon.lyrics_in_side_show))
		menu.add_to_sub(misc_sub, MenuItem(_("Toggle art position"),
			tauon.toggle_lyrics_panel_position, tauon.toggle_lyrics_panel_position_deco, show_test=tauon.lyrics_in_side_show))

	add_showcase_lyrics_items(showcase_menu)

	# Showcase view's own background menu: the lyrics items plus showcase
	# layout settings ("Show Showcase Visualiser" moved here from
	# Settings > View)
	showcase_view_menu = tauon.showcase_view_menu
	add_showcase_lyrics_items(showcase_view_menu)
	showcase_view_menu.br()
	showcase_view_menu.add(MenuItem(
		_("Enable Wide Mode"),
		tauon.toggle_showcase_wide_art,
		tauon.toggle_showcase_wide_art_deco,
		pass_ref=True,
		pass_ref_deco=True,
	))
	showcase_view_menu.add(MenuItem(
		_("Show Showcase Visualiser"), tauon.toggle_showcase_vis,
		check_test=lambda: tauon.toggle_showcase_vis(1),
		show_test=lambda _ref=None: prefs.backend == Backend.PHAZOR))

	center_info_menu.add(MenuItem(_("Search for Lyrics"), tauon.get_lyric_wiki, tauon.search_lyrics_deco, pass_ref=True, pass_ref_deco=True))
	center_info_menu.add(MenuItem(_("Show Lyrics"), tauon.toggle_lyrics, tauon.toggle_lyrics_deco, pass_ref=True, pass_ref_deco=True,
		check_test=tauon.toggle_lyrics_check))
	center_info_menu.add(MenuItem(_("Prefer Synced Lyrics"), tauon.toggle_synced_lyrics, tauon.toggle_synced_lyrics_deco, pass_ref=True, pass_ref_deco=True,
		check_test=lambda: tauon.prefs.prefer_synced_lyrics))
	center_info_menu.add(MenuItem(_("Lyrics Editor"), tauon.enter_timed_lyrics_edit, tauon.edit_lyrics_deco, pass_ref=True, pass_ref_deco=True))

	center_info_menu.add_sub(_("Misc…"), 150)
	center_info_menu.add_to_sub(0, MenuItem(_("Substitute Search..."), tauon.show_sub_search, pass_ref=True))
	center_info_menu.add_to_sub(0, MenuItem(_("Paste Lyrics"), tauon.paste_lyrics, tauon.paste_lyrics_deco, pass_ref=True))
	center_info_menu.add_to_sub(0, MenuItem(_("Copy Lyrics"), tauon.copy_lyrics, tauon.copy_lyrics_deco, pass_ref=True, pass_ref_deco=True))
	center_info_menu.add_to_sub(0, MenuItem(_("Clear Lyrics"), tauon.clear_lyrics, tauon.clear_lyrics_deco, pass_ref=True, pass_ref_deco=True))
	center_info_menu.add_to_sub(0, MenuItem(_("Clear Synced Lyrics"), tauon.clear_synced_lyrics, pass_ref=True, disable_test=tauon.clear_synced_lyrics_disable_test, pass_ref_deco=True))
	center_info_menu.add_to_sub(0, MenuItem(_("Toggle art panel"), tauon.toggle_side_art, tauon.toggle_side_art_deco, show_test=tauon.lyrics_in_side_show))
	center_info_menu.add_to_sub(0, MenuItem(_("Toggle art position"),
		tauon.toggle_lyrics_panel_position, tauon.toggle_lyrics_panel_position_deco, show_test=tauon.lyrics_in_side_show))

	picture_menu.add(MenuItem(_("Open Image"), tauon.open_image, tauon.open_image_deco, pass_ref=True, pass_ref_deco=True, disable_test=tauon.open_image_disable_test))
	# Next and previous pictures
	picture_menu.add(MenuItem(_("Next Image"), tauon.cycle_offset, tauon.cycle_image_deco, pass_ref=True, pass_ref_deco=True))
	#picture_menu.add(_("Previous"), tauon.cycle_offset_back, tauon.cycle_image_deco, pass_ref=True, pass_ref_deco=True)

	# Extract embedded artwork from file
	picture_menu.add(MenuItem(_("Extract Image"), tauon.save_embed_img, tauon.extract_image_deco, pass_ref=True, pass_ref_deco=True, disable_test=tauon.save_embed_img_disable_test))

	picture_menu.add(
		MenuItem(_("Delete Image File"), tauon.delete_track_image, tauon.delete_track_image_deco, pass_ref=True,
		pass_ref_deco=True, icon=gui.delete_icon))

	picture_menu.add(MenuItem(_("Quick-Fetch Cover Art"), tauon.download_art1_fire, tauon.dl_art_deco, pass_ref=True, pass_ref_deco=True, disable_test=tauon.download_art1_fire_disable_test))
	# picture_menu.add(_('Search Google for Images'), tauon.ser_gimage, tauon.search_image_deco, pass_ref=True, pass_ref_deco=True, show_test=tauon.toggle_gimage)

	# picture_menu.add(_('Toggle art box'), tauon.toggle_side_art, tauon.toggle_side_art_deco)

	picture_menu.add(MenuItem(_("Search for Lyrics"), tauon.get_lyric_wiki, tauon.search_lyrics_deco, pass_ref=True, pass_ref_deco=True))
	picture_menu.add(MenuItem(_("Show Lyrics"), tauon.toggle_lyrics, tauon.toggle_lyrics_deco, pass_ref=True, pass_ref_deco=True,
		check_test=tauon.toggle_lyrics_check))
	# ("Centered metadata side panel" switch moved here from Settings > View;
	# hidden in showcase where the side panel layout doesn't apply)
	picture_menu.add(MenuItem(
		_("Use Centered Style"), tauon.toggle_side_panel_layout,
		check_test=lambda: tauon.toggle_side_panel_layout(1),
		show_test=lambda _ref: not gui.showcase_mode))

	# ("Zoom album art to fit" switch moved here from Settings > View. This
	# menu also serves the custom layout Art Box widget.)
	def menu_toggle_zoom_art(ref=None) -> None:
		prefs.zoom_art ^= True
		tauon.album_art_gen.clear_cache()
		gui.update += 1

	picture_menu.add(MenuItem(
		_("Zoom Art to Fit"), menu_toggle_zoom_art,
		check_test=lambda: prefs.zoom_art))

	picture_menu.br()
	picture_menu.add(MenuItem(
		_("Enable Wide Mode"),
		tauon.toggle_showcase_wide_art,
		tauon.toggle_showcase_wide_art_deco,
		pass_ref=True,
		pass_ref_deco=True,
		show_test=tauon.showcase_mode_show_test,
	))
	if milky_ready:
		picture_menu.add(MenuItem(_("MilkDrop Visualiser"), tauon.toggle_milky, pass_ref=True,
			check_test=lambda: prefs.milk))
	milky_menu.add(MenuItem(_("MilkDrop Visualiser"), tauon.toggle_milky, pass_ref=True,
		check_test=lambda: prefs.milk))
	milky_menu.add(MenuItem(_("Auto Cycle"), tauon.toggle_milky_auto, pass_ref=True,
		check_test=lambda: prefs.auto_milk))
	milky_menu.add(MenuItem(_("Cut Out"), tauon.toggle_milk_cut_out, pass_ref=True,
		check_test=lambda: prefs.milk_cut_out))
	milky_menu.add(MenuItem(_("Favorite This Preset"), tauon.toggle_milk_preset_favorite, pass_ref=True,
		check_test=tauon.milk_preset_is_favorite))
	milky_menu.add(MenuItem(_("Choose Preset"), tauon.open_milk_preset_chooser, pass_ref=True))
	milky_menu.add(MenuItem(
		_("Enable Wide Mode"),
		tauon.toggle_showcase_wide_art,
		tauon.toggle_showcase_wide_art_deco,
		pass_ref=True,
		pass_ref_deco=True,
		show_test=tauon.showcase_mode_show_test,
	))
	milky_menu.add(MenuItem(_("Open Preset Folder"), tauon.open_preset_folder, pass_ref=True))

	milky_menu.br()
	milky_menu.add(MenuItem(_("Show Lyrics"), tauon.toggle_lyrics, tauon.toggle_lyrics_deco, pass_ref=True, pass_ref_deco=True,
		check_test=tauon.toggle_lyrics_check))


	gallery_menu.add_to_sub(0, MenuItem(_("Next"), tauon.menu_cycle_offset, tauon.cycle_image_gal_deco, pass_ref=True, pass_ref_deco=True))
	gallery_menu.add_to_sub(0, MenuItem(_("Previous"), tauon.cycle_offset_back, tauon.cycle_image_gal_deco, pass_ref=True, pass_ref_deco=True))
	gallery_menu.add_to_sub(0, MenuItem(_("Open Image"), tauon.menu_open_image, tauon.menu_open_image_deco, pass_ref=True, pass_ref_deco=True, disable_test=tauon.menu_open_image_disable_test))
	gallery_menu.add_to_sub(0, MenuItem(_("Extract Image"), tauon.menu_save_embed_img, tauon.menu_extract_image_deco, pass_ref=True, pass_ref_deco=True, disable_test=tauon.menu_save_embed_img_disable_test))
	gallery_menu.add_to_sub(0, MenuItem(_("Delete Image <combined>"), tauon.menu_delete_track_image, tauon.menu_delete_track_image_deco, pass_ref=True, pass_ref_deco=True)) #, icon=delete_icon)
	gallery_menu.add_to_sub(0, MenuItem(_("Quick-Fetch Cover Art"), tauon.menu_download_art1_fire, tauon.menu_dl_art_deco, pass_ref=True, pass_ref_deco=True, disable_test=tauon.menu_download_art1_fire_disable_test))
	# playlist_menu.add('Paste', append_here, paste_deco)

	tab_menu.add(MenuItem(_("Rename"), tauon.rename_playlist, pass_ref=True, hint="Ctrl+R"))
	tab_menu.add(MenuItem(_("Pin"), tauon.pin_playlist_toggle, tauon.pl_pin_deco, pass_ref=True, pass_ref_deco=True))

	tauon.radio_tab_menu.add(MenuItem(_("Rename"), tauon.rename_playlist, pass_ref=True, hint="Ctrl+R"))

	lock_asset = asset_loader(bag, bag.loaded_asset_dc, "lock.png", True)
	lock_icon = MenuIcon(lock_asset)
	lock_icon.base_asset_mod = asset_loader(bag, bag.loaded_asset_dc, "unlock.png", True)
	lock_icon.colour = ColourRGBA(240, 190, 10, 255)
	lock_icon.colour_callback = tauon.lock_colour_callback
	lock_icon.xoff = 4
	lock_icon.yoff = -1

	tab_menu.add(MenuItem(_("Lock"), tauon.lock_playlist_toggle, tauon.pl_lock_deco,
		pass_ref=True, pass_ref_deco=True, icon=lock_icon, show_test=inp.test_shift))

	# Clear playlist
	tab_menu.add(MenuItem(_("Clear"), tauon.clear_playlist, pass_ref=True, disable_test=tauon.test_pl_tab_locked, pass_ref_deco=True))

	gui.delete_icon.xoff = 3
	gui.delete_icon.colour = ColourRGBA(249, 70, 70, 255)

	tab_menu.add(MenuItem(_("Delete"),
		pctl.delete_playlist_force, pass_ref=True, hint="Ctrl+W", icon=gui.delete_icon, disable_test=tauon.test_pl_tab_locked, pass_ref_deco=True))
	tauon.radio_tab_menu.add(MenuItem(_("Delete"),
		pctl.delete_playlist_force, pass_ref=True, hint="Ctrl+W", icon=gui.delete_icon, disable_test=tauon.test_pl_tab_locked, pass_ref_deco=True))

	service_asset = asset_loader(bag, bag.loaded_asset_dc, "playlist.png", True)
	jell_icon = MenuIcon(service_asset)
	jell_icon.colour = ColourRGBA(190, 100, 210, 255)
	jell_icon.xoff = 5
	jell_icon.yoff = 2

	tab_menu.br()

	extra_tab_menu.add(MenuItem(_("New Playlist"), tauon.new_playlist, icon=gui.add_icon))

	tab_menu.add(MenuItem(_("Upload"),
		tauon.upload_jellyfin_playlist, pass_ref=True, pass_ref_deco=True, icon=jell_icon, show_test=tauon.jellyfin_show_test))

	tab_menu.add(MenuItem(_("Regenerate"), tauon.regen_playlist_async, tauon.regenerate_deco, pass_ref=True, pass_ref_deco=True, hint="Alt+R"))
	tab_menu.add_sub(_("Generate…"), 150)
	tab_menu.add(MenuItem(_("Edit Generator..."), tauon.edit_generator_box, pass_ref=True))
	tab_menu.add_sub(_("Sort…"), 170)
	extra_tab_menu.add_sub(_("From Current…"), 133)
	# tab_menu.add(_("Sort by Filepath"), standard_sort, pass_ref=True, disable_test=test_pl_tab_locked, pass_ref_deco=True)
	# tab_menu.add(_("Sort Track Numbers"), tauon.sort_track_2, pass_ref=True)
	# tab_menu.add(_("Sort Year per Artist"), year_sort, pass_ref=True)

	tab_menu.add_to_sub(1, MenuItem(_("Sort by Imported Tracks"), tauon.imported_sort, pass_ref=True))
	tab_menu.add_to_sub(1, MenuItem(_("Sort by Imported Folders"), tauon.imported_sort_folders, pass_ref=True))
	tab_menu.add_to_sub(1, MenuItem(_("Sort by Filepath"), tauon.standard_sort, pass_ref=True))
	tab_menu.add_to_sub(1, MenuItem(_("Sort Track Numbers"), tauon.sort_track_2, pass_ref=True))
	tab_menu.add_to_sub(1, MenuItem(_("Sort Year per Artist"), tauon.year_sort, pass_ref=True))
	tab_menu.add_to_sub(1, MenuItem(_("Make Playlist Auto-Sorting"), tauon.make_auto_sorting, pass_ref=True))

	tab_menu.br()

	tab_menu.add(MenuItem(_("Rescan Folder"), pctl.re_import2, tauon.rescan_deco, pass_ref=True, pass_ref_deco=True))

	tab_menu.add(MenuItem(_("Paste"), tauon.s_append, tauon.paste_deco, pass_ref=True))
	tab_menu.add(MenuItem(_("Append Playing"), tauon.append_current_playing, tauon.append_deco, pass_ref=True))
	tab_menu.br()

	# tab_menu.add("Sort By Filepath", tauon.sort_path_pl, pass_ref=True)

	tab_menu.add(MenuItem(_("Import/Export…"), tauon.export_playlist_box.activate, pass_ref=True))

	tab_menu.add(MenuItem(_("Toggle Grouping"), tauon.pl_toggle_playlist_break, pass_ref=True))
	tab_menu.add_sub(_("Misc…"), 175)
	tab_menu.add_to_sub(2, MenuItem(_("Export Playlist Stats"), tauon.export_stats, pass_ref=True))
	tab_menu.add_to_sub(2, MenuItem(_("Export Albums CSV"), tauon.export_playlist_albums, pass_ref=True))
	tab_menu.add_to_sub(2, MenuItem(_("Transcode All"), tauon.convert_playlist, pass_ref=True))
	tab_menu.add_to_sub(2, MenuItem(_("Rescan Tags"), tauon.rescan_tags, pass_ref=True))
	# tab_menu.add_to_sub(_('Forget Import Folder'), 2, tauon.forget_pl_import_folder, rescan_deco, pass_ref=True, pass_ref_deco=True)
	# tab_menu.add_to_sub(_('Re-Import Last Folder'), 1, tauon.re_import, pass_ref=True)
	# tab_menu.add_to_sub(_('Quick Export XSPF'), 2, tauon.export_xspf, pass_ref=True)
	# tab_menu.add_to_sub(_('Quick Export M3U'), 2, tauon.export_m3u, pass_ref=True)
	tab_menu.add_to_sub(2, MenuItem(_("Engage Gallery Quick Add"), tauon.start_quick_add, pass_ref=True))
	tab_menu.add_to_sub(2, MenuItem(_("Set as Sync Playlist"), tauon.set_sync_playlist, tauon.sync_playlist_deco, pass_ref_deco=True, pass_ref=True))
	tab_menu.add_to_sub(2, MenuItem(_("Set as Downloads Playlist"), tauon.set_download_playlist, tauon.set_download_deco, pass_ref_deco=True, pass_ref=True))
	tab_menu.add_to_sub(2, MenuItem(_("Set podcast mode"), tauon.set_podcast_playlist, tauon.set_podcast_deco, pass_ref_deco=True, pass_ref=True))
	tab_menu.add_to_sub(2, MenuItem(_("Remove Duplicates"), tauon.remove_duplicates, pass_ref=True))

	# tab_menu.add_to_sub("Empty Playlist", 0, new_playlist)

	tab_menu.add_to_sub(0, MenuItem(_("Top Played Tracks"), tauon.gen_top_100, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Top Played Tracks"), tauon.gen_top_100, pass_ref=True))

	tab_menu.add_to_sub(0, MenuItem(_("Top Played Albums"), tauon.gen_folder_top, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Top Played Albums"), tauon.gen_folder_top, pass_ref=True))

	tab_menu.add_to_sub(0, MenuItem(_("Top Rated Tracks"), tauon.gen_top_rating, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Top Rated Tracks"), tauon.gen_top_rating, pass_ref=True))

	tab_menu.add_to_sub(0, MenuItem(_("Top Rated Albums"), tauon.gen_folder_top_rating, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Top Rated Albums"), tauon.gen_folder_top_rating, pass_ref=True))

	tab_menu.add_to_sub(0, MenuItem(_("File Modified"),tauon. gen_last_modified, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("File Modified"), tauon.gen_last_modified, pass_ref=True))

	# tab_menu.add_to_sub(_("File Path"), 0, stauon.tandard_sort, pass_ref=True)
	# extra_tab_menu.add_to_sub(_("File Path"), 0, tauon.standard_sort, pass_ref=True)

	tab_menu.add_to_sub(0, MenuItem(_("Longest Tracks"), tauon.gen_sort_len, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Longest Tracks"), tauon.gen_sort_len, pass_ref=True))

	tab_menu.add_to_sub(0, MenuItem(_("Longest Albums"), tauon.gen_folder_duration, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Longest Albums"), tauon.gen_folder_duration, pass_ref=True))

	tab_menu.add_to_sub(0, MenuItem(_("Year by Oldest"), tauon.gen_sort_date, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Year by Oldest"), tauon.gen_sort_date, pass_ref=True))

	tab_menu.add_to_sub(0, MenuItem(_("Year by Latest"), tauon.gen_sort_date_new, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Year by Latest"), tauon.gen_sort_date_new, pass_ref=True))

	# tab_menu.add_to_sub(_("Year by Artist"), 0, tauon.year_sort, pass_ref=True)
	# extra_tab_menu.add_to_sub(_("Year by Artist"), 0, tauon.year_sort, pass_ref=True)

	tab_menu.add_to_sub(0, MenuItem(_("Shuffled Tracks"), tauon.gen_500_random, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Shuffled Tracks"), tauon.gen_500_random, pass_ref=True))

	tab_menu.add_to_sub(0, MenuItem(_("Shuffled Albums"), tauon.gen_folder_shuffle, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Shuffled Albums"), tauon.gen_folder_shuffle, pass_ref=True))

	tab_menu.add_to_sub(0, MenuItem(_("Lucky Random"), tauon.gen_best_random, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Lucky Random"), tauon.gen_best_random, pass_ref=True))

	tab_menu.add_to_sub(0, MenuItem(_("Reverse Tracks"), tauon.gen_reverse, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Reverse Tracks"), tauon.gen_reverse, pass_ref=True))

	tab_menu.add_to_sub(0, MenuItem(_("Reverse Albums"), tauon.gen_folder_reverse, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Reverse Albums"), tauon.gen_folder_reverse, pass_ref=True))

	tab_menu.add_to_sub(0, MenuItem(_("Duplicate"), tauon.gen_dupe, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Duplicate"), tauon.gen_dupe, pass_ref=True))

	# tab_menu.add_to_sub("Filepath", 1, tauon.gen_sort_path, pass_ref=True)
	# tab_menu.add_to_sub("Artist → gui.abc", 0, tauon.gen_sort_artist, pass_ref=True)
	# tab_menu.add_to_sub("Album → gui.abc", 0, tauon.gen_sort_album, pass_ref=True)
	tab_menu.add_to_sub(0, MenuItem(_("Loved"), tauon.gen_love, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Loved"), tauon.gen_love, pass_ref=True))
	tab_menu.add_to_sub(0, MenuItem(_("Has Comment"), tauon.gen_comment, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Has Comment"), tauon.gen_comment, pass_ref=True))
	tab_menu.add_to_sub(0, MenuItem(_("Has Lyrics"), tauon.gen_lyrics, pass_ref=True))
	extra_tab_menu.add_to_sub(0, MenuItem(_("Has Lyrics"), tauon.gen_lyrics, pass_ref=True))

	playlist_menu.add(MenuItem(_("Paste"), tauon.paste, tauon.paste_deco))

	track_menu.add(MenuItem(_("Open Folder"), tauon.menu_open_folder, pass_ref=True, pass_ref_deco=True, icon=gui.folder_icon, disable_test=tauon.menu_open_folder_disable_test))
	track_menu.add(MenuItem(_("Track Info…"), tauon.activate_track_box, pass_ref=True, icon=gui.info_icon))

	gui.heartx_icon.colour = ColourRGBA(55, 55, 55, 255)
	gui.heartx_icon.xoff = 1
	gui.heartx_icon.yoff = 0
	gui.heartx_icon.colour_callback = tauon.heart_xmenu_colour

	# Mark track as 'liked'
	track_menu.add(MenuItem(_("Love"), tauon.love_index, tauon.love_decox, pass_ref=True, pass_ref_deco=True, icon=gui.heartx_icon))

	track_menu.add(MenuItem(_("Add to Queue"), tauon.add_to_queue, pass_ref=True, hint="MB3"))

	track_menu.add(MenuItem(_("↳ After Current Track"), tauon.add_to_queue_next, pass_ref=True, show_test=inp.test_shift))

	track_menu.add(MenuItem(_("Show in Gallery"), tauon.menu_show_in_gal, pass_ref=True, show_test=tauon.test_show))

	track_menu.add_sub(_("Meta…"), 160)

	track_menu.br()
	# track_menu.add('Cut', s_cut, pass_ref=False)
	# track_menu.add('Remove', del_selected)
	track_menu.add(MenuItem(_("Copy"), tauon.s_copy, pass_ref=False))

	# track_menu.add(_('Paste + Transfer Folder'), tauon.lightning_paste, pass_ref=False, show_test=tauon.lightning_move_test)

	track_menu.add(MenuItem(_("Paste"), tauon.menu_paste, tauon.paste_deco, pass_ref=True))

	track_menu.add(MenuItem(_("Delete Track File"), tauon.delete_track, pass_ref=True, icon=gui.delete_icon, show_test=inp.test_shift))

	track_menu.br()

	# gui.rename_tracks_icon.colour = ColourRGBA(244, 241, 66, 255)
	# gui.rename_tracks_icon.colour = ColourRGBA(204, 255, 66, 255)
	gui.rename_tracks_icon.colour = ColourRGBA(204, 100, 205, 255)
	gui.rename_tracks_icon.xoff = 1
	track_menu.add_to_sub(0, MenuItem(_("Rename Tracks…"), tauon.rename_track_box.activate, tauon.rename_tracks_deco, pass_ref=True,
		pass_ref_deco=True, icon=gui.rename_tracks_icon, disable_test=tauon.rename_track_box.disable_test))

	track_menu.add_to_sub(0, MenuItem(_("Edit fields…"), tauon.activate_trans_editor))

	gui.mod_folder_icon.colour = ColourRGBA(229, 98, 98, 255)
	track_menu.add_to_sub(0, MenuItem(_("Modify Folder…"), tauon.rename_folders, pass_ref=True, pass_ref_deco=True, icon=gui.mod_folder_icon, disable_test=tauon.rename_folders_disable_test))


	# track_menu.add_to_sub("Reset Track Play Count", 0, tauon.reset_play_count, pass_ref=True)

	# track_menu.add('Reload Metadata', tauon.reload_metadata, pass_ref=True)
	track_menu.add_to_sub(0, MenuItem(_("Rescan Tags"), tauon.menu_reload_metadata, pass_ref=True))

	mbp_icon = MenuIcon(asset_loader(bag, bag.loaded_asset_dc, "mbp-g.png"))
	mbp_icon.base_asset = asset_loader(bag, bag.loaded_asset_dc, "mbp-gs.png")

	mbp_icon.xoff = 2
	mbp_icon.yoff = -1

	if gui.scale == 1.25:
		mbp_icon.yoff = 0

	edit_icon = None
	if prefs.tag_editor_name == "Picard":
		edit_icon = mbp_icon

	track_menu.add_to_sub(0, MenuItem(_("Edit with"), tauon.launch_editor, pass_ref=True, pass_ref_deco=True, icon=edit_icon, render_func=tauon.edit_deco, disable_test=tauon.launch_editor_disable_test))
	track_menu.add_to_sub(0, MenuItem(_("Lyrics..."), tauon.show_lyrics_menu, pass_ref=True))
	track_menu.add_to_sub(0, MenuItem(_("Fix Mojibake"), tauon.intel_moji, pass_ref=True))
	track_menu.add_to_sub(0, MenuItem(_("Look Out the Window"), tauon.dream_room.toggle))
	# track_menu.add_to_sub("Copy Playlist", 1, transfer, pass_ref=True, args=[1, 3])

	folder_menu.add(MenuItem(_("Open Folder"), tauon.menu_open_folder, pass_ref=True, pass_ref_deco=True, icon=gui.folder_icon, disable_test=tauon.menu_open_folder_disable_test))

	folder_menu.add(MenuItem(_("Modify Folder…"), tauon.rename_folders, pass_ref=True, pass_ref_deco=True, icon=gui.mod_folder_icon, disable_test=tauon.rename_folders_disable_test))
	folder_tree_menu.add(MenuItem(_("Modify Folder…"), tauon.rename_folders, pass_ref=True, pass_ref_deco=True, icon=gui.mod_folder_icon, disable_test=tauon.rename_folders_disable_test))
	# folder_menu.add(_("Add Album to Queue"), tauon.add_album_to_queue, pass_ref=True)
	folder_menu.add(MenuItem(_("Add Album to Queue"), tauon.menu_add_album_to_queue, pass_ref=True))
	folder_menu.add(MenuItem(_("Enqueue Album Next"), tauon.add_album_to_queue_fc, pass_ref=True))

	gallery_menu.add(MenuItem(_("Modify Folder…"), tauon.rename_folders, pass_ref=True, pass_ref_deco=True, icon=gui.mod_folder_icon, disable_test=tauon.rename_folders_disable_test))

	folder_menu.add(MenuItem(_("Rename Tracks…"), tauon.rename_track_box.activate, tauon.rename_tracks_deco,
		pass_ref=True, pass_ref_deco=True, icon=gui.rename_tracks_icon, disable_test=tauon.rename_track_box.disable_test))
	folder_tree_menu.add(MenuItem(_("Rename Tracks…"), tauon.rename_track_box.activate, pass_ref=True, pass_ref_deco=True, icon=gui.rename_tracks_icon, disable_test=tauon.rename_track_box.disable_test))

	if not tauon.snap_mode:
		folder_menu.add(MenuItem(_("Edit with"), tauon.launch_editor_selection, pass_ref=True,
			pass_ref_deco=True, icon=edit_icon, render_func=tauon.edit_deco, disable_test=tauon.launch_editor_selection_disable_test))

	folder_tree_menu.add(MenuItem(_("Add Album to Queue"), tauon.menu_add_album_to_queue, pass_ref=True))
	folder_tree_menu.add(MenuItem(_("Enqueue Album Next"), tauon.add_album_to_queue_fc, pass_ref=True))

	folder_tree_menu.br()
	folder_tree_menu.add(MenuItem(_("Collapse All"), tauon.collapse_tree, tauon.collapse_tree_deco))
	folder_tree_menu.add(MenuItem(_("lock"), tauon.lock_folder_tree, tauon.lock_folder_tree_deco))

	# selection_menu.br()

	gui.transcode_icon.colour = ColourRGBA(239, 74, 157, 255)
	folder_menu.add(MenuItem(_("Rescan Tags"), tauon.menu_reload_metadata, pass_ref=True))
	folder_menu.add(MenuItem(_("Edit fields…"), tauon.activate_trans_editor))
	folder_menu.add(MenuItem(_("Vacuum Playtimes"), tauon.vacuum_playtimes, pass_ref=True, show_test=inp.test_shift))
	folder_menu.add(MenuItem(_("Transcode Folder"), tauon.convert_folder, tauon.transcode_deco, pass_ref=True, icon=gui.transcode_icon))
	gallery_menu.add(MenuItem(_("Transcode Folder"), tauon.convert_folder, tauon.transcode_deco, pass_ref=True, icon=gui.transcode_icon))
	folder_menu.br()

	# Copy album title text to clipboard
	folder_menu.add(MenuItem(_('Copy "Artist - Album"'), tauon.clip_title, pass_ref=True))

	# Copy artist name text to clipboard
	# folder_menu.add(_('Copy "Artist"'), clip_ar, pass_ref=True)

	selection_menu.add(MenuItem(_("Add to queue"), tauon.add_selected_to_queue_multi, tauon.selection_queue_deco))
	selection_menu.br()
	selection_menu.add(MenuItem(_("Rescan Tags"), tauon.reload_metadata_selection))
	selection_menu.add(MenuItem(_("Edit fields…"), tauon.activate_trans_editor))
	selection_menu.add(MenuItem(_("Edit with "), tauon.launch_editor_selection, pass_ref=True, pass_ref_deco=True, icon=edit_icon, render_func=tauon.edit_deco, disable_test=tauon.launch_editor_selection_disable_test))

	selection_menu.br()
	folder_menu.br()

	# It's complicated
	# folder_menu.add(_('Copy Folder From Library'), lightning_copy)

	selection_menu.add(MenuItem(_("Copy"), tauon.s_copy))
	selection_menu.add(MenuItem(_("Cut"), tauon.s_cut))
	selection_menu.add(MenuItem(_("Remove"), tauon.del_selected))
	selection_menu.add(MenuItem(_("Delete Files"), tauon.force_del_selected, show_test=inp.test_shift, icon=gui.delete_icon))

	folder_menu.add(MenuItem(_("Copy"), tauon.s_copy))
	gallery_menu.add(MenuItem(_("Copy"), tauon.s_copy))
	# folder_menu.add(_('Cut'), s_cut)
	# folder_menu.add(_('Paste + Transfer Folder'), tauon.lightning_paste, pass_ref=False, show_test=tauon.lightning_move_test)
	# gallery_menu.add(_('Paste + Transfer Folder'), tauon.lightning_paste, pass_ref=False, show_test=tauon.lightning_move_test)
	folder_menu.add(MenuItem(_("Remove"), tauon.del_selected))
	gallery_menu.add(MenuItem(_("Remove"), tauon.del_selected))

	track_menu.add(MenuItem(_("Search Artist on Wikipedia"), tauon.ser_wiki, pass_ref=True, show_test=tauon.show_wiki_menu_item))
	track_menu.add(MenuItem(_("Search Track on Genius"), tauon.ser_gen, pass_ref=True, show_test=tauon.show_gen_menu_item))

	son_icon = MenuIcon(asset_loader(bag, bag.loaded_asset_dc, "sonemic-g.png"))
	son_icon.base_asset = asset_loader(bag, bag.loaded_asset_dc, "sonemic-gs.png")

	son_icon.xoff = 1
	track_menu.add(MenuItem(_("Search Artist on Sonemic"), tauon.ser_rym, pass_ref=True, icon=son_icon, show_test=tauon.show_rym_menu_item))

	band_icon = MenuIcon(asset_loader(bag, bag.loaded_asset_dc, "band.png", True))
	band_icon.xoff = 0
	band_icon.yoff = 1
	band_icon.colour = ColourRGBA(96, 147, 158, 255)

	track_menu.add(MenuItem(_("Search Artist on Bandcamp"), tauon.ser_band, pass_ref=True, icon=band_icon, show_test=tauon.show_band_menu_item))

	# Copy metadata to clipboard
	# track_menu.add(_('Copy "Artist - Album"'), tauon.clip_aar_al, pass_ref=True)
	track_menu.add(MenuItem(_('Copy "Artist - Track"'), tauon.clip_ar_tr, pass_ref=True))
	track_menu.add(MenuItem(_("Copy TIDAL Album URL"), tauon.tidal_copy_album, show_test=tauon.is_tidal_track, pass_ref=True))

	track_menu.br()
	track_menu.add(MenuItem(_("Transcode Folder"), tauon.convert_folder, tauon.transcode_deco, pass_ref=True, icon=gui.transcode_icon))

	# Layout submenu: the columns toggles plus the tracklist settings, moved
	# here from Settings > Tracklist. (ViewBox.col stays as the shared columns
	# implementation — the toggle-columns keybind uses it too.)
	def menu_toggle_columns(ref=None) -> None:
		tauon.view_box.col(True)

	def _columns_off_test(ref=None) -> bool:
		return not gui.set_mode

	def _columns_on_test(ref=None) -> bool:
		return gui.set_mode

	def menu_toggle_set_bar(ref=None) -> None:
		gui.set_bar ^= True
		gui.update_layout = True
		gui.pl_update = 1

	def _bar_deco() -> Decorator:
		text = _("Hide Bar") if gui.set_bar else _("Show Bar")
		return Decorator(colours.menu_text, colours.menu_background, text)

	def _tl_toggle_left_align() -> None:
		prefs.row_title_format = 1 if prefs.row_title_format == 2 else 2
		gui.update += 1
		gui.pl_update = 1

	def _tl_toggle_genre() -> None:
		prefs.row_title_genre ^= True
		gui.update += 1
		gui.pl_update = 1

	def _tl_toggle_scrollbar_left() -> None:
		prefs.tracklist_scrollbar_left ^= True
		gui.update += 1
		gui.pl_update = 1

	def _tl_stepper(attr: str, lo: int, hi: int) -> tuple[Callable, Callable, Callable]:
		def value(ref=None) -> int:
			return getattr(prefs, attr)
		def step(direction: int) -> Callable[..., None]:
			def cb(ref=None) -> None:
				new = min(hi, max(lo, getattr(prefs, attr) + direction))
				if new != getattr(prefs, attr):
					setattr(prefs, attr, new)
					gui.update_layout = True
					gui.pl_update = 1
			return cb
		return value, step(-1), step(1)

	def add_layout_sub(menu: Menu) -> None:
		"""Append the tracklist Layout submenu to ``menu`` (fresh MenuItems per
		menu; the callbacks/decos are shared closures above)."""
		menu.add_sub(_("Layout"), 190)
		sub = menu.sub_number - 1
		menu.add_to_sub(sub, MenuItem(_("Show Columns"), menu_toggle_columns, show_test=_columns_off_test))
		menu.add_to_sub(sub, MenuItem(_("Hide Columns"), menu_toggle_columns, show_test=_columns_on_test))
		menu.add_to_sub(sub, MenuItem(_("Hide Bar"), menu_toggle_set_bar, _bar_deco,
			no_exit=True, disable_test=lambda: not gui.set_mode))

		def toggle_item(label: str, func: Callable, get_state: Callable[[], bool | None]) -> None:
			def cb(ref=None) -> None:
				func()
			# no_exit: checkbox-style toggles keep the submenu open (the state
			# box flips in place), like the custom-layout Layout… submenu toggles.
			menu.add_to_sub(sub, MenuItem(label, cb, check_test=get_state, no_exit=True))

		toggle_item(_("Loves"), tauon.heart_toggle, lambda: tauon.heart_toggle(1))
		toggle_item(_("Track ratings"), tauon.rating_toggle, lambda: tauon.rating_toggle(1))
		toggle_item(_("Album ratings"), tauon.album_rating_toggle, lambda: tauon.album_rating_toggle(1))
		toggle_item(_("Star hints"), tauon.star_toggle, lambda: tauon.star_toggle(1))
		toggle_item(_("Playcount lines"), tauon.star_line_toggle, lambda: tauon.star_line_toggle(1))
		toggle_item(_("Left align"), _tl_toggle_left_align, lambda: prefs.row_title_format == 2)
		toggle_item(_("Genre"), _tl_toggle_genre, lambda: prefs.row_title_genre)
		toggle_item(_("Year"), tauon.toggle_append_date, lambda: tauon.toggle_append_date(1))
		toggle_item(_("Duration"), tauon.toggle_append_total_time, lambda: tauon.toggle_append_total_time(1))
		toggle_item(_("Scroll bar on left"), _tl_toggle_scrollbar_left, lambda: prefs.tracklist_scrollbar_left)

		menu.add_incrementor_to_sub(sub, _("Font size"), *_tl_stepper("playlist_font_size", 12, 17))
		menu.add_incrementor_to_sub(sub, _("Row height"), *_tl_stepper("playlist_row_height", 15, 45))
		menu.add_incrementor_to_sub(sub, _("Text baseline"), *_tl_stepper("tracklist_y_text_offset", -10, 10))

		menu.add_to_sub(sub, MenuItem(_("Thin default"), lambda: tauon.pref_box.small_preset()))
		menu.add_to_sub(sub, MenuItem(_("Thick default"), lambda: tauon.pref_box.large_preset()))

		# Optional search-provider items in the track menu, moved here from
		# Settings > Function ("Track menu extras")
		toggle_item(_("Wikipedia artist search"), tauon.toggle_wiki, lambda: tauon.toggle_wiki(1))
		toggle_item(_("Sonemic artist search"), tauon.toggle_rym, lambda: tauon.toggle_rym(1))
		toggle_item(_("Bandcamp artist search"), tauon.toggle_band, lambda: tauon.toggle_band(1))
		toggle_item(_("Genius track search"), tauon.toggle_gen, lambda: tauon.toggle_gen(1))

	add_layout_sub(track_menu)
	folder_menu.br()
	add_layout_sub(folder_menu)


	# Create top menu
	x_menu          = tauon.x_menu
	view_menu       = tauon.view_menu
	set_menu        = tauon.set_menu
	set_menu_hidden = tauon.set_menu_hidden
	vis_menu        = tauon.vis_menu
	window_menu     = tauon.window_menu
	field_menu      = tauon.field_menu

	tauon.stop_menu.add(MenuItem(_("Always stop after album"), tauon.stop_mode_album_persist))
	tauon.stop_menu.add(MenuItem(_("Always stop after track"), tauon.stop_mode_track_persist))
	tauon.stop_menu.add(MenuItem(_("Stop after album"), tauon.stop_mode_album))
	tauon.stop_menu.add(MenuItem(_("Stop after track"), tauon.stop_mode_track))
	tauon.stop_menu.add(MenuItem(_("Continue Play"), tauon.stop_mode_off))

	window_menu.add(MenuItem(_("Minimize"), tauon.do_minimize_button))
	window_menu.add(MenuItem(_("Maximize"), tauon.do_maximize_button))
	window_menu.add(MenuItem(_("Exit"),     tauon.do_exit_button))

	# Copy text
	field_menu.add(MenuItem(_("Copy"), field_copy, pass_ref=True))
	# Paste text
	field_menu.add(MenuItem(_("Paste"), field_paste, pass_ref=True))
	# Clear text
	field_menu.add(MenuItem(_("Clear"), field_clear, pass_ref=True))

	vis_menu.add(MenuItem(_("Off"), tauon.vis_off))
	vis_menu.add(MenuItem(_("Level Meter"), tauon.level_on))
	vis_menu.add(MenuItem(_("Spectrum Visualizer"), tauon.spec_on))
	# vis_menu.add(_("Spectrogram"), spec2_def)

	# Mark for translation
	_("Time")
	_("Filepath")

	# set_menu.add(MenuItem(_("Sort Ascending"), tauon.sort_ass, pass_ref=True, disable_test=tauon.view_pl_is_locked, pass_ref_deco=True))
	# set_menu.add(MenuItem(_("Sort Descending"), tauon.sort_dec, pass_ref=True, disable_test=tauon.view_pl_is_locked, pass_ref_deco=True))
	# set_menu.br()

	set_menu_hidden.add(MenuItem(_("Show bar"), tauon.show_set_bar))

	tauon.sa_regen_menu()

	gui.add_icon.xoff = 3
	gui.add_icon.yoff = 0
	gui.add_icon.colour = ColourRGBA(237, 80, 221, 255)
	gui.add_icon.colour_callback = tauon.new_playlist_colour_callback

	x_menu.add(MenuItem(_("New Playlist"), tauon.new_playlist, tauon.new_playlist_deco, icon=gui.add_icon))

	x_menu.add(MenuItem(_("Clean Database!"), tauon.clean_db_fast, tauon.clean_db_deco, show_test=tauon.clean_db_show_test))

	# x_menu.add(_("Internet Radio…"), activate_radio_box)

	x_menu.add(MenuItem(_("Import Music Folder"), tauon.import_music, show_test=tauon.show_import_music))

	x_menu.br()

	gui.settings_icon.xoff = 0
	gui.settings_icon.yoff = 2
	gui.settings_icon.colour = ColourRGBA(232, 200, 96, 255)  # [230, 152, 118, 255]#[173, 255, 47, 255] #[198, 237, 56, 255]
	# gui.settings_icon.colour = ColourRGBA(180, 140, 255, 255)
	x_menu.add(MenuItem(_("Settings"), tauon.activate_info_box, icon=gui.settings_icon))
	x_menu.add_sub(_("Database…"), 190)

	if tauon.dev_mode:
		def dev_mode_enable_save_state() -> None:
			bag.should_save_state = True
			tauon.show_message(_("Enabled saving state"))

		def dev_mode_disable_save_state() -> None:
			bag.should_save_state = False
			tauon.show_message(_("Disabled saving state"))

		x_menu.add_sub(_("Dev Mode"), 190)
		x_menu.add_to_sub(1, MenuItem(_("Enable Saving State"), tauon.dev_mode_enable_save_state))
		x_menu.add_to_sub(1, MenuItem(_("Disable Saving State"), tauon.dev_mode_disable_save_state))

	# Top panel layout settings (moved here from the Settings UI)
	top_panel_sub = x_menu.sub_number
	x_menu.add_sub(_("Top Panel Layout…"), 180)
	x_menu.add_to_sub(top_panel_sub, MenuItem(
		_("Show Tabs"), tauon.toggle_top_tabs, no_exit=True,
		check_test=lambda: tauon.toggle_top_tabs(1)))
	x_menu.add_to_sub(top_panel_sub, MenuItem(
		_("Show Visualiser"), tauon.toggle_level_meter, no_exit=True,
		check_test=lambda: tauon.toggle_level_meter(1)))

	x_menu.br()

	# x_menu.add('Toggle Side panel', tauon.toggle_combo_view, tauon.combo_deco)

	x_menu.add_to_sub(0, MenuItem(_("Export as CSV"), tauon.export_database))
	x_menu.add_to_sub(0, MenuItem(_("Reload All Folders"), pctl.rescan_all_folders))
	x_menu.add_to_sub(0, MenuItem(_("Play History to Playlist"), tauon.q_to_playlist))
	x_menu.add_to_sub(0, MenuItem(_("Reset Image Cache"), tauon.clear_img_cache))
	x_menu.add_to_sub(0, MenuItem(_("Clear Artist Cache"), tauon.clear_artist_cache))

	x_menu.add_to_sub(0, MenuItem(_("Remove Network Tracks"), tauon.clean_db2))
	x_menu.add_to_sub(0, MenuItem(_("Remove Missing Tracks"), tauon.clean_db))
	x_menu.add_to_sub(0, MenuItem(_("Import FMPS Ratings"), tauon.import_fmps))
	x_menu.add_to_sub(0, MenuItem(_("Import POPM Ratings"), tauon.import_popm))
	x_menu.add_to_sub(0, MenuItem(_("Reset User Ratings"), tauon.clear_ratings))
	x_menu.add_to_sub(0, MenuItem(_("Find Incomplete Albums"), tauon.find_incomplete))
	x_menu.add_to_sub(0, MenuItem(_("Mark Missing as Found"), pctl.reset_missing_flags, show_test=inp.test_shift))

	if tauon.chrome:
		x_menu.add_sub(_("Chromecast…"), 220)
		shooter(tauon.cast_search2)

	tauon.chrome_menu = x_menu

	#x_menu.add(_("Cast…"), cast_search, cast_deco)


	mode_menu.add(MenuItem(_("Tab"), tauon.set_mini_mode_D))
	mode_menu.add(MenuItem(_("Mini"), tauon.set_mini_mode_A1))
	# mode_menu.add(_('Mini Mode Large'), tauon.set_mini_mode_A2)
	mode_menu.add(MenuItem(_("Signal"), tauon.set_mini_mode_E))
	mode_menu.add(MenuItem(_("Slate"), tauon.set_mini_mode_C1))
	mode_menu.add(MenuItem(_("Square"), tauon.set_mini_mode_B1))
	mode_menu.add(MenuItem(_("Square Large"), tauon.set_mini_mode_B2))

	mode_menu.br()
	mode_menu.add(MenuItem(_("Copy Title to Clipboard"), tauon.copy_bb_metadata))

	extra_menu.add_sub(_("Mini Mode"), 175)
	extra_menu.add_to_sub(0, MenuItem(_("Tab"), tauon.set_mini_mode_D))
	extra_menu.add_to_sub(0, MenuItem(_("Mini"), tauon.set_mini_mode_A1))
	extra_menu.add_to_sub(0, MenuItem(_("Signal"), tauon.set_mini_mode_E))
	extra_menu.add_to_sub(0, MenuItem(_("Slate"), tauon.set_mini_mode_C1))
	extra_menu.add_to_sub(0, MenuItem(_("Square"), tauon.set_mini_mode_B1))
	extra_menu.add_to_sub(0, MenuItem(_("Square Large"), tauon.set_mini_mode_B2))

	extra_menu.add(MenuItem(_("Random Track"), tauon.random_track, hint=";"))

	gui.radiorandom_icon.xoff = 1
	gui.radiorandom_icon.yoff = 0
	gui.radiorandom_icon.colour = ColourRGBA(153, 229, 133, 255)
	extra_menu.add(MenuItem(_("Radio Random"), tauon.radio_random, hint="/", icon=gui.radiorandom_icon))

	gui.revert_icon.xoff = 1
	gui.revert_icon.yoff = 0
	gui.revert_icon.colour = ColourRGBA(229, 102, 59, 255)
	extra_menu.add(MenuItem(_("Revert"), pctl.revert, hint="Shift+/", icon=gui.revert_icon))

	# extra_menu.add('Toggle Repeat', tauon.toggle_repeat, hint='COMMA')


	# extra_menu.add('Toggle Random', tauon.toggle_random, hint='PERIOD')
	extra_menu.add(MenuItem(_("Clear Queue"), tauon.clear_queue, tauon.queue_deco, hint="Alt+Shift+Q"))

	gui.heart_icon.colour = ColourRGBA(245, 60, 60, 255)
	gui.heart_icon.xoff = 3
	gui.heart_icon.yoff = 0

	if gui.scale == 1.25:
		gui.heart_icon.yoff = 1

	gui.heart_icon.colour_callback = tauon.heart_menu_colour
	extra_menu.add(MenuItem(_("Love"), tauon.bar_love_notify, tauon.love_deco, icon=gui.heart_icon))
	extra_menu.add(MenuItem(_("Global Search"), tauon.activate_search_overlay, hint="Ctrl+G"))
	extra_menu.add(MenuItem(_("Locate Artist"), tauon.locate_artist))
	extra_menu.add(MenuItem(_("Go To Playing"), tauon.goto_playing_extra, hint="'"))

	extra_menu.br()

	theme_files = os.listdir(str(tauon.install_directory / "theme"))
	theme_files.sort()

	lastfm_icon = MenuIcon(gui.last_fm_icon)

	if gui.scale in (2, 1.25):
		lastfm_icon.xoff = 0
	else:
		lastfm_icon.xoff = -1

	lastfm_icon.yoff = 1

	lastfm_icon.colour = ColourRGBA(249, 70, 70, 255)
	lastfm_icon.colour_callback = tauon.lastfm_colour

	lb_icon = MenuIcon(asset_loader(bag, bag.loaded_asset_dc, "lb-g.png"))
	lb_icon.base_asset = asset_loader(bag, bag.loaded_asset_dc, "lb-gs.png")

	lb_icon.mode_callback = tauon.lb_mode

	lb_icon.xoff = 3
	lb_icon.yoff = -1

	if gui.scale == 1.25:
		lb_icon.yoff = 0

	if prefs.auto_lfm:
		listen_icon = lastfm_icon
	elif tauon.lb.enable:
		listen_icon = lb_icon
	else:
		listen_icon = None

	x_menu.add(MenuItem("LFM", tauon.lastfm.toggle, tauon.last_fm_menu_deco, icon=listen_icon, show_test=tauon.lastfm_menu_test))
	#x_menu.add(MenuItem(_("Synced Lyrics Editor"), tauon.view_box.activate_synced_lyric_editor)) #show_test=tauon.exit_shuffle_layout))
	#x_menu.add(MenuItem(_("Donate"), open_donate_link))
	x_menu.add(MenuItem(_("Online Manual"), tauon.open_manual_link))
	x_menu.add(MenuItem(_("Show Release Notes"), tauon.nagbox.show))
	x_menu.add(MenuItem(_("Exit"), tauon.exit, hint="Alt+F4", set_ref="User clicked menu exit button", pass_ref=+True))
	x_menu.add(MenuItem(_("Disengage Quick Add"), tauon.stop_quick_add, show_test=tauon.show_stop_quick_add))
	x_menu.add(MenuItem(_("Exit Shuffle Lockdown"), tauon.toggle_shuffle_layout, tauon.toggle_shuffle_layout_deco, show_test=tauon.exit_shuffle_layout))


	gui.pt_on = Timer()
	gui.pt_off = Timer()
	gui.pt = 0

	# ------------------------------------------------------------------------------------
	# WEBSERVER
	if prefs.enable_web is True:
		webThread = threading.Thread(
			target=webserve, args=[pctl, prefs, gui, tauon.album_art_gen, str(tauon.install_directory), tauon.strings, tauon])
		webThread.daemon = True
		webThread.start()

	ctlThread = threading.Thread(target=controller, args=[tauon])
	ctlThread.daemon = True
	ctlThread.start()

	if prefs.enable_remote:
		tauon.start_remote()
		tauon.remote_limited = False
	# --------------------------------------------------------------

	pref_box = tauon.pref_box
	radiobox = tauon.radiobox

	radio_entry_menu.add(MenuItem(_("Visit Website"), visit_radio_site, tauon.visit_radio_site_deco, pass_ref=True, pass_ref_deco=True))
	radio_entry_menu.add(MenuItem(_("Save"), tauon.save_to_radios, pass_ref=True))

	artist_list_menu.add(MenuItem(_("Filter to New Playlist"), tauon.create_artist_pl, pass_ref=True, icon=gui.filter_icon))
	artist_list_menu.add_sub(_("View..."), 140)
	artist_list_menu.add_to_sub(0, MenuItem(_("Sort Alphabetically"), tauon.aa_sort_alpha))
	artist_list_menu.add_to_sub(0, MenuItem(_("Sort by Popularity"), tauon.aa_sort_popular))
	artist_list_menu.add_to_sub(0, MenuItem(_("Sort by Playtime"), tauon.aa_sort_play))
	artist_list_menu.add_to_sub(0, MenuItem(_("Toggle Thumbnails"), tauon.toggle_artist_list_style))
	artist_list_menu.add_to_sub(0, MenuItem(_("Toggle Filter"), tauon.toggle_artist_list_threshold, tauon.toggle_artist_list_threshold_deco))

	artist_info_menu.add(MenuItem(_("Download Artist Data"), tauon.artist_info_box.manual_dl, tauon.artist_dl_deco, show_test=tauon.test_artist_dl))
	artist_info_menu.add(MenuItem(_("Clear Bio"), tauon.flush_artist_bio, pass_ref=True, show_test=inp.test_shift))
	radio_context_menu.add(MenuItem(_("Edit..."), tauon.rename_station, pass_ref=True))
	radio_context_menu.add(
		MenuItem(_("Visit Website"), visit_radio_station, tauon.visit_radio_station_site_deco, pass_ref=True, pass_ref_deco=True))
	radio_context_menu.add(MenuItem(_("Remove"), tauon.remove_station, pass_ref=True))

	tauon.dl_menu.add(MenuItem(_("Dismiss"), tauon.dismiss_dl))

	# Set SDL window drag areas
	# if system != "Windows":

	c_hit_callback = sdl3.SDL_HitTest(tauon.hit_callback)
	sdl3.SDL_SetWindowHitTest(t_window, c_hit_callback, 0)

	# --------------------------------------------------------------------------------------------

	# caster = threading.Thread(target=enc, args=[tauon])
	# caster.daemon = True
	# caster.start()

	tauon.thread_manager.ready_playback()

	try:
		tauon.thread_manager.d["caster"] = [lambda: x, [tauon], None]
	except Exception:
		logging.exception("Failed to cast")

	tauon.thread_manager.d["worker"]  = [worker1, [tauon], None]
	tauon.thread_manager.d["search"]  = [worker2, [tauon], None]
	tauon.thread_manager.d["gallery"] = [worker3, [tauon], None]
	tauon.thread_manager.d["style"]   = [worker4, [tauon], None]
	tauon.thread_manager.d["radio-thumb"] = [tauon.radio_thumb_gen.loader, [], None]

	tauon.thread_manager.ready("search")
	tauon.thread_manager.ready("gallery")
	tauon.thread_manager.ready("worker")

	# thread = threading.Thread(target=worker1)
	# thread.daemon = True
	# thread.start()
	# # #
	# thread = threading.Thread(target=worker2)
	# thread.daemon = True
	# thread.start()
	# # #
	# thread = threading.Thread(target=worker3)
	# thread.daemon = True
	# thread.start()
	#
	# thread = threading.Thread(target=worker4)
	# thread.daemon = True
	# thread.start()

	gui.playlist_view_length = int(((window_size[1] - gui.playlist_top) / 16) - 1)

	d_border = 1

	mouse_moved = False

	for item in sys.argv[1:]:
		if (
			(os.path.isdir(item) or os.path.isfile(item) or "file://" in item)
			and not item.endswith(".py")
			and not item.endswith(".exe")
			and not item.endswith("tauonmb")
			and not item.startswith("-")
		):
			tauon.open_uri(item)

	sdl_version = sdl3.SDL_GetVersion()
	logging.info(f"Using SDL version: {sdl_version!s}")

	# C-ML
	# if prefs.backend == Backend.GSTREAMER:
	#     logging.warning("Using GStreamer as fallback. Some functions disabled")
	if prefs.backend == Backend.NONE:
		tauon.show_message(_("ERROR: No backend found"), mode="error")

	# SDL_RenderClear(renderer)
	# SDL_RenderPresent(renderer)

	# SDL_ShowWindow(t_window)

	# Clear spectogram texture
	sdl3.SDL_SetRenderTarget(renderer, gui.spec2_tex)
	sdl3.SDL_RenderClear(renderer)
	ddt.rect((0, 0, 1000, 1000), ColourRGBA(7, 7, 7, 255))

	sdl3.SDL_SetRenderTarget(renderer, gui.spec1_tex)
	sdl3.SDL_RenderClear(renderer)
	ddt.rect((0, 0, 1000, 1000), ColourRGBA(7, 7, 7, 255))

	sdl3.SDL_SetRenderTarget(renderer, gui.spec_level_tex)
	sdl3.SDL_RenderClear(renderer)
	ddt.rect((0, 0, 1000, 1000), ColourRGBA(7, 7, 7, 255))

	sdl3.SDL_SetRenderTarget(renderer, None)

	# sdl3.SDL_RenderPresent(renderer)

	# time.sleep(3)

	sdl3.SDL_StartTextInput(t_window)

	# SDL_SetHint(SDL_HINT_IME_INTERNAL_EDITING, b"1")
	# SDL_EventState(SDL_SYSWMEVENT, 1)
	tauon.test_show_add_home_music()

	if gui.restart_album_mode:
		tauon.toggle_album_mode(force_on=True)

	if gui.remember_library_mode:
		tauon.toggle_library_mode()

	if prefs.reload_play_state and prefs.reload_state and prefs.reload_state[0] == PlayingState.PLAYING:
		pctl.jump_time = prefs.reload_state[1]
		pctl.play()
	elif not prefs.reload_play_state:
		prefs.reload_state = None

	pctl.refresh_now_playing()

	prefs.theme = get_theme_number(dirs, prefs.theme_name)

	if pctl.pl_to_id(pctl.active_playlist_viewing) in gui.gallery_positions:
		gui.album_scroll_px = gui.gallery_positions[pctl.pl_to_id(pctl.active_playlist_viewing)]

	for playlist in pctl.multi_playlist:
		pctl.try_reload_playlist_from_file(playlist, False)
	tauon.playlist_autoscan = True
	tauon.thread_manager.ready("worker")

	# Hold the splash/loading screen for a minimum duration
	# while tauon.core_timer.get() < 0.5:
	#     time.sleep(0.01)

	# Resize menu widths to text length (length can vary due to translations)
	for menu in Menu.instances:
		w = 0
		icon_space = 0

		if menu.show_icons:
			icon_space = 25 * gui.scale

		for item in menu.items:
			if item is None:
				continue
			test_width = ddt.get_text_w(item.title, menu.font) + icon_space + 21 * gui.scale
			if not item.is_sub_menu and item.hint:
				test_width += ddt.get_text_w(item.hint, menu.font) + 4 * gui.scale

			w = max(test_width, w)

			# sub
			if item.is_sub_menu:
				ww = 0
				sub_icon_space = 0
				for sub_item in menu.subs[item.sub_menu_number]:
					if sub_item.icon is not None:
						sub_icon_space = 25
						break
				for sub_item in menu.subs[item.sub_menu_number]:
					test_width = math.ceil(ddt.get_text_w(sub_item.title, menu.font) / gui.scale) + sub_icon_space + 23
					ww = max(test_width, ww)

				item.sub_menu_width = max(ww, item.sub_menu_width)

		menu.w = max(w, menu.w)

	if gui.restore_showcase_view:
		tauon.enter_showcase_view()
	if gui.restore_radio_view:
		tauon.enter_radio_view()

	if bag.macos:
		try:
			from tauon.t_modules.t_macos_menubar import MacMenuBar
		except ModuleNotFoundError:
			logging.warning("Unable to import PyObjC menubar support, macOS menus will be unavailable.")
		except Exception:
			logging.exception("Failed to initialise macOS menubar support.")
		else:
			tauon.macos_menu_bar = MacMenuBar(tauon)
			tauon.macos_menu_bar.install()

	# pctl.switch_playlist(len(pctl.multi_playlist) - 1)

	sdl3.SDL_SetRenderTarget(renderer, overlay_texture_texture)

	block_size = 3

	x = 0
	y = 0
	while y < 300:
		x = 0
		while x < 300:
			ddt.rect((x, y, 1, 1), ColourRGBA(0, 0, 0, 70))
			ddt.rect((x + 2, y + 0, 1, 1), ColourRGBA(0, 0, 0, 70))
			ddt.rect((x + 2, y + 2, 1, 1), ColourRGBA(0, 0, 0, 70))
			ddt.rect((x + 0, y + 2, 1, 1), ColourRGBA(0, 0, 0, 70))

			x += block_size
		y += block_size

	tauon.sync_target.text = prefs.sync_target
	sdl3.SDL_SetRenderTarget(renderer, None)

	if tauon.windows:
		sdl3.SDL_SetWindowResizable(t_window, True)  # Not sure why this is needed

	# Generate theme buttons
	tauon.pref_box.refresh_theme_presets()

	pctl.total_playtime = tauon.star_store.get_total()

	# MAIN LOOP
	# main_loop(tauon)

	event = sdl3.SDL_Event()

	# ---------------------------------------------------------------------
	# Player variables
	draw_sep_hl = False

	# Playlist Panel
	scroll_opacity = 0

	row_len = 5
	b_info_y = int(window_size[1] * 0.7)  # For future possible panel below playlist

	gal_up = False
	gal_down = False
	gal_left = False
	gal_right = False

	scroll_hold = False
	album_scroll_hold = False
	scroll_point = 0
	scroll_bpoint = 0
	sbl = 50
	sbp = 100

	time_last_save = 0
	spec_smoothing = True  # TODO(Martin): Always true
	resize_mode = False  # TODO(Martin): Always false
	power = 0
	reset_render = False
	c_yax = 0
	c_yax_timer = Timer()
	c_xax = 0
	c_xax_timer = Timer()
	c_xay = 0
	c_xay_timer = Timer()
	rt = 0
	ggc = 2
	pl_bg = None
	if (tauon.user_directory / "bg.png").exists():
		pl_bg = LoadImageAsset(
			scaled_asset_directory=tauon.dirs.scaled_asset_directory,
			path=str(tauon.user_directory / "bg.png"),
			is_full_path=True,
		)

	playlist_render = StandardPlaylist(tauon, pl_bg)
	tauon.playlist_render = playlist_render  # exposed for the Custom Layout Tracklist widget
	meta_box = MetaBox(tauon)
	tauon.meta_box = meta_box  # exposed for the Custom Layout metadata/lyrics widgets
	showcase = Showcase(tauon)
	tauon.showcase = showcase  # exposed for the Custom Layout Sticks visualiser widget

	def render_gallery() -> None:
		"""Render the album gallery grid (art tiles, scrolling, input handling and
		the power tag bar). Extracted verbatim from the main-loop album-mode block
		so the Custom Layout's Album Gallery widget can reuse it. Geometry derives
		from gui.rspw / gui.panelY / gui.panelBY / window_size, so a caller places
		it by pointing those at a target rect (the preset path calls it with them
		untouched, so preset rendering is byte-identical).
		"""
		nonlocal gal_up, gal_down, gal_left, gal_right, row_len
		try:
			# Arrow key input
			if gal_right:
				gal_right = False
				tauon.gal_jump_select(False, 1)
				tauon.goto_album(pctl.selected_in_playlist)
				pctl.playlist_view_position = pctl.selected_in_playlist
				logging.debug("Position changed by gallery key press")
				gui.pl_update = 1
			if gal_down:
				gal_down = False
				tauon.gal_jump_select(False, row_len)
				tauon.goto_album(pctl.selected_in_playlist, down=True)
				pctl.playlist_view_position = pctl.selected_in_playlist
				logging.debug("Position changed by gallery key press")
				gui.pl_update = 1
			if gal_left:
				gal_left = False
				tauon.gal_jump_select(True, 1)
				tauon.goto_album(pctl.selected_in_playlist)
				pctl.playlist_view_position = pctl.selected_in_playlist
				logging.debug("Position changed by gallery key press")
				gui.pl_update = 1
			if gal_up:
				gal_up = False
				tauon.gal_jump_select(True, row_len)
				tauon.goto_album(pctl.selected_in_playlist)
				pctl.playlist_view_position = pctl.selected_in_playlist
				logging.debug("Position changed by gallery key press")
				gui.pl_update = 1

			w = gui.rspw

			if window_size[0] < 750 * gui.scale:
				w = window_size[0] - 20 * gui.scale
				if gui.lsp:
					w -= gui.lspw

			# Gallery: Compact reframes the window to the segment and applies its
			# own uniform margins below — the narrow-window left inset would
			# double up on the left and be missing from the top/right.
			if gui.gallery_forced_row_len:
				w = gui.rspw

			x = window_size[0] - w
			# sx = x
			# sw = w
			h = window_size[1] - gui.panelY - gui.panelBY

			if not gui.show_playlist and inp.mouse_click:
				left = gui.playlist_left

				if (
					left < inp.mouse_position[0] < left + 20 * gui.scale
					and window_size[1] - gui.panelBY > inp.mouse_position[1] > gui.panelY
				):
					tauon.toggle_album_mode()
					inp.mouse_click = False
					inp.mouse_down = False

			rect = [x, gui.panelY, w, h]
			if not gui.show_playlist and x > gui.playlist_left:
				ddt.rect([gui.playlist_left, gui.panelY, x - gui.playlist_left, h], colours.gallery_background)
			ddt.rect(rect, colours.gallery_background)

			# ddt.rect_r(rect, [255, 0, 0, 200], True)

			area_x = w + 38 * gui.scale
			# area_x = w - 40 * gui.scale

			row_len = int((area_x - gui.album_h_gap) / (tauon.album_mode_art_size + gui.album_h_gap))

			# The Album Grid widget forces the row length (its art size is derived
			# from it, so the width formula above can land one off). 0 in presets.
			if gui.gallery_forced_row_len:
				row_len = gui.gallery_forced_row_len

			# logging.info(row_len)

			compact = 40 * gui.scale
			if gui.custom_mode:
				compact -= 30 * gui.scale
			a_offset = 7 * gui.scale

			l_area = x
			r_area = w
			# c_area = r_area // 2 + l_area

			ddt.text_background_colour = colours.gallery_background

			line1_colour = colours.gallery_artist_line
			line2_colour = colours.grey(240)  # colours.side_bar_line1

			if colours.side_panel_background != colours.gallery_background:
				line2_colour = ColourRGBA(240, 240, 240, 255)
				line1_colour = alpha_mod(ColourRGBA(20, 220, 220, 255), 120)

			if test_lumi(colours.gallery_background) < 0.5 or (prefs.use_card_style and colours.lm):
				line1_colour = colours.grey(80)
				line2_colour = colours.grey(40)

			if row_len == 0:
				row_len = 1

			dev = int((r_area - compact) / (row_len + 0))

			# Gallery: Compact (forced row length): the centred preset formula
			# below leaves asymmetric outer margins (~h_gap/2 - 2px left,
			# ~h_gap/2 + 12px right); the grid instead lays tiles on a uniform
			# float pitch inset by gallery_grid_margin on each side, so the
			# left and right margins match.
			grid_edge = False
			grid_margin = 0
			if gui.gallery_forced_row_len:
				grid_edge = True
				grid_margin = gui.gallery_grid_margin
				dev = (r_area - grid_margin * 2 + gui.album_h_gap) / row_len
			# The grid draws the title lines slightly closer to the art (the row
			# pitch is unchanged — the text just sits higher within it). 0 = preset.
			text_lift = round(5 * gui.scale) if grid_edge else 0
			artist_lift = round(6 * gui.scale) if grid_edge else 0

			render_pos = 0
			album_on = 0

			max_scroll = round(
				(math.ceil((len(tauon.album_dex)) / row_len) - 1)
				* (tauon.album_mode_art_size + gui.album_v_gap)
			) - round(50 * gui.scale)

			# Mouse wheel scrolling. The momentum channel key is per Gallery:
			# Compact instance (swapped in around this call) so several
			# galleries scroll independently; the preset key in gallery_scroll_key
			# is the default.
			scroll_key = gui.gallery_scroll_key
			gallery_scroll_area = (window_size[0] - w, gui.panelY, w, window_size[1] - gui.panelBY - gui.panelY)
			touch_scroll = inp.touch_scroll_y != 0 and coll_point(inp.touch_position, gallery_scroll_area)
			use_smooth_gallery = (
				tauon.smooth_scroll.enabled()
				or touch_scroll
				or tauon.smooth_scroll.active(scroll_key)
			)
			row_gallery_scroll = prefs.gallery_row_scroll and not use_smooth_gallery and not touch_scroll
			if (
				not tauon.search_over.active
				and not radiobox.active
				and inp.mouse_position[0] > window_size[0] - w
				and gui.panelY < inp.mouse_position[1] < window_size[1] - gui.panelBY
			):
				if inp.mouse_wheel != 0:
					tauon.scroll_gallery_hide_timer.set()
					gui.frame_callback_list.append(TestTimer(0.9))

				if use_smooth_gallery and inp.mouse_wheel != 0:
					tauon.smooth_scroll.add_wheel_motion(scroll_key, -inp.mouse_wheel, prefs.gallery_scroll_wheel_px)
				elif row_gallery_scroll:
					gui.album_scroll_px -= inp.mouse_wheel * (
						tauon.album_mode_art_size + gui.album_v_gap
					)  # 90
				else:
					gui.album_scroll_px -= inp.mouse_wheel * prefs.gallery_scroll_wheel_px


				if inp.touch_released:
					tauon.smooth_scroll.release_touch("gallery")
				elif touch_scroll:
					tauon.smooth_scroll.apply_touch_drag("gallery", -inp.touch_scroll_y)

				if use_smooth_gallery:
					gui.album_scroll_px += tauon.smooth_scroll.step_motion(scroll_key)

				if gui.album_scroll_px < round(gui.album_v_slide_value * -1):
					gui.album_scroll_px = round(gui.album_v_slide_value * -1)
					if tauon.album_dex:
						tauon.gallery_pulse_top.pulse()

				if gui.album_scroll_px > max_scroll:
					gui.album_scroll_px = max_scroll
					gui.album_scroll_px = max(gui.album_scroll_px, round(gui.album_v_slide_value * -1))
			elif inp.touch_released:
				tauon.smooth_scroll.release_touch("gallery")
				if tauon.smooth_scroll.active("gallery"):
					gui.album_scroll_px += tauon.smooth_scroll.step_motion("gallery")
					if gui.album_scroll_px < round(gui.album_v_slide_value * -1):
						gui.album_scroll_px = round(gui.album_v_slide_value * -1)
					if gui.album_scroll_px > max_scroll:
						gui.album_scroll_px = max_scroll
						gui.album_scroll_px = max(gui.album_scroll_px, round(gui.album_v_slide_value * -1))
			elif touch_scroll:
				tauon.scroll_gallery_hide_timer.set()
				gui.frame_callback_list.append(TestTimer(0.9))
				tauon.smooth_scroll.apply_touch_drag("gallery", -inp.touch_scroll_y)
				gui.album_scroll_px += tauon.smooth_scroll.step_motion("gallery")
				if gui.album_scroll_px < round(gui.album_v_slide_value * -1):
					gui.album_scroll_px = round(gui.album_v_slide_value * -1)
				if gui.album_scroll_px > max_scroll:
					gui.album_scroll_px = max_scroll
					gui.album_scroll_px = max(gui.album_scroll_px, round(gui.album_v_slide_value * -1))

			if tauon.smooth_scroll.active(scroll_key) and not touch_scroll and not inp.touch_released and not (
				not tauon.search_over.active
				and not radiobox.active
				and inp.mouse_position[0] > window_size[0] - w
				and gui.panelY < inp.mouse_position[1] < window_size[1] - gui.panelBY
			):
				gui.album_scroll_px += tauon.smooth_scroll.step_motion(scroll_key)
				if gui.album_scroll_px < round(gui.album_v_slide_value * -1):
					gui.album_scroll_px = round(gui.album_v_slide_value * -1)
				if gui.album_scroll_px > max_scroll:
					gui.album_scroll_px = max_scroll
					gui.album_scroll_px = max(gui.album_scroll_px, round(gui.album_v_slide_value * -1))

			rect = (
				gui.gallery_scroll_field_left,
				gui.panelY,
				window_size[0] - gui.gallery_scroll_field_left - 2,
				h,
			)

			card_mode = False
			if prefs.use_card_style and colours.lm and gui.gallery_show_text:
				card_mode = True

			rect = (window_size[0] - 40 * gui.scale, gui.panelY, 38 * gui.scale, h)
			tauon.fields.add(rect)

			# Show scroll area
			if (
				tauon.coll(rect)
				or tauon.gallery_scroll.held
				or tauon.scroll_gallery_hide_timer.get() < 0.9
				or gui.album_tab_mode
			):
				if tauon.gallery_scroll.held:
					while len(tauon.gall_ren.queue) > 2:
						tauon.gall_ren.queue.pop()

				# Draw power bar button
				if gui.pt == 0 and gui.power_bar is not None and len(gui.power_bar) > 3:
					rect = (
						window_size[0] - (15 + 20) * gui.scale,
						gui.panelY + 3 * gui.scale,
						18 * gui.scale,
						24 * gui.scale,
					)
					tauon.fields.add(rect)
					colour = ColourRGBA(255, 255, 255, 35)
					if colours.lm:
						colour = ColourRGBA(0, 0, 0, 30)
					if tauon.coll(rect) and not tauon.gallery_scroll.held:
						colour = ColourRGBA(255, 220, 100, 245)
						if colours.lm:
							colour = ColourRGBA(250, 100, 0, 255)
						if inp.mouse_click:
							gui.pt = 1

					gui.power_bar_icon.render(
						rect[0] + round(5 * gui.scale), rect[1] + round(3 * gui.scale), colour
					)

				# Draw scroll bar
				if gui.pt == 0:
					gui.album_scroll_px = (
						tauon.gallery_scroll.draw(
							window_size[0] - 16 * gui.scale,
							gui.panelY,
							15 * gui.scale,
							window_size[1] - (gui.panelY + gui.panelBY),
							gui.album_scroll_px + gui.album_v_slide_value,
							max_scroll + gui.album_v_slide_value,
							jump_distance=1400 * gui.scale,
							r_click=inp.right_click,
							extend_field=15 * gui.scale,
						)
						- gui.album_v_slide_value
					)

			if gui.last_row != row_len:
				gui.last_row = row_len

				if pctl.selected_in_playlist < len(pctl.playing_playlist()):
					tauon.goto_album(pctl.selected_in_playlist)
				# else:
				# 	tauon.goto_album(pctl.playlist_playing_position)

			extend = 0
			if card_mode:  # gui.gallery_show_text:
				extend = 40 * gui.scale

			# Process inputs first
			if (
				inp.mouse_click or inp.right_click or inp.middle_click or inp.mouse_down or inp.mouse_up
			) and pctl.default_playlist:
				while render_pos < gui.album_scroll_px + window_size[1]:
					if gui.b_info_bar and render_pos > gui.album_scroll_px + b_info_y:
						break

					if render_pos < gui.album_scroll_px - tauon.album_mode_art_size - gui.album_v_gap:
						# Skip row
						render_pos += tauon.album_mode_art_size + gui.album_v_gap
						album_on += row_len
					else:
						# render row
						y = render_pos - gui.album_scroll_px
						row_x = 0
						for a in range(row_len):
							if album_on > len(tauon.album_dex) - 1:
								break

							if grid_edge:
								x = l_area + grid_margin + int(dev * a)
							else:
								x = (
									(l_area + dev * a)
									- int(tauon.album_mode_art_size / 2)
									+ int(dev / 2)
									+ int(compact / 2)
									- a_offset
								)

							if tauon.album_dex[album_on] >= len(pctl.default_playlist):
								break

							rect = (
								x,
								y,
								tauon.album_mode_art_size,
								tauon.album_mode_art_size + extend * gui.scale,
							)
							# tauon.fields.add(rect)
							m_in = (
								tauon.coll(rect)
								and gui.panelY < inp.mouse_position[1] < window_size[1] - gui.panelBY
							)

							# if m_in:
							#     ddt.rect_r((x - 7, y - 7, tauon.album_mode_art_size + 14, tauon.album_mode_art_size + extend + 55), [80, 80, 80, 80], True)

							# Quick drag and drop
							if (
								inp.mouse_up
								and (gui.playlist_hold and m_in)
								and not gui.side_drag
								and gui.shift_selection
							):
								info = tauon.get_album_info(tauon.album_dex[album_on])
								if info[1]:
									track_position = info[1][0]

									if track_position > gui.shift_selection[0]:
										track_position = info[1][-1] + 1

									ref = []
									for item in gui.shift_selection:
										ref.append(pctl.default_playlist[item])

									for item in gui.shift_selection:
										pctl.default_playlist[item] = "old"

									for item in gui.shift_selection:
										pctl.default_playlist.insert(track_position, "new")

									for b in reversed(range(len(pctl.default_playlist))):
										if pctl.default_playlist[b] == "old":
											del pctl.default_playlist[b]
									gui.shift_selection = []
									for b in range(len(pctl.default_playlist)):
										if pctl.default_playlist[b] == "new":
											gui.shift_selection.append(b)
											pctl.default_playlist[b] = ref.pop(0)

									pctl.selected_in_playlist = gui.shift_selection[0]
									gui.pl_update += 1
									gui.playlist_hold = False

									tauon.reload_albums(True)
									pctl.notify_database_changed()
							elif not gui.side_drag and tauon.is_level_zero():
								if (
									coll_point(inp.click_location, rect)
									and gui.panelY < inp.mouse_position[1] < window_size[1] - gui.panelBY
								):
									info = tauon.get_album_info(tauon.album_dex[album_on])

									if m_in and inp.mouse_up and prefs.gallery_single_click:
										if (
											tauon.is_level_zero()
											and gui.d_click_ref == tauon.album_dex[album_on]
										):
											if info[0] == 1 and pctl.playing_state == PlayingState.PAUSED:
												pctl.play()
											elif (
												info[0] == 1 and pctl.playing_state != PlayingState.STOPPED
											):
												pctl.playlist_view_position = tauon.album_dex[album_on]
												logging.debug("Position changed by gallery click")
											else:
												pctl.playlist_view_position = tauon.album_dex[album_on]
												logging.debug("Position changed by gallery click")
												pctl.jump(
													pctl.default_playlist[tauon.album_dex[album_on]],
													tauon.album_dex[album_on],
												)
											pctl.show_current()
									elif inp.mouse_down and not m_in:
										info = tauon.get_album_info(tauon.album_dex[album_on])
										inp.quick_drag = True
										if (
											not tauon.pl_is_locked(pctl.active_playlist_viewing)
											or inp.key_shift_down
										):
											gui.playlist_hold = True
										gui.shift_selection = info[1]
										gui.pl_update += 1
										inp.click_location = [0, 0]

							if m_in:
								info = tauon.get_album_info(tauon.album_dex[album_on])
								if inp.mouse_click:
									if prefs.gallery_single_click:
										gui.d_click_ref = tauon.album_dex[album_on]
									else:
										if (
											tauon.d_click_timer.get() < 0.5
											and gui.d_click_ref == tauon.album_dex[album_on]
										):
											if info[0] == 1 and pctl.playing_state == PlayingState.PAUSED:
												pctl.play()
											elif (
												info[0] == 1 and pctl.playing_state != PlayingState.STOPPED
											):
												pctl.playlist_view_position = tauon.album_dex[album_on]
												logging.debug("Position changed by gallery click")
											else:
												pctl.playlist_view_position = tauon.album_dex[album_on]
												logging.debug("Position changed by gallery click")
												pctl.jump(
													pctl.default_playlist[tauon.album_dex[album_on]],
													tauon.album_dex[album_on],
												)
										else:
											gui.d_click_ref = tauon.album_dex[album_on]
											tauon.d_click_timer.set()

										pctl.playlist_view_position = tauon.album_dex[album_on]
										logging.debug("Position changed by gallery click")
										pctl.selected_in_playlist = tauon.album_dex[album_on]
										gui.pl_update += 1
								elif inp.middle_click and tauon.is_level_zero():
									# Middle click to add album to queue
									if inp.key_ctrl_down:
										# Add to queue ungrouped
										album = tauon.get_album_info(tauon.album_dex[album_on])[1]
										for item in album:
											pctl.force_queue.append(
												queue_item_gen(
													pctl.default_playlist[item],
													item,
													pctl.pl_to_id(pctl.active_playlist_viewing),
												)
											)
										tauon.queue_timer_set(plural=True)
										if prefs.stop_end_queue:
											pctl.stop_mode = StopMode.OFF
									else:
										# Add to queue grouped
										tauon.add_album_to_queue(
											pctl.default_playlist[tauon.album_dex[album_on]],
											tauon.album_dex[album_on],
											pctl.pl_to_id(pctl.active_playlist_viewing),
										)
								elif inp.right_click:
									if pctl.quick_add_target:
										pl = pctl.id_to_pl(pctl.quick_add_target)
										if pl is not None:
											parent = pctl.get_track(
												pctl.default_playlist[tauon.album_dex[album_on]]
											).parent_folder_path
											# remove from target pl
											if (
												pctl.default_playlist[tauon.album_dex[album_on]]
												in pctl.multi_playlist[pl].playlist_ids
											):
												for i in reversed(
													range(len(pctl.multi_playlist[pl].playlist_ids))
												):
													if (
														pctl.get_track(
															pctl.multi_playlist[pl].playlist_ids[i]
														).parent_folder_path
														== parent
													):
														del pctl.multi_playlist[pl].playlist_ids[i]
											else:
												# add
												for i in range(len(pctl.default_playlist)):
													if (
														pctl.get_track(
															pctl.default_playlist[i]
														).parent_folder_path
														== parent
													):
														pctl.multi_playlist[pl].playlist_ids.append(
															pctl.default_playlist[i]
														)
										tauon.reload_albums(True)
									else:
										pctl.selected_in_playlist = tauon.album_dex[album_on]
										# playlist_position = pctl.playlist_selected
										gui.shift_selection = [pctl.selected_in_playlist]
										gallery_menu.activate(MenuTrackRef(
											pctl.default_playlist[pctl.selected_in_playlist],
											pctl.selected_in_playlist,
											pctl.pl_to_id(pctl.active_playlist_viewing),
										))

										gui.shift_selection = []
										u = pctl.selected_in_playlist
										while (
											u < len(pctl.default_playlist)
											and pctl.master_library[
												pctl.default_playlist[u]
											].parent_folder_path
											== pctl.master_library[
												pctl.default_playlist[pctl.selected_in_playlist]
											].parent_folder_path
										):
											gui.shift_selection.append(u)
											u += 1
										pctl.render_playlist()

							album_on += 1

						if album_on > len(tauon.album_dex):
							break
						render_pos += tauon.album_mode_art_size + gui.album_v_gap

			# Right-click on the gallery background: settings menu. Runs after
			# the album input pass, so a right-click on a tile has already
			# activated gallery_menu (making is_level_zero False). The right
			# ~40px strip is the scroll-bar hotzone (right-click there jumps
			# the scroll). The Compact grid widget has its own menu instead.
			if (
				inp.right_click
				and not gui.gallery_forced_row_len
				and tauon.is_level_zero()
				and window_size[0] - w < inp.mouse_position[0] < window_size[0] - 40 * gui.scale
				and gui.panelY < inp.mouse_position[1] < window_size[1] - gui.panelBY
			):
				inp.right_click = False
				gallery_settings_menu.activate()

			render_pos = 0
			album_on = 0
			album_count = 0

			if not pref_box.enabled or inp.mouse_wheel != 0:
				gui.first_in_grid = None

			# Render album grid
			while render_pos < gui.album_scroll_px + window_size[1] and pctl.default_playlist:
				if gui.b_info_bar and render_pos > gui.album_scroll_px + b_info_y:
					break

				if render_pos < gui.album_scroll_px - tauon.album_mode_art_size - gui.album_v_gap:
					# Skip row
					render_pos += tauon.album_mode_art_size + gui.album_v_gap
					album_on += row_len
				else:
					# render row
					y = render_pos - gui.album_scroll_px

					row_x = 0

					if (
						y > window_size[1] - gui.panelBY - 30 * gui.scale
						and window_size[1] < 340 * gui.scale
					):
						break
					# if y >

					for a in range(row_len):
						if album_on > len(tauon.album_dex) - 1:
							break

						if grid_edge:
							x = l_area + grid_margin + int(dev * a)
						else:
							x = (
								(l_area + dev * a)
								- int(tauon.album_mode_art_size / 2)
								+ int(dev / 2)
								+ int(compact / 2)
								- a_offset
							)

						if tauon.album_dex[album_on] >= len(pctl.default_playlist):
							break

						track = pctl.master_library[pctl.default_playlist[tauon.album_dex[album_on]]]

						info = tauon.get_album_info(tauon.album_dex[album_on])
						album = info[1]
						# info = (0, 0, 0)

						# rect = (x, y, tauon.album_mode_art_size, tauon.album_mode_art_size + extend * gui.scale)
						# tauon.fields.add(rect)
						# m_in = tauon.coll(rect) and gui.panelY < inp.mouse_position[1] < window_size[1] - gui.panelBY

						if (
							gui.first_in_grid is None and y > gui.panelY
						):  # This marks what track is the first in the grid
							gui.first_in_grid = tauon.album_dex[album_on]

						# artisttitle = colours.side_bar_line2
						# albumtitle = colours.side_bar_line1  # grey(220)

						if card_mode:
							ddt.text_background_colour = colours.grey(250)
							tauon.drop_shadow.render(
								x + 3 * gui.scale,
								y + 3 * gui.scale,
								tauon.album_mode_art_size + 11 * gui.scale,
								tauon.album_mode_art_size + 45 * gui.scale + 13 * gui.scale,
							)
							ddt.rect(
								(
									x,
									y,
									tauon.album_mode_art_size,
									tauon.album_mode_art_size + 45 * gui.scale,
								),
								colours.grey(250),
							)

						# White background needs extra border
						if colours.lm and not card_mode:
							ddt.rect_a(
								(x - 2, y - 2),
								(tauon.album_mode_art_size + 4, tauon.album_mode_art_size + 4),
								colours.grey(200),
							)

						if a == row_len - 1:
							gui.gallery_scroll_field_left = max(
								x + tauon.album_mode_art_size, window_size[0] - round(50 * gui.scale)
							)

						# Skip if the selection highlight is drawn over this album,
						# so the two highlights don't show at the same time
						selection_highlight_shown = (
							(gui.album_tab_mode or gallery_menu.active) and info[2] is True
						)
						highlight_border = round(3 * gui.scale)
						if info[0] == 1 and not selection_highlight_shown and (
							pctl.playing_state in (PlayingState.PLAYING, PlayingState.PAUSED)
						):
							ddt.rect_a(
								(x - highlight_border, y - highlight_border),
								(tauon.album_mode_art_size + highlight_border * 2, tauon.album_mode_art_size + highlight_border * 2),
								colours.gallery_highlight,
							)
							# ddt.rect_a((x, y), (tauon.album_mode_art_size, tauon.album_mode_art_size),
							#            colours.gallery_background, True)

						# Draw quick add highlight
						if pctl.quick_add_target:
							pl = pctl.id_to_pl(pctl.quick_add_target)
							if (
								pl is not None
								and pctl.default_playlist[tauon.album_dex[album_on]]
								in pctl.multi_playlist[pl].playlist_ids
							):
								c = ColourRGBA(110, 233, 90, 255)
								if colours.lm:
									c = ColourRGBA(66, 244, 66, 255)
								ddt.rect_a(
									(x - highlight_border, y - highlight_border),
									(tauon.album_mode_art_size + highlight_border * 2, tauon.album_mode_art_size + highlight_border * 2),
									c,
								)

						# Draw transcode highlight
						if tauon.transcode_list and os.path.isdir(prefs.encoder_output):
							tr = False

							if encode_folder_name(track) in os.listdir(prefs.encoder_output):
								tr = True
							else:
								for folder in tauon.transcode_list:
									if (
										pctl.get_track(folder[0]).parent_folder_path
										== track.parent_folder_path
									):
										tr = True
										break
							if tr:
								c = ColourRGBA(244, 212, 66, 255)
								if colours.lm:
									c = ColourRGBA(244, 64, 244, 255)
								ddt.rect_a(
									(x - highlight_border, y - highlight_border),
									(tauon.album_mode_art_size + highlight_border * 2, tauon.album_mode_art_size + highlight_border * 2),
									c,
								)
								# ddt.rect_a((x, y), (tauon.album_mode_art_size, tauon.album_mode_art_size),
								#            colours.gallery_background, True)

						# Draw selection

						if (gui.album_tab_mode or gallery_menu.active) and info[2] is True:
							c = colours.gallery_highlight
							c = ColourRGBA(c.g, c.b, c.r, c.a)
							ddt.rect_a(
								(x - highlight_border, y - highlight_border),
								(tauon.album_mode_art_size + highlight_border * 2, tauon.album_mode_art_size + highlight_border * 2),
								c,
							)  # [150, 80, 222, 255]
							# ddt.rect_a((x, y), (tauon.album_mode_art_size, tauon.album_mode_art_size),
							#            colours.gallery_background, True)

						# Draw selection animation
						if (
							gui.gallery_animate_highlight_on == tauon.album_dex[album_on]
							and tauon.gallery_select_animate_timer.get() < 1.5
						):
							t = tauon.gallery_select_animate_timer.get()
							c = colours.gallery_highlight
							if t < 0.2:
								a = int(255 * (t / 0.2))
							elif t < 0.5:
								a = 255
							else:
								a = int(255 - 255 * (t - 0.5))

							c = ColourRGBA(c.g, c.b, c.r, a)
							animate_border = round(3 * gui.scale)
							ddt.rect_a(
								(x - animate_border, y - animate_border),
								(tauon.album_mode_art_size + animate_border * 2, tauon.album_mode_art_size + animate_border * 2),
								c,
							)  # [150, 80, 222, 255]

							gui.update += 1

						# Draw faint outline
						ddt.rect(
							(x - 1, y - 1, tauon.album_mode_art_size + 2, tauon.album_mode_art_size + 2),
							ColourRGBA(255, 255, 255, 11),
						)

						if gui.album_tab_mode or gallery_menu.active:
							if info[2] is False and info[0] != 1 and not colours.lm:
								ddt.rect_a(
									(x, y),
									(tauon.album_mode_art_size, tauon.album_mode_art_size),
									ColourRGBA(0, 0, 0, 110),
								)
								albumtitle = colours.grey(160)

						elif info[0] != 1 and pctl.playing_state != PlayingState.STOPPED and prefs.dim_art:
							ddt.rect_a(
								(x, y),
								(tauon.album_mode_art_size, tauon.album_mode_art_size),
								ColourRGBA(0, 0, 0, 110),
							)
							albumtitle = colours.grey(160)

						# Determine meta info
						singles = False
						artists = 0
						last_album = ""
						last_artist = ""
						s = 0
						ones = 0
						for id in album:
							tr = pctl.get_track(pctl.default_playlist[id])
							if tr.album != last_album:
								if last_album:
									s += 1
								last_album = tr.album
								if str(tr.track_number) == "1":
									ones += 1
							if tr.artist != last_artist:
								artists += 1
						if s > 2 or ones > 2:
							singles = True

						# Draw blank back colour
						back_colour = ColourRGBA(40, 40, 40, 50)
						if colours.lm:
							back_colour = ColourRGBA(10, 10, 10, 15)

						back_colour = alpha_blend(ColourRGBA(10, 10, 10, 15), colours.gallery_background)

						ddt.rect_a(
							(x, y), (tauon.album_mode_art_size, tauon.album_mode_art_size), back_colour
						)

						# Draw album art
						if singles:
							dia = math.sqrt(tauon.album_mode_art_size * tauon.album_mode_art_size * 2)
							ran = dia * 0.25
							off = (dia - ran) / 2
							albs = min(len(album), 5)
							spacing = ran / (albs - 1)
							size = round(tauon.album_mode_art_size * 0.5)

							i = 0
							for p in album[:albs]:
								pp = spacing * i
								pp += off
								xx = pp / math.sqrt(2)

								xx -= size / 2
								drawn_art = tauon.gall_ren.render(
									pctl.get_track(pctl.default_playlist[p]),
									(x + xx, y + xx),
									size=size,
									force_offset=0,
								)
								if not drawn_art:
									g = 50 + round(100 / albs) * i
									ddt.rect((x + xx, y + xx, size, size), ColourRGBA(g, g, g, 100))
								drawn_art = True
								i += 1
						else:
							album_count += 1
							if (album_count * 1.5) + 10 > tauon.gall_ren.limit:
								tauon.gall_ren.limit = round((album_count * 1.5) + 30)
							drawn_art = tauon.gall_ren.render(track, (x, y))

						# Determine mouse collision
						rect = (
							x,
							y,
							tauon.album_mode_art_size,
							tauon.album_mode_art_size + extend * gui.scale,
						)
						m_in = (
							tauon.coll(rect)
							and gui.panelY < inp.mouse_position[1] < window_size[1] - gui.panelBY
						)
						tauon.fields.add(rect)

						# Draw mouse-over highlight
						if (not gallery_menu.active and m_in) or (gallery_menu.active and info[2]):
							if tauon.is_level_zero():
								ddt.rect(rect, ColourRGBA(255, 255, 255, 10))

						if drawn_art is False and gui.gallery_show_text is False:
							ddt.text(
								(
									x + int(tauon.album_mode_art_size / 2),
									y + tauon.album_mode_art_size - 22 * gui.scale,
									2,
								),
								pctl.master_library[
									pctl.default_playlist[tauon.album_dex[album_on]]
								].parent_folder_name,
								colours.gallery_artist_line,
								13,
								tauon.album_mode_art_size - 15 * gui.scale,
								bg=alpha_blend(back_colour, colours.gallery_background),
							)

						if prefs.art_bg and drawn_art:
							rect = sdl3.SDL_FRect(
								round(x), round(y), tauon.album_mode_art_size, tauon.album_mode_art_size
							)
							if rect.y < gui.panelY:
								diff = round(gui.panelY - rect.y)
								rect.y += diff
								rect.h -= diff
							elif (rect.y + rect.h) > window_size[1] - gui.panelBY:
								diff = round((rect.y + rect.h) - (window_size[1] - gui.panelBY))
								rect.h -= diff

							if rect.h > 0:
								tauon.style_overlay.hole_punches.append(rect)

						# # Drag over highlight
						# if inp.quick_drag and gui.playlist_hold and inp.mouse_down:
						# 	rect = (x, y, tauon.album_mode_art_size, tauon.album_mode_art_size + extend * gui.scale)
						# 	m_in = tauon.coll(rect) and gui.panelY < inp.mouse_position[1] < window_size[1] - gui.panelBY
						# 	if m_in:
						# 		ddt.rect_a((x, y), (tauon.album_mode_art_size, tauon.album_mode_art_size), [120, 10, 255, 100], True)

						if gui.gallery_show_text:
							c_index = pctl.default_playlist[tauon.album_dex[album_on]]
							if c_index in gui.album_artist_dict:
								pass
							else:
								i = tauon.album_dex[album_on]
								if pctl.master_library[pctl.default_playlist[i]].album_artist:
									gui.album_artist_dict[c_index] = pctl.master_library[
										pctl.default_playlist[i]
									].album_artist
								else:
									while i < len(pctl.default_playlist):
										if (
											pctl.master_library[pctl.default_playlist[i]].parent_folder_name
											!= pctl.master_library[
												pctl.default_playlist[tauon.album_dex[album_on]]
											].parent_folder_name
										):
											gui.album_artist_dict[c_index] = pctl.master_library[
												pctl.default_playlist[tauon.album_dex[album_on]]
											].artist
											break
										if (
											pctl.master_library[pctl.default_playlist[i]].artist
											!= pctl.master_library[
												pctl.default_playlist[tauon.album_dex[album_on]]
											].artist
										):
											gui.album_artist_dict[c_index] = _("Various Artists")
											break
										i += 1
									else:
										gui.album_artist_dict[c_index] = pctl.master_library[
											pctl.default_playlist[tauon.album_dex[album_on]]
										].artist

							line = gui.album_artist_dict[c_index]
							line2 = pctl.master_library[
								pctl.default_playlist[tauon.album_dex[album_on]]
							].album
							if singles:
								line2 = pctl.master_library[
									pctl.default_playlist[tauon.album_dex[album_on]]
								].parent_folder_name
								if artists > 1:
									line = _("Various Artists")

							text_align = 0
							if prefs.center_gallery_text:
								x += tauon.album_mode_art_size // 2
								text_align = 2
							elif card_mode:
								x += round(6 * gui.scale)

							if card_mode:
								if line2 == "":
									ddt.text(
										(x, y + tauon.album_mode_art_size + 8 * gui.scale, text_align),
										line,
										line1_colour,
										310,
										tauon.album_mode_art_size - 18 * gui.scale,
									)
								else:
									ddt.text(
										(x, y + tauon.album_mode_art_size + 7 * gui.scale, text_align),
										line2,
										line2_colour,
										311,
										tauon.album_mode_art_size - 18 * gui.scale,
									)

									ddt.text(
										(
											x,
											y + tauon.album_mode_art_size + (10 + 14) * gui.scale,
											text_align,
										),
										line,
										line1_colour,
										10,
										tauon.album_mode_art_size - 18 * gui.scale,
									)
							elif line2 == "":
								ddt.text(
									(x, y + tauon.album_mode_art_size + 9 * gui.scale - text_lift, text_align),
									line,
									line1_colour,
									311,
									tauon.album_mode_art_size - 5 * gui.scale,
								)
							else:
								ddt.text(
									(x, y + tauon.album_mode_art_size + 8 * gui.scale - text_lift, text_align),
									line2,
									line2_colour,
									211 if grid_edge else 212,
									tauon.album_mode_art_size - (round(5 * gui.scale) if grid_edge else 0),
								)

								ddt.text(
									(x, y + tauon.album_mode_art_size + (10 + 14) * gui.scale - artist_lift, text_align),
									line,
									line1_colour,
									311,
									tauon.album_mode_art_size - 5 * gui.scale,
								)

						album_on += 1

					if album_on > len(tauon.album_dex):
						break
					render_pos += tauon.album_mode_art_size + gui.album_v_gap

			# POWER TAG BAR --------------

			if gui.pt > 0:  # gui.pt > 0 or (gui.power_bar is not None and len(gui.power_bar) > 1):
				top = gui.panelY
				run_y = top + 1

				hot_r = (window_size[0] - 47 * gui.scale, top, 45 * gui.scale, h)
				tauon.fields.add(hot_r)

				if gui.pt == 0:  # mouse moves in
					if tauon.coll(hot_r) and window_is_focused(t_window):
						gui.pt_on.set()
						gui.pt = 1
				elif gui.pt == 1:  # wait then trigger if stays, reset if goes out
					if not tauon.coll(hot_r):
						gui.pt = 0
					elif gui.pt_on.get() > 0.2:
						gui.pt = 2

						off = 0
						for item in gui.power_bar:
							item.ani_timer.force_set(off)
							off -= 0.005

				elif gui.pt == 2:  # wait to turn off
					if tauon.coll(hot_r):
						gui.pt_off.set()
					if gui.pt_off.get() > 0.6 and not lightning_menu.active:
						gui.pt = 3

						off = 0
						for item in gui.power_bar:
							item.ani_timer.force_set(off)
							off -= 0.01

				done = True
				# Animate tags on
				if gui.pt == 2:
					for item in gui.power_bar:
						t = item.ani_timer.get()
						if t < 0:
							break
						if t > 0.2:
							item.peak_x = 9 * gui.scale
						else:
							item.peak_x = (t / 0.2) * 9 * gui.scale

				# Animate tags off
				if gui.pt == 3:
					for item in gui.power_bar:
						t = item.ani_timer.get()
						if t < 0:
							done = False
							break
						if t > 0.2:
							item.peak_x = 0
						else:
							item.peak_x = 9 * gui.scale - ((t / 0.2) * 9 * gui.scale)
							done = False
					if done:
						gui.pt = 0
						gui.update += 1

				# Keep draw loop running while on
				if gui.pt > 0:
					gui.update = 2

				# Draw tags

				block_h = round(27 * gui.scale)
				block_gap = 1 * gui.scale
				if gui.scale == 1.25:
					block_gap = 1

				if tauon.coll(hot_r) or gui.pt > 0:
					for i, item in enumerate(gui.power_bar):
						if run_y + block_h > top + h:
							break

						rect = [window_size[0] - item.peak_x, run_y, 7 * gui.scale, block_h]
						i_rect = [window_size[0] - 36 * gui.scale, run_y, 34 * gui.scale, block_h]
						tauon.fields.add(i_rect)

						if (
							tauon.coll(i_rect)
							or (lightning_menu.active and lightning_menu.reference == item)
						) and item.peak_x == 9 * gui.scale:
							if (
								not lightning_menu.active
								or lightning_menu.reference == item
								or inp.right_click
							):
								minx = 100 * gui.scale
								maxx = minx * 2

								ww = ddt.get_text_w(item.name, 213)

								w = max(minx, ww)
								w = min(maxx, w)

								ddt.rect(
									(rect[0] - w - 25 * gui.scale, run_y, w + 26 * gui.scale, block_h),
									ColourRGBA(230, 230, 230, 255),
								)
								ddt.text(
									(rect[0] - 10 * gui.scale, run_y + 5 * gui.scale, 1),
									item.name,
									ColourRGBA(5, 5, 5, 255),
									213,
									w,
									bg=ColourRGBA(230, 230, 230, 255),
								)

								if inp.mouse_click:
									tauon.goto_album(item.position)
								if inp.right_click:
									lightning_menu.activate(
										item,
										position=(
											window_size[0] - 180 * gui.scale,
											rect[1] + rect[3] + 5 * gui.scale,
										),
									)
								if inp.middle_click:
									tauon.path_stem_to_playlist(item.path, item.name)

						ddt.rect(rect, item.colour)
						run_y += block_h + block_gap

			tauon.gallery_pulse_top.render(
				window_size[0] - gui.rspw, gui.panelY, gui.rspw - round(16 * gui.scale), 20 * gui.scale
			)
		except Exception:
			logging.exception("Gallery render error!")
		# END POWER BAR ------------------------

	tauon.gallery_render = render_gallery  # exposed for the Custom Layout Album Gallery widget

	def update_tracklist_scrollbar_lock(hitbox) -> bool:
		"""The tracklist scroll bar's interaction lock (moved verbatim from the
		input phase of the main loop): while the pointer is in the bar's hitbox
		and pressed, the bar owns interaction — unless the pointer is over an
		album-rating star (gui.album_rating_hover), in which case the bar
		yields. Updates gui.scrollbar_interaction_lock and gui.scrollbar_active
		(read by the playlist body input, e.g. to disable rating input while
		scrolling); returns the suppressed-by-album-rating flag. Shared with
		the Custom Layout Tracklist widget, which passes its segment's hitbox.
		"""
		scrollbar_pointer_in_area = tauon.coll(hitbox)
		if not scrollbar_pointer_in_area:
			gui.scrollbar_interaction_lock = False
		elif (
			not gui.album_rating_hover
			and (
				inp.mouse_click
				or inp.right_click
				or (inp.mouse_down and coll_point(inp.click_location, hitbox))
			)
		):
			gui.scrollbar_interaction_lock = True
		gui.scrollbar_active = scroll_hold or gui.scrollbar_interaction_lock
		return (
			gui.album_rating_hover
			and not scroll_hold
			and not gui.scrollbar_interaction_lock
		)

	tauon.tracklist_scrollbar_lock = update_tracklist_scrollbar_lock  # for the Tracklist widget

	def render_tracklist_scrollbar(left, plw, top, bottom, ey) -> None:
		"""The tracklist scroll bar: auto-hide (shows on hover / while
		scrolling), thumb drag, continuous click-slide above/below the thumb,
		right-click jump, and the album-rating suppression. Moved verbatim from
		the main loop with the geometry parameterized: left/plw = tracklist
		left edge/width, top/bottom = the bar's vertical span, ey = the thumb
		travel limit (the preset passes its historical window-based value).
		The Custom Layout Tracklist widget calls this with segment geometry.
		Thumb state (scroll_hold etc.) is the shared main-loop state — only one
		tracklist bar exists at a time (preset and widget never run together).
		"""
		nonlocal scroll_hold, scroll_point, scroll_bpoint, sbp, sbl
		width = 15 * gui.scale

		# When the columns header bar is shown, drop the scroll bar (and its
		# auto-hide/hover field) below the header so it doesn't overlap it. Only
		# the top moves; the thumb travel simply shrinks from the top.
		if gui.set_bar and gui.set_mode:
			top += gui.set_height

		if gui.set_mode and prefs.left_align_album_artist_title:
			width = 11 * gui.scale
		scroll_hitbox_width = 28 * gui.scale
		if prefs.tracklist_scrollbar_left:
			x = left + 1 * gui.scale
			scroll_hitbox_x = x - 1 * gui.scale
		else:
			x = left + plw - width - 3 * gui.scale
			scroll_hitbox_x = x + width + 1 * gui.scale - scroll_hitbox_width

		gui.scroll_hide_box = (
			scroll_hitbox_x,
			top,
			scroll_hitbox_width,
			bottom - top,
		)

		scrollbar_hidden_by_album_rating = (
			gui.album_rating_hover
			and not scroll_hold
			and not gui.scrollbar_interaction_lock
		)
		if not scrollbar_hidden_by_album_rating:
			tauon.fields.add(gui.scroll_hide_box)
		if not scrollbar_hidden_by_album_rating and not prefs.show_nag and (
			tauon.scroll_hide_timer.get() < 0.9 or (
				(tauon.coll(gui.scroll_hide_box) or scroll_hold or gui.quick_search_mode)
				and not menu_is_open()
				and not pref_box.enabled
				and not gui.rename_playlist_box
				and gui.layer_focus == 0
				and gui.show_playlist
				and not tauon.search_over.active
			)
		):
			scroll_opacity = 255

			if not gui.combo_mode:
				if len(pctl.default_playlist) < 50:
					sbl = 85 * gui.scale
					if len(pctl.default_playlist) == 0:
						sbp = top
				else:
					sbl = 105 * gui.scale

				tauon.fields.add((scroll_hitbox_x, sbp, scroll_hitbox_width, sbl))
				if (
					tauon.coll((scroll_hitbox_x, top, scroll_hitbox_width, ey - top))
					and (inp.mouse_down or inp.right_click)
					and coll_point(inp.click_location, (scroll_hitbox_x, top, scroll_hitbox_width, ey - top))
				):
					gui.pl_update = 1
					if inp.right_click:
						sbp = inp.mouse_position[1] - int(sbl / 2)
						if sbp + sbl > ey:
							sbp = ey - sbl
						elif sbp < top:
							sbp = top
						per = (sbp - top) / (ey - top - sbl)
						pctl.playlist_view_position = int(len(pctl.default_playlist) * per)
						gui.playlist_scroll_pixels = 0
						logging.debug("Position set by scroll bar (right click)")
						pctl.playlist_view_position = max(pctl.playlist_view_position, 0)

					elif inp.mouse_click:
						if inp.mouse_position[1] < sbp:
							gui.scroll_direction = -1
						elif inp.mouse_position[1] > sbp + sbl:
							gui.scroll_direction = 1
						else:
							tauon.input_sdl.mouse_capture_want = True

							scroll_hold = True
							scroll_point = inp.mouse_position[1]
							scroll_bpoint = sbp
					else:
						if sbp < inp.mouse_position[1] < sbp + sbl:
							gui.scroll_direction = 0
						pctl.playlist_view_position += gui.scroll_direction * 2
						gui.playlist_scroll_pixels = 0
						logging.debug("Position set by scroll bar (slide)")
						pctl.playlist_view_position = max(pctl.playlist_view_position, 0)
						pctl.playlist_view_position = min(
							pctl.playlist_view_position, len(pctl.default_playlist)
						)

						if sbp + sbl > ey:
							sbp = ey - sbl
						elif sbp < top:
							sbp = top

				if not inp.mouse_down:
					scroll_hold = False

				if scroll_hold and not inp.mouse_click:
					gui.pl_update = 1
					tauon.input_sdl.mouse_capture_want = True

					sbp = inp.mouse_position[1] - (scroll_point - scroll_bpoint)
					if sbp + sbl > ey:
						sbp = ey - sbl
					elif sbp < top:
						sbp = top
					per = (sbp - top) / (ey - top - sbl)
					pctl.playlist_view_position = int(len(pctl.default_playlist) * per)
					gui.playlist_scroll_pixels = 0
					logging.debug("Position set by scroll bar (drag)")

				elif len(pctl.default_playlist) > 0:
					per = (pctl.playlist_view_position + (gui.playlist_scroll_pixels / max(gui.playlist_row_height, 1))) / len(pctl.default_playlist)
					sbp = int((ey - top - sbl) * per) + top + 1

				bg = ColourRGBA(255, 255, 255, 6)
				fg = colours.scroll_colour

				if colours.lm:
					bg = ColourRGBA(200, 200, 200, 100)
					fg = ColourRGBA(100, 100, 100, 200)

				ddt.rect_a((x, top), (width + 1 * gui.scale, bottom - top), bg)
				ddt.rect_a((x + 1, sbp), (width, sbl), alpha_mod(fg, scroll_opacity))

				if (
					tauon.coll((scroll_hitbox_x, sbp, scroll_hitbox_width, sbl)) and inp.mouse_position[0] != 0
				) or scroll_hold:
					ddt.rect_a((x + 1 * gui.scale, sbp), (width, sbl), ColourRGBA(255, 255, 255, 19))

	tauon.tracklist_scrollbar_render = render_tracklist_scrollbar  # for the Tracklist widget

	def render_column_bar_input() -> None:
		# Columns (set mode) header-bar INPUT: grip resize, label drag/sort,
		# right-click menus. Reads gui.playlist_left/plw/panelY so it can run
		# against either the preset layout or a Tracklist widget's segment.
		top = gui.panelY
		if gui.artist_info_panel and not gui.custom_mode:
			top += gui.artist_panel_height

		if gui.set_mode and not gui.set_bar:
			left = gui.playlist_left
			rect = [left, top, gui.plw, 12 * gui.scale]
			if inp.right_click and tauon.coll(rect):
				tauon.set_menu_hidden.activate()
				inp.right_click = False

		width = gui.plw
		if gui.set_bar and gui.set_mode:
			left = gui.playlist_left

			if gui.tracklist_center_mode:
				left = gui.tracklist_inset_left - round(20 * gui.scale)
				width = gui.tracklist_inset_width + round(20 * gui.scale)

			rect = [left, top, width, gui.set_height]
			start = left + gui.pl_st_left * gui.scale
			run = 0
			in_grip = False

			if not inp.mouse_down and gui.set_hold != -1:
				gui.set_hold = -1

			for h, item in enumerate(gui.pl_st):
				box = (start + run, rect[1], item[1], rect[3])
				grip = (start + run, rect[1], 3 * gui.scale, rect[3])
				m_grip = (grip[0] - 4 * gui.scale, grip[1], grip[2] + 8 * gui.scale, grip[3])
				l_grip = (grip[0] + 9 * gui.scale, grip[1], box[2] - 14 * gui.scale, grip[3])
				tauon.fields.add(m_grip)

				if tauon.coll(l_grip):
					if inp.mouse_up and gui.set_label_hold != -1:
						if point_distance(inp.mouse_position, gui.set_label_point) < 8 * gui.scale:
							sort_direction = 0
							if h != gui.column_d_click_on or gui.column_d_click_timer.get() > 2.5:
								gui.column_d_click_timer.set()
								gui.column_d_click_on = h

								sort_direction = 1

								gui.column_sort_ani_direction = 1
								gui.column_sort_ani_x = start + run + item[1]
							elif gui.column_d_click_on == h:
								gui.column_d_click_on = -1
								gui.column_d_click_timer.force_set(10)

								sort_direction = -1

								gui.column_sort_ani_direction = -1
								gui.column_sort_ani_x = start + run + item[1]

							if sort_direction:
								if gui.pl_st[h][0] in {"Starline", "Rating", "❤", "P", "S", "Time", "Date"}:
									sort_direction *= -1

								if sort_direction == 1:
									tauon.sort_ass(h)
								else:
									tauon.sort_ass(h, True)
								gui.column_sort_ani_timer.set()
						else:
							gui.column_d_click_on = -1
							if h != gui.set_label_hold:
								dest = h
								if dest > gui.set_label_hold:
									dest += 1
								temp = gui.pl_st[gui.set_label_hold]
								gui.pl_st[gui.set_label_hold] = "old"
								gui.pl_st.insert(dest, temp)
								gui.pl_st.remove("old")

								gui.pl_update = 1
								gui.set_label_hold = -1
								# logging.info("MOVE")
								break

							gui.set_label_hold = -1

					if inp.mouse_click:
						gui.set_label_hold = h
						gui.set_label_point = copy.deepcopy(inp.mouse_position)
					if inp.right_click:
						tauon.set_menu.reference = h
						tauon.sa_regen_menu()
						tauon.set_menu.activate(h)

				if h == 0:
					# The first grip has no column to its left; it instead
					# drags the leading inset (gui.pl_st_left), shifting the
					# whole column block left/right.
					if tauon.coll(m_grip):
						in_grip = True
						if inp.mouse_click:
							gui.set_hold = 0
							gui.set_point = inp.mouse_position[0]
							gui.set_old = gui.pl_st_left

					if inp.mouse_down and gui.set_hold == 0:
						# pl_st_left is stored pre-scale, so convert the pixel
						# drag delta back to base units.
						gui.pl_st_left = gui.set_old + (inp.mouse_position[0] - gui.set_point) / gui.scale
						# Keep a small minimum so the grip stays grabbable and
						# doesn't slip off the left edge of the window.
						gui.pl_st_left = max(gui.pl_st_left, 2)

						gui.update = 1

						total = 0
						for i in range(len(gui.pl_st) - 1):
							total += gui.pl_st[i][1]

						wid = gui.plw - round(gui.pl_st_left * gui.scale)
						if gui.tracklist_center_mode:
							wid = gui.tracklist_highlight_width - round(gui.pl_st_left * gui.scale)
						gui.pl_st[len(gui.pl_st) - 1][1] = wid - total
				else:
					if tauon.coll(m_grip):
						in_grip = True
						if inp.mouse_click:
							gui.set_hold = h
							gui.set_point = inp.mouse_position[0]
							gui.set_old = gui.pl_st[h - 1][1]

					if inp.mouse_down and gui.set_hold == h:
						gui.pl_st[h - 1][1] = gui.set_old + (inp.mouse_position[0] - gui.set_point)
						gui.pl_st[h - 1][1] = max(gui.pl_st[h - 1][1], 25)

						gui.update = 1
						# gui.pl_update = 1

						total = 0
						for i in range(len(gui.pl_st) - 1):
							total += gui.pl_st[i][1]

						wid = gui.plw - round(gui.pl_st_left * gui.scale)
						if gui.tracklist_center_mode:
							wid = gui.tracklist_highlight_width - round(gui.pl_st_left * gui.scale)
						gui.pl_st[len(gui.pl_st) - 1][1] = wid - total

				run += item[1]

			# A right-click on the bar was handled above (column menu on a label,
			# otherwise ignored); consume it so the tracklist body beneath the bar
			# doesn't also treat it as a track right-click. In the preset the bar
			# sits above the rows so this is a no-op; in the Custom Layout Tracklist
			# widget the bar and body share the segment.
			if inp.right_click and tauon.coll(rect):
				inp.right_click = False

			if not inp.mouse_down:
				gui.set_label_hold = -1
			# logging.info(in_grip)
			if gui.set_label_hold == -1:
				if (
					in_grip
					and not tauon.x_menu.active
					and not tauon.view_menu.active
					and not tab_menu.active
					and not tauon.set_menu.active
				):
					gui.cursor_want = 1
				if gui.set_hold != -1:
					gui.cursor_want = 1
					gui.pl_update_on_drag = True
	tauon.column_bar_input = render_column_bar_input

	def render_column_bar_draw() -> None:
		# Columns (set mode) header-bar DRAWING (and the hover strip that
		# re-shows a hidden bar). Must run AFTER the tracklist body so it sits
		# on top; the widget calls it post-body, the preset does the same.
		top = gui.panelY
		if gui.artist_info_panel and not gui.custom_mode:
			top += gui.artist_panel_height

		if not gui.set_bar and gui.set_mode and not gui.combo_mode:
			width = gui.plw
			left = gui.playlist_left
			if gui.tracklist_center_mode:
				left = gui.tracklist_highlight_left
				width = gui.tracklist_highlight_width
			rect = [left, top, width, gui.set_height // 2.5]
			tauon.fields.add(rect)
			gui.delay_frame(0.26)

			if tauon.coll(rect) and gui.bar_hover_timer.get() > 0.25:
				ddt.rect(rect, colours.column_bar_background)
				if inp.mouse_click:
					gui.set_bar = True
					tauon.update_layout_do()
			if not tauon.coll(rect):
				gui.bar_hover_timer.set()

		if gui.set_bar and gui.set_mode and not gui.combo_mode:
			x = gui.playlist_left

			width = gui.plw

			if gui.tracklist_center_mode:
				x = gui.tracklist_highlight_left
				width = gui.tracklist_highlight_width

			rect = [x, top, width, gui.set_height]

			c_bar_background = colours.column_bar_background

			# if colours.lm:
			#     c_bar_background = [235, 110, 160, 255]

			if gui.tracklist_center_mode:
				ddt.rect((0, top, window_size[0], gui.set_height), c_bar_background)
			else:
				ddt.rect(rect, c_bar_background)

			start = x + gui.pl_st_left * gui.scale
			c_width = width - gui.pl_st_left * gui.scale

			run = 0

			for i, item in enumerate(gui.pl_st):
				# if run > rect[2] - 55 * gui.scale:
				#     break

				wid = item[1]

				if run + wid > c_width:
					wid = c_width - run

				if run > c_width - 22 * gui.scale:
					break

				# if run > c_width - 20 * gui.scale:
				#     run = run - 20 * gui.scale

				wid = max(0, wid)

				# ddt.rect_r((run, 40, wid, 10), [255, 0, 0, 100])
				box = (start + run, rect[1], wid, rect[3])

				grip = (start + run, rect[1], 3 * gui.scale, rect[3])

				bg = c_bar_background

				if tauon.coll(box) and gui.set_label_hold != -1:
					bg = ColourRGBA(39, 39, 39, 255)

				if i == gui.set_label_hold:
					bg = ColourRGBA(22, 22, 22, 255)

				ddt.rect(box, bg)
				ddt.rect(grip, colours.column_grip)

				line = _(item[0])
				ddt.text_background_colour = bg

				# # Remove columns if positioned out of view
				# if box[0] + 10 * gui.scale > start + (gui.plw - 25 * gui.scale):
				#
				#     if box[0] + 10 * gui.scale > start + gui.plw:
				#         del gui.pl_st[i]
				#
				#     i += 1
				#     while i < len(gui.pl_st):
				#         del gui.pl_st[i]
				#         i += 1
				#
				#     break
				if line == "❤":
					gui.heart_row_icon.render(
						box[0] + 9 * gui.scale, top + 8 * gui.scale, colours.column_bar_text
					)
				else:
					ddt.text(
						(box[0] + 10 * gui.scale, top + 4 * gui.scale),
						line,
						colours.column_bar_text,
						312,
						bg=bg,
						max_w=box[2] - 25 * gui.scale,
					)

				run += box[2]

			t = gui.column_sort_ani_timer.get()
			if t < 0.30:
				gui.update += 1
				x = round(gui.column_sort_ani_x - 22 * gui.scale)
				p = t / 0.30

				if gui.column_sort_ani_direction == 1:
					y = top + 8 * p + 3 * gui.scale
					gui.column_sort_down_icon.render(x, round(y), ColourRGBA(255, 255, 255, 90))
				else:
					p = 1 - p
					y = top + 8 * p + 2 * gui.scale
					gui.column_sort_up_icon.render(x, round(y), ColourRGBA(255, 255, 255, 90))
	tauon.column_bar_draw = render_column_bar_draw

	render_heartbeat_timer = Timer()
	vis_pace_timer = Timer()
	pace_vis = False

	tauon.set_tray_icons()
	if prefs.use_tray or "--tray" in sys.argv:
		tauon.show_tray()

	if (prefs.start_in_tray and prefs.use_tray) or "--tray" in sys.argv:
		tauon.min_to_tray()

	while pctl.running:
		tauon.update_sdl_tray()
		# bm.get('main')
		# time.sleep(100)

		if inp.k_input:
			keymaps.hits.clear()

			inp.d_mouse_click = False
			inp.right_click = False
			inp.level_2_right_click = False
			inp.middle_click = False
			inp.mouse_up = False
			inp.key_return_press = False
			inp.key_down_press = False
			inp.key_up_press = False
			inp.key_right_press = False
			inp.key_left_press = False
			inp.key_esc_press = False
			inp.key_del = False
			inp.backspace_press = 0
			inp.key_backspace_press = False
			inp.key_tab_press = False
			inp.key_c_press = False
			inp.key_v_press = False
			inp.key_a_press = False
			inp.key_s_press = False
			inp.key_z_press = False
			inp.key_x_press = False
			inp.key_home_press = False
			inp.key_end_press = False
			inp.mouse_wheel = 0
			inp.mouse_wheel_precise = False
			inp.touch_scroll_y = 0
			inp.touch_released = False
			pref_box.scroll = 0
			gui.new_playlist_cooldown = False
			inp.input_text = ""
			inp.level_2_enter = False

			mouse_enter_window = False
			gui.mouse_in_window = True
			if inp.key_focused:
				inp.key_focused -= 1

		# f not inp.mouse_down:
		inp.k_input = False
		inp.global_clicked = False
		focused = False
		mouse_moved = False
		gui.level_2_click = False
		inp.mouse_click = False
		# gui.update = 2

		while sdl3.SDL_PollEvent(ctypes.byref(event)) != 0:
			# if event.type == sdl3.SDL_SYSWMEVENT:
			#      logging.info(event.syswm.msg.contents) # Not implemented by pysdl2

			if event.type == sdl3.SDL_EVENT_GAMEPAD_ADDED:
				if not prefs.use_gamepad:
					continue
				if sdl3.SDL_IsGamepad(event.gdevice.which):
					sdl3.SDL_OpenGamepad(event.gdevice.which)
					try:
						logging.info(
							f"Found game controller: {sdl3.SDL_GetGamepadNameForID(event.gdevice.which).decode()}"
						)
					except Exception:
						logging.exception("Error getting game controller")
			elif event.type == sdl3.SDL_EVENT_GAMEPAD_AXIS_MOTION:
				if not prefs.use_gamepad:
					continue
				if event.gaxis.axis == sdl3.SDL_GAMEPAD_AXIS_LEFT_TRIGGER:
					rt = event.gaxis.value > 5000
				elif event.gaxis.axis == sdl3.SDL_GAMEPAD_AXIS_LEFTY:
					if event.gaxis.value < -10000:
						new = -1
					elif event.gaxis.value > 10000:
						new = 1
					else:
						new = 0
					if new != c_yax:
						c_yax_timer.force_set(1)
						c_yax = new
						power += 5
						gui.update += 1
				elif event.gaxis.axis == sdl3.SDL_GAMEPAD_AXIS_RIGHTX:
					if event.gaxis.value < -15000:
						new = -1
					elif event.gaxis.value > 15000:
						new = 1
					else:
						new = 0
					if new != c_xax:
						c_xax_timer.force_set(1)
						c_xax = new
						power += 5
						gui.update += 1
				elif event.gaxis.axis == sdl3.SDL_GAMEPAD_AXIS_RIGHTY:
					if event.gaxis.value < -15000:
						new = -1
					elif event.gaxis.value > 15000:
						new = 1
					else:
						new = 0
					if new != c_xay:
						c_xay_timer.force_set(1)
						c_xay = new
						power += 5
						gui.update += 1
			elif event.type == sdl3.SDL_EVENT_GAMEPAD_BUTTON_DOWN:
				if not prefs.use_gamepad:
					continue
				inp.k_input = True
				power += 5
				gui.update += 2
				# print(event.gbutton.button)
				if event.gbutton.button == sdl3.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER:
					if rt:
						tauon.toggle_random()
					else:
						pctl.advance()
				elif event.gbutton.button == sdl3.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER:
					if rt:
						tauon.toggle_repeat()
					else:
						pctl.back()
				elif event.gbutton.button == sdl3.SDL_GAMEPAD_BUTTON_SOUTH:
					if rt:
						pctl.show_current(highlight=True)
					elif (
						pctl.playing_ready()
						and pctl.active_playlist_playing == pctl.active_playlist_viewing
						and pctl.selected_ready()
						and pctl.default_playlist[pctl.selected_in_playlist] == pctl.playing_object().index
					):
						pctl.play_pause()
					else:
						inp.key_return_press = True
				elif event.gbutton.button == sdl3.SDL_GAMEPAD_BUTTON_WEST:
					if rt:
						tauon.random_track()
					else:
						tauon.toggle_gallery_keycontrol(always_exit=True)
				elif event.gbutton.button == sdl3.SDL_GAMEPAD_BUTTON_NORTH:
					if rt:
						pctl.advance(rr=True)
					else:
						pctl.play_pause()
				elif event.gbutton.button == sdl3.SDL_GAMEPAD_BUTTON_EAST:
					if rt:
						pctl.revert()
					elif tauon.is_level_zero():
						pctl.stop()
					else:
						inp.key_esc_press = True
				elif event.gbutton.button == sdl3.SDL_GAMEPAD_BUTTON_DPAD_UP:
					inp.key_up_press = True
				elif event.gbutton.button == sdl3.SDL_GAMEPAD_BUTTON_DPAD_DOWN:
					inp.key_down_press = True
				elif event.gbutton.button == sdl3.SDL_GAMEPAD_BUTTON_DPAD_LEFT:
					if gui.album_tab_mode:
						inp.key_left_press = True
					elif (tauon.is_level_zero() or gui.quick_search_mode) and not gui.timed_lyrics_editing_now:
						pctl.cycle_playlist_pinned(1)
				elif event.gbutton.button == sdl3.SDL_GAMEPAD_BUTTON_DPAD_RIGHT:
					if gui.album_tab_mode:
						inp.key_right_press = True
					elif (tauon.is_level_zero() or gui.quick_search_mode) and not gui.timed_lyrics_editing_now:
						pctl.cycle_playlist_pinned(-1)
			elif event.type == sdl3.SDL_EVENT_RENDER_TARGETS_RESET:
				if not tauon.windows:
					reset_render = True
				gui.pl_update = 1
				gui.update += 2
			elif event.type == sdl3.SDL_EVENT_DROP_TEXT:
				power += 5

				link = event.drop.data.decode("utf-8", errors="surrogateescape")
				# logging.info(link)

				if pctl.playing_ready() and link.startswith("http"):
					if sdl3.SDL_version >= 204:
						gmp = get_global_mouse()
						gwp = get_window_position(t_window)
						i_x = gmp[0] - gwp[0]
						i_x = max(i_x, 0)
						i_x = min(i_x, window_size[0])
						i_y = gmp[1] - gwp[1]
						i_y = max(i_y, 0)
						i_y = min(i_y, window_size[1])
					else:
						i_y = pointer(c_int(0))
						i_x = pointer(c_int(0))

						sdl3.SDL_GetMouseState(i_x, i_y)
						i_y = i_y.contents.value / logical_size[0] * window_size[0]
						i_x = i_x.contents.value / logical_size[0] * window_size[0]

					if coll_point((i_x, i_y), gui.main_art_box):
						logging.info("Drop picture...")
						# logging.info(link)
						gui.image_downloading = True
						track = pctl.playing_object()
						target_dir = track.parent_folder_path

						shoot_dl = threading.Thread(target=tauon.download_img, args=(link, target_dir, track))
						shoot_dl.daemon = True
						shoot_dl.start()

						gui.update = True

				elif link.startswith("file:///"):
					link = link.replace("\r", "")
					for line in link.split("\n"):
						target = str(urllib.parse.unquote(line)).replace("file:///", "/")
						tauon.drop_file(target)
			elif event.type == sdl3.SDL_EVENT_DROP_BEGIN:
				gui.ext_drop_mode = True
			elif event.type == sdl3.SDL_EVENT_DROP_POSITION:
				inp.mouse_position[0] = int(event.drop.x / logical_size[0] * window_size[0])
				inp.mouse_position[1] = int(event.drop.y / logical_size[0] * window_size[0])
				mouse_moved = True
				gui.mouse_unknown = False
				gui.ext_drop_mode = True
				gui.pl_update += 1
				gui.update += 2
			elif event.type == sdl3.SDL_EVENT_DROP_COMPLETE:
				gui.ext_drop_mode = False
			elif event.type == sdl3.SDL_EVENT_DROP_FILE:
				gui.ext_drop_mode = False
				power += 5
				dropped_file_sdl = event.drop.data
				inp.mouse_position[0] = int(event.drop.x / logical_size[0] * window_size[0])
				inp.mouse_position[1] = int(event.drop.y / logical_size[0] * window_size[0])
				logging.info(f"Dropped data: {dropped_file_sdl}")
				target = (
					str(urllib.parse.unquote(dropped_file_sdl.decode("utf-8", errors="surrogateescape")))
					.replace("file:///", "/")
					.replace("\r", "")
				)
				# logging.info(target)
				tauon.drop_file(target)

			elif event.type == sdl3.SDL_EVENT_QUIT:
				power += 5

				if gui.tray_active and prefs.min_to_tray and not inp.key_shift_down:
					tauon.min_to_tray()
				else:
					tauon.exit("Window received exit signal")
					break
			elif event.type == sdl3.SDL_EVENT_TEXT_EDITING:
				power += 5
				# logging.info("edit text")
				gui.editline = event.edit.text
				# logging.info(gui.editline)
				gui.editline = gui.editline.decode("utf-8", "ignore")
				inp.k_input = True
				gui.update += 1

			elif event.type == sdl3.SDL_EVENT_MOUSE_MOTION:
				mp = tauon.menu_popup_for_window(event.motion.windowID)
				if mp is not None:
					# Motion inside a menu popup (main or submenu): track it in that
					# window's own local pixel space and mark it the active pointer
					# window. It must never touch the main window's mouse_position.
					mp.last_local = (int(event.motion.x * mp.scale), int(event.motion.y * mp.scale))
					tauon.active_pointer_window = mp
					gui.update += 1
				elif event.motion.windowID == sdl3.SDL_GetWindowID(tauon.t_window):
					inp.mouse_position[0] = int(event.motion.x / logical_size[0] * window_size[0])
					inp.mouse_position[1] = int(event.motion.y / logical_size[0] * window_size[0])
					mouse_moved = True
					gui.mouse_unknown = False
					# Pointer is over the main window, not a menu popup, so no menu
					# popup is the active pointer window - prevents a popup menu from
					# hit-testing against a stale popup-local position.
					tauon.active_pointer_window = None
				# else: motion from a hidden/closed popup or other window - ignore.
			elif event.type == sdl3.SDL_EVENT_MOUSE_BUTTON_DOWN:
				inp.k_input = True
				focused = True
				power += 5
				gui.update += 1
				gui.mouse_in_window = True

				if ggc == 2:  # dont click on first full frame
					continue

				# Route button events for visible menu popups the same way as motion.
				# A press inside either popup (main or submenu) drives the menu
				# normally. A press anywhere else dismisses the menu but is allowed
				# to fall through to the main UI - matching the original in-window
				# menus, where clicking away also activated whatever was under the
				# cursor (e.g. the companion view switcher). close_all_menus()
				# deactivates the menu first, so the click-transfer block below
				# won't feed the click to a now-closed menu. Menus dismissed here
				# are flagged so a toggle button under the cursor doesn't treat
				# the same click as "open again".
				for menu in Menu.instances:
					menu.click_dismissed = False
				if tauon.menu_popup is not None and tauon.menu_popup.visible \
						and tauon.menu_popup_for_window(event.button.windowID) is None:
					for menu in Menu.instances:
						if menu.active:
							menu.click_dismissed = True
					close_all_menus()
					tauon.close_menu_popup()

				if event.button.button == sdl3.SDL_BUTTON_RIGHT:
					inp.right_click = True
					inp.right_down = True
					# logging.info("RIGHT DOWN")
				elif event.button.button == sdl3.SDL_BUTTON_LEFT:
					# logging.info("LEFT DOWN")

					# if inp.mouse_position[1] > 1 and inp.mouse_position[0] > 1:
					#     inp.mouse_down = True

					inp.mouse_click = True

					inp.mouse_down = True
				elif event.button.button == sdl3.SDL_BUTTON_MIDDLE:
					inp.middle_click = True
					gui.update += 1
				elif event.button.button == sdl3.SDL_BUTTON_X1:
					keymaps.hits.append("MB4")
				elif event.button.button == sdl3.SDL_BUTTON_X2:
					keymaps.hits.append("MB5")
			elif event.type == sdl3.SDL_EVENT_MOUSE_BUTTON_UP:
				inp.k_input = True
				power += 5
				gui.update += 1
				if event.button.button == sdl3.SDL_BUTTON_RIGHT:
					inp.right_down = False
				elif event.button.button == sdl3.SDL_BUTTON_LEFT:
					# Only a release on the main window records a main-window mouse-up; a
					# release inside the menu popup uses popup-local coords and must not
					# drive main drag logic or corrupt mouse_up_position.
					if inp.mouse_down and event.button.windowID == sdl3.SDL_GetWindowID(tauon.t_window):
						inp.mouse_up = True
						inp.mouse_up_position[0] = event.motion.x / logical_size[0] * window_size[0]
						inp.mouse_up_position[1] = event.motion.y / logical_size[0] * window_size[0]

					inp.mouse_down = False
					gui.update += 1
			elif event.type == sdl3.SDL_EVENT_KEY_DOWN:
				if inp.key_focused != 0:
					continue
				inp.k_input = True
				power += 5
				gui.update += 2
				if prefs.use_scancodes:
					keymaps.hits.append(event.key.scancode)
				else:
					keymaps.hits.append(event.key.key)

				if prefs.use_scancodes:
					if event.key.scancode == sdl3.SDL_SCANCODE_V:
						inp.key_v_press = True
					elif event.key.scancode == sdl3.SDL_SCANCODE_A:
						inp.key_a_press = True
					elif event.key.scancode == sdl3.SDL_SCANCODE_S:
						inp.key_s_press = True
					elif event.key.scancode == sdl3.SDL_SCANCODE_C:
						inp.key_c_press = True
					elif event.key.scancode == sdl3.SDL_SCANCODE_Z:
						inp.key_z_press = True
					elif event.key.scancode == sdl3.SDL_SCANCODE_X:
						inp.key_x_press = True
				elif event.key.key == sdl3.SDLK_V:
					inp.key_v_press = True
				elif event.key.key == sdl3.SDLK_A:
					inp.key_a_press = True
				elif event.key.key == sdl3.SDLK_S:
					inp.key_s_press = True
				elif event.key.key == sdl3.SDLK_C:
					inp.key_c_press = True
				elif event.key.key == sdl3.SDLK_Z:
					inp.key_z_press = True
				elif event.key.key == sdl3.SDLK_X:
					inp.key_x_press = True

				if (event.key.key in (sdl3.SDLK_RETURN, sdl3.SDLK_RETURN2) and len(gui.editline) == 0) or (
					event.key.key == sdl3.SDLK_KP_ENTER and len(gui.editline) == 0
				):
					inp.key_return_press = True
				elif event.key.key == sdl3.SDLK_TAB:
					inp.key_tab_press = True
				elif event.key.key == sdl3.SDLK_BACKSPACE:
					inp.backspace_press += 1
					inp.key_backspace_press = True
				elif event.key.key == sdl3.SDLK_DELETE:
					inp.key_del = True
				elif event.key.key == sdl3.SDLK_RALT:
					inp.key_ralt = True
				elif event.key.key == sdl3.SDLK_LALT:
					inp.key_lalt = True
				elif event.key.key == sdl3.SDLK_DOWN:
					inp.key_down_press = True
				elif event.key.key == sdl3.SDLK_UP:
					inp.key_up_press = True
				elif event.key.key == sdl3.SDLK_LEFT:
					inp.key_left_press = True
				elif event.key.key == sdl3.SDLK_RIGHT:
					inp.key_right_press = True
				elif event.key.key == sdl3.SDLK_LSHIFT:
					inp.key_shift_down = True
				elif event.key.key == sdl3.SDLK_RSHIFT:
					inp.key_shiftr_down = True
				elif event.key.key == sdl3.SDLK_LCTRL:
					inp.key_ctrl_down = True
				elif event.key.key == sdl3.SDLK_RCTRL:
					inp.key_rctrl_down = True
				elif event.key.key == sdl3.SDLK_HOME:
					inp.key_home_press = True
				elif event.key.key == sdl3.SDLK_END:
					inp.key_end_press = True
				elif event.key.key == sdl3.SDLK_LGUI:
					if macos:
						inp.key_ctrl_down = True
					else:
						inp.key_meta = True
						inp.key_focused = 1

			elif event.type == sdl3.SDL_EVENT_KEY_UP:
				inp.k_input = True
				power += 5
				gui.update += 2
				if event.key.key == sdl3.SDLK_LSHIFT:
					inp.key_shift_down = False
				elif event.key.key == sdl3.SDLK_LCTRL:
					inp.key_ctrl_down = False
				elif event.key.key == sdl3.SDLK_RCTRL:
					inp.key_rctrl_down = False
				elif event.key.key == sdl3.SDLK_RSHIFT:
					inp.key_shiftr_down = False
				elif event.key.key == sdl3.SDLK_RALT:
					gui.album_tab_mode = False
					inp.key_ralt = False
				elif event.key.key == sdl3.SDLK_LALT:
					gui.album_tab_mode = False
					inp.key_lalt = False
				elif event.key.key == sdl3.SDLK_LGUI:
					if macos:
						inp.key_ctrl_down = False
					else:
						inp.key_meta = False
						inp.key_focused = 1

			elif event.type == sdl3.SDL_EVENT_TEXT_INPUT:
				inp.k_input = True
				power += 5
				inp.input_text += event.text.text.decode("utf-8")

				gui.update += 1
				# logging.info(inp.input_text)

			elif event.type == sdl3.SDL_EVENT_MOUSE_WHEEL:
				inp.k_input = True
				power += 6
				now = time.monotonic()
				wheel_before = inp.mouse_wheel
				raw_scroll_y = event.wheel.y
				integer_scroll_y = event.wheel.integer_y
				smooth_enabled = tauon.smooth_scroll.enabled()
				precise_input = smooth_enabled and (macos or raw_scroll_y != integer_scroll_y)
				scroll_y = raw_scroll_y if smooth_enabled else float(integer_scroll_y)
				event_mode = "precise-smooth" if precise_input else "smooth" if smooth_enabled else "line"
				if event_mode != inp.scroll_debug_last_mode or now - inp.scroll_debug_last_log > 1.0:
					# logging.debug(
					# 	"Wheel event mode=%s raw_y=%.3f integer_y=%d scroll_y=%.3f smooth_enabled=%s precise_input=%s speed=%.3f wheel_before=%.3f mouse=(%d,%d)",
					# 	event_mode,
					# 	raw_scroll_y,
					# 	integer_scroll_y,
					# 	scroll_y,
					# 	smooth_enabled,
					# 	precise_input,
					# 	tauon.smooth_scroll.speed(),
					# 	wheel_before,
					# 	inp.mouse_position[0],
					# 	inp.mouse_position[1],
					# )
					inp.scroll_debug_last_mode = event_mode
					inp.scroll_debug_last_log = now
				inp.mouse_wheel += scroll_y
				# if logging.getLogger().isEnabledFor(logging.DEBUG):
				# 	logging.debug(
				# 		"Wheel event accumulate mode=%s scroll_y=%.3f wheel_after=%.3f",
				# 		event_mode,
				# 		scroll_y,
				# 		inp.mouse_wheel,
				# 	)
				inp.mouse_wheel_precise = precise_input
				inp.trackpad_scroll_mode_until = 0.0

				gui.update += 1
			elif event.type == sdl3.SDL_EVENT_FINGER_DOWN:
				if inp.active_touch_id is None:
					inp.active_touch_id = event.tfinger.fingerID
				if event.tfinger.fingerID == inp.active_touch_id:
					inp.k_input = True
					inp.touch_active = True
					inp.touch_released = False
					inp.touch_position[0] = int(event.tfinger.x * window_size[0])
					inp.touch_position[1] = int(event.tfinger.y * window_size[1])
					gui.update += 1
			elif event.type == sdl3.SDL_EVENT_FINGER_MOTION:
				if inp.active_touch_id is None:
					inp.active_touch_id = event.tfinger.fingerID
				if event.tfinger.fingerID == inp.active_touch_id:
					inp.k_input = True
					inp.touch_active = True
					inp.touch_position[0] = int(event.tfinger.x * window_size[0])
					inp.touch_position[1] = int(event.tfinger.y * window_size[1])
					inp.touch_scroll_y += event.tfinger.dy * window_size[1]
					gui.update += 1
			elif event.type in (sdl3.SDL_EVENT_FINGER_UP, sdl3.SDL_EVENT_FINGER_CANCELED):
				if event.tfinger.fingerID == inp.active_touch_id:
					inp.k_input = True
					inp.touch_active = False
					inp.touch_released = True
					inp.touch_position[0] = int(event.tfinger.x * window_size[0])
					inp.touch_position[1] = int(event.tfinger.y * window_size[1])
					inp.active_touch_id = None
					gui.update += 1
			elif event.type >= sdl3.SDL_EVENT_WINDOW_FIRST and event.type <= sdl3.SDL_EVENT_WINDOW_LAST:
				power += 5
				# logging.info(event.type)

				# These handlers all act on the main window (resize, focus,
				# layout, etc.). Ignore window events from secondary windows such
				# as the menu popup - otherwise e.g. a popup resize fires
				# WINDOW_RESIZED and overwrites the main window's logical_size,
				# corrupting all main-window mouse-coordinate scaling.
				if event.window.windowID != sdl3.SDL_GetWindowID(tauon.t_window):
					continue

				if event.type == sdl3.SDL_EVENT_WINDOW_FOCUS_GAINED:
					# logging.info("sdl3.SDL_WINDOWEVENT_FOCUS_GAINED")

					if not macos and not tauon.windows:
						tauon.gnome.focus()
					inp.k_input = True

					mouse_enter_window = True
					focused = True
					gui.lowered = False
					inp.key_focused = 1
					inp.mouse_down = False
					gui.album_tab_mode = False
					gui.pl_update = 1
					gui.update += 1

				elif event.type == sdl3.SDL_EVENT_WINDOW_FOCUS_LOST:
					# The menu popup is non-focusable, so showing it never steals
					# focus from the main window; a FOCUS_LOST here is a genuine
					# app deactivation, so close any open menu (and hide its popup
					# so it does not linger over other apps).
					close_all_menus()
					tauon.close_menu_popup()  # no-op if nothing is open
					inp.key_focused = 1
					gui.update += 1

				elif event.type == sdl3.SDL_EVENT_WINDOW_DISPLAY_CHANGED:
					# sdl3.SDL_WINDOWEVENT_DISPLAY_CHANGED logs new display ID as data1 (0 or 1 or 2...), it not width, and data 2 is always 0
					pass
				elif event.type == sdl3.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED:
					i_x = pointer(c_int(0))
					i_y = pointer(c_int(0))
					sdl3.SDL_GetWindowSizeInPixels(t_window, i_x, i_y)
					window_size[0] = i_x.contents.value
					window_size[1] = i_y.contents.value
					auto_scale(bag)
					gui.update_layout = True
					gui.update = 2
				elif event.type == sdl3.SDL_EVENT_WINDOW_RESIZED:
					# sdl3.SDL_WINDOWEVENT_RESIZED logs width to data1 and height to data2
					# if event.window.data1 < 500:
					# 	logging.error("Window width is less than 500, grrr why does this happen, stupid bug")
					# 	sdl3.SDL_SetWindowSize(t_window, logical_size[0], logical_size[1])
					# elif tauon.restore_ignore_timer.get() > 1:  # Hacky
					# 	gui.update = 2

					# 	logical_size[0] = event.window.data1
					# 	logical_size[1] = event.window.data2

					# 	if gui.mode != GuiMode.MINI:
					# 		logical_size[0] = max(300, logical_size[0])
					# 		logical_size[1] = max(300, logical_size[1])

					gui.update = 2
					logical_size[0] = event.window.data1
					logical_size[1] = event.window.data2
					# auto_scale(bag)
					# gui.update_layout = True

				elif event.type == sdl3.SDL_EVENT_WINDOW_MOUSE_ENTER:
					# logging.info("ENTER")
					mouse_enter_window = True
					gui.mouse_in_window = True
					gui.update += 1

				# elif event.type == sdl3.SDL_WINDOWEVENT_HIDDEN:
				#
				elif event.type == sdl3.SDL_EVENT_WINDOW_EXPOSED:
					# logging.info("expose")
					gui.lowered = False

				elif event.type == sdl3.SDL_EVENT_WINDOW_MINIMIZED:
					gui.lowered = True
					# if prefs.min_to_tray:
					# 	tray.down()
					# tauon.thread_manager.sleep()

				elif event.type == sdl3.SDL_EVENT_WINDOW_RESTORED:
					gui.lowered = False
					gui.maximized = False
					gui.pl_update = 1
					gui.update += 2

					if prefs.update_title:
						tauon.update_title_do()
						# logging.info("restore")

				elif event.type == sdl3.SDL_EVENT_WINDOW_SHOWN:
					focused = True
					gui.pl_update = 1
					gui.update += 1

				# elif event.type == sdl3.SDL_WINDOWEVENT_FOCUS_GAINED:
				#     logging.info("FOCUS GAINED")
				#     # input.mouse_enter_event = True
				#     # gui.update += 1
				#     # inp.k_input = True

				elif event.type == sdl3.SDL_EVENT_WINDOW_MAXIMIZED:
					if gui.mode != GuiMode.MINI:  # TODO(Taiko): workaround. sdl bug? gives event on window size set
						gui.maximized = True
					gui.update_layout = True
					gui.pl_update = 1
					gui.update += 1

				elif event.type == sdl3.SDL_EVENT_WINDOW_MOUSE_LEAVE:
					gui.mouse_in_window = False
					gui.update += 1
					power = 1000

		if mouse_moved and tauon.fields.test():
			gui.update += 1

		# if tauon.thread_manager.sleeping:
		#     if not gui.lowered:
		#         tauon.thread_manager.wake()
		if gui.lowered:
			gui.update = 0
		# ----------------
		# This section of code controls the internal processing speed or 'frame-rate'
		# It's pretty messy
		# if not gui.pl_update and gui.rendered_playlist_position != pctl.playlist_view_position:
		#     logging.warning("The playlist failed to render at the latest position!!!!")

		power += 1

		if pctl.playerCommandReady and tauon.thread_manager.player_lock.locked():
			try:
				tauon.thread_manager.player_lock.release()
			except RuntimeError as e:
				if str(e) == "release unlocked lock":
					logging.error("RuntimeError: Attempted to release already unlocked player_lock")  # noqa: TRY400
				else:
					logging.exception("Unknown RuntimeError trying to release player_lock")
			except Exception:
				logging.exception("Unknown exception trying to release player_lock")

		if gui.frame_callback_list:
			i = len(gui.frame_callback_list) - 1
			while i >= 0:
				if gui.frame_callback_list[i].test():
					gui.update = 1
					power = 1000
					del gui.frame_callback_list[i]
				i -= 1

		# Dream Room (F7): keep frames flowing while the 3D scene animates.
		# Only meaningful in the main GUI mode.
		if tauon.dream_room.active:
			if gui.mode != GuiMode.MAIN:
				tauon.dream_room.close_instant()
			else:
				gui.update = max(gui.update, 1)
				power = 1000
				pace_vis = True

		# Milkdrop preset chooser: keep frames flowing so hover tracking on the
		# overlay stays live (the visualiser underneath is animating anyway).
		if tauon.milk_choose.active:
			if gui.mode != GuiMode.MAIN:
				tauon.milk_choose.close()
			else:
				gui.update = max(gui.update, 1)
				power = 1000
				pace_vis = True

		if tauon.animate_monitor_timer.get() < 1 or tauon.load_orders:
			if tauon.cursor_blink_timer.get() > 0.65:
				tauon.cursor_blink_timer.set()
				TextBox.cursor ^= True
				gui.update = 1

			if inp.k_input:
				tauon.cursor_blink_timer.set()
				TextBox.cursor = True

			sdl3.SDL_Delay(3)
			power = 1000

		if inp.mouse_wheel or inp.k_input or gui.pl_update or gui.update or tauon.top_panel.adds:  # or mouse_moved:
			power = 1000

		if not tauon.smooth_scroll.enabled():
			tauon.smooth_scroll.reset_disabled_motion()

		if tauon.smooth_scroll.any_active():
			power = 1000
			gui.pl_update = max(gui.pl_update, 1)
			gui.update = max(gui.update, 1)
			now = time.monotonic()
			if tauon.scroll_animation_deadline <= 0:
				tauon.scroll_animation_deadline = now + SCROLL_ANIMATION_FRAME_INTERVAL
			elif now - tauon.scroll_animation_deadline > SCROLL_ANIMATION_FRAME_INTERVAL * 4:
				tauon.scroll_animation_deadline = now + SCROLL_ANIMATION_FRAME_INTERVAL

			frame_wait = tauon.scroll_animation_deadline - now
			if frame_wait > 0:
				time.sleep(frame_wait)
				now = time.monotonic()
			while tauon.scroll_animation_deadline <= now:
				tauon.scroll_animation_deadline += SCROLL_ANIMATION_FRAME_INTERVAL
		else:
			tauon.scroll_animation_deadline = 0.0

		if prefs.art_bg and tauon.core_timer.get() < 3:
			power = 1000

		if inp.mouse_down and mouse_moved:
			power = 1000
			if gui.update_on_drag:
				gui.update += 1
			if gui.pl_update_on_drag:
				gui.pl_update += 1

		if pctl.wake_past_time and tauon.get_real_time() > pctl.wake_past_time:
			pctl.wake_past_time = 0
			power = 1000
			gui.update += 1

		# The level_update partial-present path (top-panel spectrum/level meter)
		# re-arms itself every iteration while playing, so it presents at the
		# loop rate — pace it below like the other continuous visuals.
		if gui.level_update and not album_scroll_hold and not scroll_hold:
			power = 500
			pace_vis = True

		# if gui.vis == 3 and (pctl.playing_state in (PlayingState.PLAYING, PlayingState.URL_STREAM)):
		# 	power = 500
		# 	if len(gui.spec2_buffers) > 0 and gui.spec2_timer.get() > 0.04:
		# 		gui.spec2_timer.set()
		# 		gui.level_update = True
		# 		vis_update = True
		# 	else:
		# 		sdl3.SDL_Delay(5)

		if not pctl.running:
			break

		if tauon.requested_raise:
			tauon.raise_window()
			tauon.requested_raise = False

		if tauon.requested_tray_destruct:
			#logging.debug("Destroying tray as it was requested")
			tauon.destroy_sdl_tray()
			tauon.requested_tray_destruct = False
		if tauon.requested_tray:
			#logging.debug("Creating tray as it was requested")
			tauon.init_sdl_tray()
			tauon.requested_tray = False

		if pctl.playing_state != PlayingState.STOPPED:
			power += 400

		# Keep the scrolling spectrogram at a steady frame rate. Playback alone
		# only bumps power to 400 (below the 500 render threshold), so between the
		# jittery level_update pulses the loop drops into the ~33fps power-save
		# throttle and the scroll stutters. Holding power high + update set makes
		# it render every iteration.
		if gui.spectrogram_in_widget \
				and pctl.playing_state in (PlayingState.PLAYING, PlayingState.URL_STREAM):
			power = 1000
			gui.update = max(gui.update, 1)
			pace_vis = True

		# Pace the continuously animating visuals above by sleeping off whatever
		# part of the frame budget the present's vsync block didn't absorb: a
		# no-op while vsync paces the loop, a refresh-rate cap when it silently
		# stops blocking (driver override, occluded window).
		if pace_vis:
			pace_vis = False
			excess = tauon.frame_pace() - vis_pace_timer.get()
			if excess > 0:
				time.sleep(excess)
		vis_pace_timer.set()

		if prefs.milk and render_heartbeat_timer.get() > 5:
			# workaround for invis window bug?
			gui.pl_update = 1
			gui.update = 1
			power = 1000
			render_heartbeat_timer.set()

		if power < 500:
			# Limit FPS to 500 when Milkdrop presets are enabled, otherwise to 33
			if prefs.milk:
				time.sleep(0.002)
			else:
				time.sleep(0.03)
			if (
				(pctl.playing_state in (PlayingState.STOPPED, PlayingState.PAUSED))
				and not tauon.load_orders
				and gui.update == 0
				and not tauon.gall_ren.queue
				and not tauon.transcode_list
				and not gui.frame_callback_list
			):
				pass
			else:
				tauon.sleep_timer.set()
			if tauon.sleep_timer.get() > 2:
				sdl3.SDL_WaitEventTimeout(None, 1000)
			continue
		power = 0

		gui.pl_update = min(gui.pl_update, 2)

		gui.new_playlist_cooldown = False

		if prefs.auto_extract and prefs.monitor_downloads:
			tauon.dl_mon.scan()

		if inp.mouse_down and not tauon.coll((2, 2, window_size[0] - 4, window_size[1] - 4)):
			# logging.info(sdl3.SDL_GetMouseState(None, None))
			if sdl3.SDL_GetGlobalMouseState(None, None) == 0:
				inp.mouse_down = False
				inp.mouse_up = True
				inp.quick_drag = False

		# logging.info(window_size)
		# if window_size[0] / window_size[1] == 16 / 9:
		#     logging.info('OK')
		# if window_size[0] / window_size[1] > 16 / 9:
		#     logging.info("A")

		if inp.key_meta:
			inp.input_text = ""
			inp.k_input = False
			inp.key_return_press = False
			inp.key_tab_press = False

		if inp.k_input:
			if inp.mouse_click or inp.right_click or inp.mouse_up:
				inp.last_click_location = copy.deepcopy(inp.click_location)
				inp.click_location = copy.deepcopy(inp.mouse_position)

			if inp.key_focused != 0:
				keymaps.hits.clear()

				# inp.d_mouse_click = False
				# inp.right_click = False
				# inp.level_2_right_click = False
				# inp.mouse_click = False
				# inp.middle_click = False
				inp.mouse_up = False
				inp.key_return_press = False
				inp.key_down_press = False
				inp.key_up_press = False
				inp.key_right_press = False
				inp.key_left_press = False
				inp.key_esc_press = False
				inp.key_del = False
				inp.backspace_press = 0
				inp.key_backspace_press = False
				inp.key_tab_press = False
				inp.key_c_press = False
				inp.key_v_press = False
				# inp.key_f_press = False
				inp.key_a_press = False
				inp.key_s_press = False
				# inp.key_t_press = False
				inp.key_z_press = False
				inp.key_x_press = False
				inp.key_home_press = False
				inp.key_end_press = False
				inp.mouse_wheel = 0
				inp.mouse_wheel_precise = False
				inp.touch_scroll_y = 0
				inp.touch_released = False
				pref_box.scroll = 0
				inp.input_text = ""
				inp.level_2_enter = False

		if c_yax != 0:
			if c_yax_timer.get() >= 0:
				if c_yax == -1:
					inp.key_up_press = True
				if c_yax == 1:
					inp.key_down_press = True
				c_yax_timer.force_set(-0.01)
				gui.delay_frame(0.02)
				inp.k_input = True
		if c_xax != 0:
			if c_xax_timer.get() >= 0:
				if c_xax == 1:
					pctl.seek_time(pctl.playing_time + 2)
				if c_xax == -1:
					pctl.seek_time(pctl.playing_time - 2)
				c_xax_timer.force_set(-0.01)
				gui.delay_frame(0.02)
				inp.k_input = True
		if c_xay != 0:
			if c_xay_timer.get() >= 0:
				if c_xay == -1:
					pctl.player_volume += 1
					pctl.player_volume = min(pctl.player_volume, 100)
					pctl.set_volume()
				if c_xay == 1:
					if pctl.player_volume > 1:
						pctl.player_volume -= 1
					else:
						pctl.player_volume = 0
					pctl.set_volume()
				c_xay_timer.force_set(-0.01)
				gui.delay_frame(0.02)
				inp.k_input = True

		text_entry_shortcuts_blocked = (
			gui.rename_folder_box
			or tauon.rename_track_box.active
			or gui.rename_playlist_box
			or radiobox.active
			or pref_box.enabled
			or tauon.trans_edit_box.active
			or gui.timed_lyrics_editing_now
			or tauon.export_playlist_box.active
		)

		if inp.k_input and inp.key_focused == 0:
			if gui.timed_lyrics_editing_now:
				keymaps.hits.clear()
			elif text_entry_shortcuts_blocked and keymaps.hits:
				escape_pressed = keymaps.test("escape")
				keymaps.hits.clear()
				if escape_pressed:
					inp.key_esc_press = True
			if keymaps.hits:
				n = 1
				while n < 10:
					if keymaps.test(f"jump-playlist-{n}"):
						if len(pctl.multi_playlist) > n - 1:
							pctl.switch_playlist(n - 1)
					n += 1

				if keymaps.test("cycle-playlist-left"):
					if gui.album_tab_mode and inp.key_left_press:
						pass
					elif tauon.is_level_zero() or gui.quick_search_mode:
						pctl.cycle_playlist_pinned(1)
				if keymaps.test("cycle-playlist-right"):
					if gui.album_tab_mode and inp.key_right_press:
						pass
					elif tauon.is_level_zero() or gui.quick_search_mode:
						pctl.cycle_playlist_pinned(-1)

				if keymaps.test("toggle-console"):
					tauon.console.toggle()

				if keymaps.test("toggle-fullscreen"):
					if not gui.fullscreen and gui.mode != GuiMode.MINI:
						gui.fullscreen = True
						sdl3.SDL_SetWindowFullscreenMode(t_window, None)
						sdl3.SDL_SetWindowFullscreen(t_window, True)
					elif gui.fullscreen:
						gui.fullscreen = False
						sdl3.SDL_SetWindowFullscreen(t_window, False)

				if keymaps.test("playlist-toggle-breaks"):
					# Toggle force off folder break for viewed playlist
					pctl.multi_playlist[pctl.active_playlist_viewing].hide_title ^= 1
					gui.pl_update = 1

				if keymaps.test("find-playing-artist"):
					# standard_size()
					if len(pctl.track_queue) > 0:
						gui.quick_search_mode = True
						tauon.search_over.search_text.text = ""
						inp.input_text = pctl.playing_object().artist

				if keymaps.test("show-encode-folder"):
					tauon.open_encode_out()

				if keymaps.test("toggle-left-panel"):
					gui.lsp ^= True
					tauon.update_layout_do()

				if keymaps.test("toggle-last-left-panel"):
					tauon.toggle_left_last()
					tauon.update_layout_do()

				if keymaps.test("escape"):
					inp.key_esc_press = True

			if inp.key_ctrl_down:
				gui.pl_update += 1

			if mouse_enter_window:
				inp.key_return_press = False

			if gui.fullscreen and inp.key_esc_press:
				gui.fullscreen = False
				sdl3.SDL_SetWindowFullscreen(t_window, 0)

			# Disable keys for text cursor control
			if not text_entry_shortcuts_blocked:
				# On macOS, the key labeled Delete reports as BACKSPACE in SDL.
				# Require Command+Backspace here so plain Backspace keeps its normal meaning.
				macos_delete_shortcut = macos and inp.key_ctrl_down and inp.key_backspace_press and not gui.quick_search_mode

				if not gui.quick_search_mode and not tauon.search_over.active:
					if (
						prefs.album_mode
						and gui.album_tab_mode
						and not inp.key_ctrl_down
						and not inp.key_meta
						and not inp.key_lalt
					):
						if inp.key_left_press:
							gal_left = True
							inp.key_left_press = False
						if inp.key_right_press:
							gal_right = True
							inp.key_right_press = False
						if inp.key_up_press:
							gal_up = True
							inp.key_up_press = False
						if inp.key_down_press:
							gal_down = True
							inp.key_down_press = False

				if not tauon.search_over.active:
					if inp.key_del or macos_delete_shortcut:
						close_all_menus()
						tauon.del_selected()

					# Arrow keys to change playlist
					if (inp.key_left_press or inp.key_right_press) and len(pctl.multi_playlist) > 1:
						gui.pl_update = 1
						gui.update += 1

				if keymaps.test("start"):
					if pctl.playing_time < 4:
						pctl.back()
					else:
						pctl.new_time = 0
						pctl.playing_time = 0
						pctl.decode_time = 0
						pctl.playerCommand = "seek"
						pctl.playerCommandReady = True

				if keymaps.test("goto-top"):
					pctl.playlist_view_position = 0
					logging.debug("Position changed by key")
					pctl.selected_in_playlist = 0
					gui.pl_update = 1

				if keymaps.test("goto-bottom"):
					n = len(pctl.default_playlist) - gui.playlist_view_length + 1
					n = max(n, 0)
					pctl.playlist_view_position = n
					logging.debug("Position changed by key")
					pctl.selected_in_playlist = len(pctl.default_playlist) - 1
					gui.pl_update = 1

			if not text_entry_shortcuts_blocked and not tauon.search_over.active and not gui.box_over:
				if gui.quick_search_mode:
					if keymaps.test("add-to-queue") and pctl.selected_ready():
						tauon.add_selected_to_queue()
						inp.input_text = ""
				else:
					if inp.key_c_press and inp.key_ctrl_down:
						gui.pl_update = 1
						tauon.s_copy()

					if inp.key_x_press and inp.key_ctrl_down:
						gui.pl_update = 1
						tauon.s_cut()

					if inp.key_v_press and inp.key_ctrl_down:
						gui.pl_update = 1
						tauon.paste()

					if keymaps.test("playpause"):
						pctl.play_pause()

			if inp.key_return_press and (gui.rename_folder_box or tauon.rename_track_box.active or radiobox.active):
				inp.key_return_press = False
				inp.level_2_enter = True

			if inp.key_ctrl_down and inp.key_z_press and not text_entry_shortcuts_blocked:
				tauon.undo.undo()

			if keymaps.test("quit"):
				tauon.exit("Quit keyboard shortcut pressed")

			if keymaps.test("testkey"):  # F7: unused
				pass

			if gui.mode == GuiMode.MAIN:
				if keymaps.test("toggle-auto-theme"):
					prefs.colour_from_image ^= True
					if prefs.colour_from_image:
						tauon.show_message(_("Enabled auto theme"))
					else:
						tauon.show_message(_("Disabled auto theme"))
						gui.reload_theme = True
						gui.theme_temp_current = -1

				if keymaps.test("transfer-playtime-to"):
					if (
						len(pctl.cargo) == 1
						and tauon.copied_track is not None
						and -1 < pctl.selected_in_playlist < len(pctl.default_playlist)
					):
						fr = pctl.get_track(tauon.copied_track)
						to = pctl.get_track(pctl.default_playlist[pctl.selected_in_playlist])

						fr_s = tauon.star_store.full_get(fr.index)
						to_s = tauon.star_store.full_get(to.index)

						fr_scr = fr.lfm_scrobbles
						to_scr = to.lfm_scrobbles

						tauon.undo.bk_playtime_transfer(fr, fr_s, fr_scr, to, to_s, to_scr)

						if to_s is None:
							to_s = StarRecord()
						if fr_s is None:
							fr_s = StarRecord()

						new = StarRecord()

						new.playtime = fr_s.playtime + to_s.playtime
						new.rating = fr_s.rating
						if to_s.rating > 0 and fr_s.rating == 0:
							new.rating = to_s.rating  # keep target rating
						to.lfm_scrobbles = fr.lfm_scrobbles

						tauon.star_store.remove(fr.index)
						tauon.star_store.remove(to.index)
						if new.playtime or new.rating:
							tauon.star_store.insert(to.index, new)

						tauon.copied_track = None
						gui.pl_update += 1
						logging.info("Transferred track stats!")
					elif tauon.copied_track is None:
						tauon.show_message(_("First select a source track by copying it into clipboard"))

				if keymaps.test("toggle-gallery"):
					tauon.toggle_album_mode()

				if keymaps.test("toggle-right-panel"):
					if gui.combo_mode:
						tauon.exit_combo()
					elif not prefs.album_mode:
						tauon.toggle_side_panel()
					else:
						tauon.toggle_album_mode()

				if keymaps.test("toggle-minimode"):
					tauon.set_mini_mode()
					gui.update += 1

				if keymaps.test("cycle-layouts"):
					tauon.view_box.cycle()

				if keymaps.test("cycle-layouts-reverse"):
					tauon.view_box.cycle(reverse=True)

				if keymaps.test("toggle-columns"):
					tauon.view_box.col(True)

				if keymaps.test("toggle-artistinfo"):
					tauon.view_box.artist_info(True)

				if keymaps.test("toggle-showcase"):
					tauon.view_box.lyrics(True)

				if keymaps.test("toggle-gallery-keycontrol"):
					tauon.toggle_gallery_keycontrol()

				if keymaps.test("toggle-show-art"):
					tauon.toggle_side_art()

			elif gui.mode == GuiMode.MINI:
				if keymaps.test("toggle-minimode"):
					tauon.restore_full_mode()
					gui.update += 1

			inp.ab_click = False

			if keymaps.test("new-playlist"):
				tauon.new_playlist()

			if keymaps.test("edit-generator"):
				tauon.edit_generator_box(pctl.active_playlist_viewing)

			if keymaps.test("new-generator-playlist"):
				tauon.new_playlist()
				tauon.edit_generator_box(pctl.active_playlist_viewing)

			if keymaps.test("delete-playlist"):
				pctl.delete_playlist(pctl.active_playlist_viewing)

			if keymaps.test("delete-playlist-force"):
				pctl.delete_playlist(pctl.active_playlist_viewing, force=True)

			if keymaps.test("rename-playlist"):
				if gui.radio_view:
					tauon.rename_playlist(pctl.radio_playlist_viewing)
				else:
					tauon.rename_playlist(pctl.active_playlist_viewing)
				tauon.rename_playlist_box.x = 60 * gui.scale
				tauon.rename_playlist_box.y = 60 * gui.scale

			# Dream Room: while the camera is out, intercept input before any
			# menu/box consumer so a click reliably flies it back in, and the
			# UI on the little monitor doesn't react to the muted mouse.
			if tauon.dream_room.active:
				tauon.dream_room.handle_input()

			# Milkdrop preset chooser: capture then mute the pointer before any
			# other consumer so clicks never leak through the overlay.
			if tauon.milk_choose.active:
				tauon.milk_choose.handle_input()

			# Transfer click register to menus
			if inp.mouse_click:
				for instance in Menu.instances:
					if instance.active:
						instance.click()
						inp.mouse_click = False
						inp.ab_click = True
				if tauon.view_box.active:
					tauon.view_box.clicked = True

			if inp.mouse_click and (
				prefs.show_nag
				or gui.box_over
				or radiobox.active
				or tauon.search_over.active
				or gui.rename_folder_box
				or gui.rename_playlist_box
				or tauon.rename_track_box.active
				or tauon.view_box.active
				or tauon.trans_edit_box.active
			):  # and not gui.message_box:
				inp.mouse_click = False
				gui.level_2_click = True
			else:
				gui.level_2_click = False

			if gui.track_box and inp.mouse_click:
				w = 540
				h = 240
				x = int(window_size[0] / 2) - int(w / 2)
				y = int(window_size[1] / 2) - int(h / 2)
				if tauon.coll([x, y, w, h]):
					inp.mouse_click = False
					gui.level_2_click = True

			if inp.right_click:
				inp.level_2_right_click = True

			if pref_box.enabled:
				if pref_box.inside():
					if inp.mouse_click:  # and not gui.message_box:
						pref_box.click = True
						inp.mouse_click = False
					if inp.right_click:
						inp.right_click = False
						pref_box.right_click = True

					pref_box.scroll = inp.mouse_wheel
					inp.mouse_wheel = 0
				else:
					if inp.mouse_click:
						inp.mouse_click = False
						pref_box.close()
					if inp.right_click:
						inp.right_click = False
						pref_box.close()
					if pref_box.lock is False:
						pass

			if inp.right_click and (
				radiobox.active
				or tauon.rename_track_box.active
				or gui.rename_playlist_box
				or gui.rename_folder_box
				or tauon.search_over.active
			):
				inp.right_click = False

			if inp.mouse_wheel != 0:
				gui.update += 1
			if inp.mouse_down is True:
				gui.update += 1

			if keymaps.test("pagedown"):  # key_PGD:
				if len(pctl.default_playlist) > 10:
					pctl.playlist_view_position += gui.playlist_view_length - 4
					if pctl.playlist_view_position >= len(pctl.default_playlist):
						pctl.playlist_view_position = len(pctl.default_playlist) - 2
					gui.pl_update = 1
					pctl.selected_in_playlist = pctl.playlist_view_position
					logging.debug("Position changed by page key")
					gui.shift_selection.clear()
			if keymaps.test("pageup"):
				if len(pctl.default_playlist) > 0:
					pctl.playlist_view_position -= gui.playlist_view_length - 4
					pctl.playlist_view_position = max(pctl.playlist_view_position, 0)
					gui.pl_update = 1
					pctl.selected_in_playlist = pctl.playlist_view_position
					logging.debug("Position changed by page key")
					gui.shift_selection.clear()

			if (
				gui.quick_search_mode is False
				and tauon.rename_track_box.active is False
				and gui.rename_folder_box is False
				and gui.rename_playlist_box is False
				and not pref_box.enabled
				and not radiobox.active
			):
				if keymaps.test("info-playing"):
					playing_track = pctl.playing_object()
					if playing_track is not None and playing_track.index in pctl.master_library:
						tauon.show_track_box(playing_track.index)

				if keymaps.test("info-show"):
					if pctl.selected_ready():
						tauon.show_track_box(pctl.get_track(pctl.default_playlist[pctl.selected_in_playlist]).index)

				# These need to be disabled when text fields are active
				if (
					not tauon.search_over.active
					and not gui.box_over
					and not radiobox.active
					and not gui.rename_folder_box
					and not tauon.rename_track_box.active
					and not gui.rename_playlist_box
					and not tauon.trans_edit_box.active
					and not gui.timed_lyrics_editing_now
				):
					if keymaps.test("advance"):
						inp.key_right_press = False
						pctl.advance()

					if keymaps.test("previous"):
						inp.key_left_press = False
						pctl.back()

					if inp.key_a_press and inp.key_ctrl_down:
						gui.pl_update = 1
						gui.shift_selection = list(range(len(pctl.default_playlist)))

					if keymaps.test("revert"):
						pctl.revert()

					if keymaps.test("random-track-start"):
						pctl.advance(rr=True)

					if keymaps.test("vol-down"):
						if pctl.player_volume > 3:
							pctl.player_volume -= 3
						else:
							pctl.player_volume = 0
						pctl.set_volume()

					if keymaps.test("toggle-mute"):
						pctl.toggle_mute()

					if keymaps.test("vol-up"):
						pctl.player_volume += 3
						pctl.player_volume = min(pctl.player_volume, 100)
						pctl.set_volume()

					if keymaps.test("shift-down") and len(pctl.default_playlist) > 0:
						gui.pl_update += 1
						if pctl.selected_in_playlist > len(pctl.default_playlist) - 1:
							pctl.selected_in_playlist = 0

						if not gui.shift_selection:
							gui.shift_selection.append(pctl.selected_in_playlist)
						if pctl.selected_in_playlist < len(pctl.default_playlist) - 1:
							r = pctl.selected_in_playlist
							pctl.selected_in_playlist += 1
							if pctl.selected_in_playlist not in gui.shift_selection:
								gui.shift_selection.append(pctl.selected_in_playlist)
							else:
								gui.shift_selection.remove(r)

					if keymaps.test("shift-up") and pctl.selected_in_playlist > -1:
						gui.pl_update += 1
						pctl.selected_in_playlist = min(pctl.selected_in_playlist, len(pctl.default_playlist) - 1)

						if not gui.shift_selection:
							gui.shift_selection.append(pctl.selected_in_playlist)
						if pctl.selected_in_playlist > 0:
							r = pctl.selected_in_playlist
							pctl.selected_in_playlist -= 1
							if pctl.selected_in_playlist not in gui.shift_selection:
								gui.shift_selection.insert(0, pctl.selected_in_playlist)
							else:
								gui.shift_selection.remove(r)

					if keymaps.test("toggle-shuffle"):
						# pctl.random_mode ^= True
						tauon.toggle_random()

					if keymaps.test("goto-playing"):
						pctl.show_current()
					if keymaps.test("goto-previous"):
						if pctl.queue_step > 1:
							pctl.show_current(index=pctl.track_queue[pctl.queue_step - 1])

					if keymaps.test("toggle-repeat"):
						tauon.toggle_repeat()

					if keymaps.test("random-track"):
						tauon.random_track()

					if keymaps.test("random-album"):
						tauon.random_album()

					if keymaps.test("opacity-up"):
						prefs.window_opacity += 0.05
						prefs.window_opacity = min(prefs.window_opacity, 1)
						sdl3.SDL_SetWindowOpacity(t_window, prefs.window_opacity)

					if keymaps.test("opacity-down"):
						prefs.window_opacity -= 0.05
						prefs.window_opacity = max(prefs.window_opacity, 0.30)
						sdl3.SDL_SetWindowOpacity(t_window, prefs.window_opacity)

					if keymaps.test("seek-forward"):
						pctl.seek_time(pctl.playing_time + prefs.seek_interval)

					if keymaps.test("seek-back"):
						pctl.seek_time(pctl.playing_time - prefs.seek_interval)

					if keymaps.test("play"):
						pctl.play()

					if keymaps.test("stop"):
						pctl.stop()

					if keymaps.test("pause"):
						pctl.pause_only()

					if keymaps.test("love-playing"):
						tauon.bar_love(notify=True)

					if keymaps.test("love-selected"):
						tauon.select_love(notify=True)

					if keymaps.test("search-lyrics-selected"):
						if pctl.selected_ready():
							track = pctl.get_track(pctl.default_playlist[pctl.selected_in_playlist])
							if track.lyrics:
								tauon.show_message(_("Track already has lyrics"))
							else:
								tauon.get_lyric_wiki(track)

					if keymaps.test("substitute-search-selected"):
						if pctl.selected_ready():
							tauon.show_sub_search(pctl.get_track(pctl.default_playlist[pctl.selected_in_playlist]))

					if keymaps.test("global-search"):
						tauon.activate_search_overlay()

					if keymaps.test("add-to-queue") and pctl.selected_ready():
						tauon.add_selected_to_queue()

					if keymaps.test("clear-queue"):
						tauon.clear_queue()

					if keymaps.test("regenerate-playlist"):
						tauon.regenerate_playlist(pctl.active_playlist_viewing)

			if keymaps.test("cycle-theme"):
				gui.reload_theme = True
				gui.theme_temp_current = -1
				gui.temp_themes.clear()
				prefs.theme += 1

			if keymaps.test("cycle-theme-reverse"):
				gui.theme_temp_current = -1
				gui.temp_themes.clear()
				pref_box.previous_theme()

			if keymaps.test("reload-theme"):
				gui.reload_theme = True

		# if inp.mouse_position[1] < 1:
		#     inp.mouse_down = False

		if inp.mouse_down is False:
			scroll_hold = False

		# if focused is True:
		#     inp.mouse_down = False

		if inp.media_key:
			if inp.media_key == "Play":
				pctl.play_pause()
			elif inp.media_key == "Pause":
				pctl.pause_only()
			elif inp.media_key == "Stop":
				pctl.stop()
			elif inp.media_key == "Next":
				pctl.advance()
			elif inp.media_key == "Previous":
				pctl.back()

			elif inp.media_key == "Rewind":
				pctl.seek_time(pctl.playing_time - 10)
			elif inp.media_key == "FastForward":
				pctl.seek_time(pctl.playing_time + 10)
			elif inp.media_key == "Repeat":
				tauon.toggle_repeat()
			elif inp.media_key == "Shuffle":
				tauon.toggle_random()

			inp.media_key = ""

		if len(tauon.load_orders) > 0:
			pctl.loading_in_progress = True
			pctl.after_import_flag = True
			tauon.thread_manager.ready("worker")
			if tauon.loaderCommand == LoaderCommand.NONE:
				# Filter out files matching CUE filenames
				# This isn't the only mechanism that does this. This one helps in the situation
				# where the user drags and drops multiple files at onec. CUEs in folders are handled elsewhere
				if len(tauon.load_orders) > 1:
					for order in tauon.load_orders:
						if order.stage == 0 and order.target.endswith(".cue"):
							for order2 in tauon.load_orders:
								if (
									not order2.target.endswith(".cue")
									and os.path.splitext(order2.target)[0] == os.path.splitext(order.target)[0]
									and os.path.isfile(order2.target)
								):
									order2.stage = -1
					for i in reversed(range(len(tauon.load_orders))):
						order = tauon.load_orders[i]
						if order.stage == -1:
							del tauon.load_orders[i]

				# Prepare loader thread with load order
				for order in tauon.load_orders:
					if order.stage == 0:
						order.target = order.target.replace("\\", "/")
						order.stage = 1
						if os.path.isdir(order.target):
							tauon.loaderCommand = LoaderCommand.FOLDER
						else:
							tauon.loaderCommand = LoaderCommand.FILE
							if order.target.endswith(".xspf"):
								gui.to_got = "xspf"
								gui.to_get = 0
							else:
								gui.to_got = 1
								gui.to_get = 1
						tauon.loaderCommandReady = True
						tauon.thread_manager.ready("worker")
						break

		elif pctl.loading_in_progress is True:
			pctl.loading_in_progress = False
			pctl.notify_database_changed()

		if tauon.loaderCommand == LoaderCommand.DONE:
			tauon.loaderCommand = LoaderCommand.NONE
			gui.update += 1
			# gui.pl_update = 1
			# pctl.loading_in_progress = False

		if gui.update_layout:
			tauon.update_layout_do()
			gui.update_layout = False

		# if tauon.worker_save_state and\
		# 		not gui.pl_pulse and\
		# 		not pctl.loading_in_progress and\
		# 		not tauon.to_scan and\
		# 		not tauon.plex.scanning and\
		# 		not tauon.cm_clean_db and\
		# 		not tauon.lastfm.scanning_friends and\
		# 		not tauon.move_in_progress:
		# 	tauon.save_state()
		# 	cue_list.clear()
		# 	tauon.worker_save_state = False

		# -----------------------------------------------------
		# THEME SWITCHER--------------------------------------------------------------------

		if gui.reload_theme is True:
			gui.pl_update = 1
			theme_files = get_themes(dirs)

			if prefs.theme > len(theme_files):  # sic
				prefs.theme = 0

			if prefs.theme > 0:
				theme_number = prefs.theme - 1
				try:
					colours.column_colours.clear()
					colours.column_colours_playing.clear()

					theme_item = theme_files[theme_number]

					gui.theme_name = theme_item[1]
					colours.lm = False
					colours.__init__()

					load_theme(colours, Path(theme_item[0]))
					tauon.deco.load(colours.deco)
					logging.info(f"Applying theme: {gui.theme_name}")

					if colours.lm:
						gui.info_icon.colour = ColourRGBA(60, 60, 60, 255)
					else:
						gui.info_icon.colour = ColourRGBA(61, 247, 163, 255)

					if colours.lm:
						gui.folder_icon.colour = ColourRGBA(255, 190, 80, 255)
					else:
						gui.folder_icon.colour = ColourRGBA(244, 220, 66, 255)

					if colours.lm:
						gui.settings_icon.colour = ColourRGBA(85, 187, 250, 255)
					else:
						gui.settings_icon.colour = ColourRGBA(232, 200, 96, 255)

					if colours.lm:
						gui.radiorandom_icon.colour = ColourRGBA(120, 200, 120, 255)
					else:
						gui.radiorandom_icon.colour = ColourRGBA(153, 229, 133, 255)

				except Exception:
					logging.exception("Error loading theme file")
					tauon.show_message(_("Error loading theme file"), "", mode="warning")

			if prefs.theme == 0:
				gui.theme_name = "Mindaro"
				logging.info("Applying default theme: Mindaro")
				colours.lm = False
				colours.__init__()
				colours.post_config()
				tauon.deco.unload()

			if prefs.transparent_mode:
				colours.apply_transparency()

			prefs.theme_name = gui.theme_name

			# logging.info("Theme number: " + str(prefs.theme))
			gui.reload_theme = False
			ddt.text_background_colour = colours.playlist_panel_background

		# ---------------------------------------------------------------------------------------------------------
		# GUI DRAWING------
		# logging.info(gui.update)
		# logging.info(gui.lowered)
		if gui.mode == GuiMode.MINI:
			gui.pl_update = 0

		if gui.pl_update and not gui.update:
			gui.update = 1

		if gui.update > 0 and not resize_mode:
			gui.update = min(gui.update, 2)
			tauon.gall_ren.new_frame()
			ddt.new_frame()

			if reset_render:
				logging.info("Reset render targets!")
				tauon.clear_img_cache(delete_disk=False)
				ddt.clear_text_cache()
				tauon.destroy_corner_textures()
				for item in WhiteModImageAsset.assets:
					item.reload()
				reset_render = False

			sdl3.SDL_SetRenderTarget(renderer, None)
			sdl3.SDL_SetRenderDrawBlendMode(renderer, sdl3.SDL_BLENDMODE_NONE)
			sdl3.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0)
			sdl3.SDL_RenderClear(renderer)
			sdl3.SDL_SetRenderDrawBlendMode(renderer, sdl3.SDL_BLENDMODE_BLEND)
			sdl3.SDL_RenderClear(renderer)
			sdl3.SDL_SetRenderTarget(renderer, gui.main_texture)
			sdl3.SDL_RenderClear(renderer)

			# tauon.perf_timer.set()
			gui.update_on_drag = False
			gui.pl_update_on_drag = False

			# inp.mouse_position[0], inp.mouse_position[1] = tauon.input_sdl.mouse()
			gui.showed_title = False

			if (
				not gui.ext_drop_mode
				and not gui.mouse_in_window
				and not tauon.bottom_bar1.volume_bar_being_dragged
				and not tauon.bottom_bar1.volume_hit
				and not tauon.bottom_bar1.seek_hit
			):
				inp.mouse_position[0] = -300.0
				inp.mouse_position[1] = -300.0

			if gui.clear_image_cache_next:
				gui.clear_image_cache_next -= 1
				tauon.album_art_gen.clear_cache()
				tauon.style_overlay.radio_meta = None
				if prefs.art_bg:
					tauon.thread_manager.ready("style")

			tauon.fields.clear()
			gui.cursor_want = 0

			gui.layer_focus = 0

			if inp.mouse_click or inp.mouse_wheel or inp.right_click:
				inp.mouse_position[0], inp.mouse_position[1] = tauon.input_sdl.mouse()

			if inp.mouse_click:
				n_click_time = time.time()
				if n_click_time - gui.click_time < 0.42:
					inp.d_mouse_click = True
				gui.click_time = n_click_time

				# Don't register bottom level click when closing message box
				if (
					gui.message_box
					and pref_box.enabled
					and not inp.key_focused
					and not tauon.coll(tauon.message_box.get_rect())
				):
					inp.mouse_click = False
					gui.message_box = False

			# Enable the garbage collector (since we disabled it during startup)
			if ggc > 0:
				if ggc == 2:
					ggc = 1
				elif ggc == 1:
					ggc = 0
					gbc.enable()
					# logging.info("Enabling garbage collecting")

			# Custom Layout System: handle edit/interaction input early and consume
			# the events so the underlying UI doesn't also react. Inert when off.
			# MAIN mode only: the mouse it neutralises is restored in
			# custom.render(), which mini mode never reaches — running here in
			# mini mode left the mini modes with no mouse input at all.
			if gui.custom_mode and gui.mode == GuiMode.MAIN:
				tauon.custom.handle_input()

			# Dream Room: keep muting the mouse every frame (motion-only frames
			# skip the input block above), so hover doesn't leak to the hidden
			# full-size UI. Click/key exit is handled early, before menu consumers.
			if tauon.dream_room.active:
				tauon.dream_room.handle_input()

			if tauon.milk_choose.active:
				tauon.milk_choose.handle_input()

			if gui.mode == GuiMode.MAIN:
				ddt.text_background_colour = colours.playlist_panel_background
				playlist_render.update_album_rating_hover()

				# Side Bar Draging----------

				if not inp.mouse_down:
					gui.side_drag = False

				side_drag_x = gui.rsp_split_x - 5 * gui.scale
				if not gui.rsp_on_left:
					side_drag_x = gui.rsp_split_x + 4 * gui.scale
					if prefs.scroll_enable:
						side_drag_x = gui.rsp_split_x - 1 * gui.scale
				rect = (
					side_drag_x,
					gui.panelY,
					12 * gui.scale,
					window_size[1] - gui.panelY - gui.panelBY,
				)
				tauon.fields.add(rect)
				tracklist_scroll_top = gui.panelY
				if gui.artist_info_panel:
					tracklist_scroll_top += gui.artist_panel_height
				if gui.set_bar and gui.set_mode:
					# Match render_tracklist_scrollbar: the bar starts below the
					# columns header, so its side-drag-blocking hitbox must too.
					tracklist_scroll_top += gui.set_height
				tracklist_scrollbar_width = 15 * gui.scale
				if gui.set_mode and prefs.left_align_album_artist_title:
					tracklist_scrollbar_width = 11 * gui.scale
				tracklist_scroll_hitbox_width = 28 * gui.scale
				if prefs.tracklist_scrollbar_left:
					tracklist_scrollbar_x = gui.playlist_left + 1 * gui.scale
					tracklist_scroll_hitbox_left = tracklist_scrollbar_x - 1 * gui.scale
				else:
					tracklist_scrollbar_x = gui.playlist_left + gui.plw - tracklist_scrollbar_width - 3 * gui.scale
					tracklist_scroll_hitbox_left = tracklist_scrollbar_x + tracklist_scrollbar_width + 1 * gui.scale - tracklist_scroll_hitbox_width
				tracklist_scroll_hitbox = (
					tracklist_scroll_hitbox_left,
					tracklist_scroll_top,
					tracklist_scroll_hitbox_width,
					window_size[1] - gui.panelBY - tracklist_scroll_top,
				)
				scroll_bar_held = (
					scroll_hold
					or tauon.mini_lyrics_scroll.held
					or tauon.playlist_panel_scroll.held
					or tauon.artist_info_scroll.held
					or tauon.device_scroll.held
					or tauon.artist_list_scroll.held
					or tauon.gallery_scroll.held
					or tauon.tree_view_scroll.held
					or tauon.radio_view_scroll.held
				)
				scroll_bar_suppressed_by_album_rating = update_tracklist_scrollbar_lock(tracklist_scroll_hitbox)
				scroll_bar_blocks_side_drag = (
					scroll_bar_held
					or (tauon.coll(tracklist_scroll_hitbox) and not scroll_bar_suppressed_by_album_rating)
				)
				if scroll_bar_held:
					gui.side_drag = False

				if (
					(tauon.coll(rect) or gui.side_drag is True)
					and tauon.rename_track_box.active is False
					and radiobox.active is False
					and gui.rename_playlist_box is False
					and gui.message_box is False
					and prefs.show_nag is False
					and pref_box.enabled is False
					and gui.track_box is False
					and not gui.rename_folder_box
					and not gui.timed_lyrics_editing_now
					and not Menu.active
					and (gui.rsp or prefs.album_mode)
					and not scroll_bar_blocks_side_drag
					and gui.layer_focus == 0
					and gui.show_playlist
				):
					if gui.side_drag is True:
						draw_sep_hl = True
						# gui.update += 1
						gui.update_on_drag = True

					if inp.mouse_click:
						gui.side_drag = True
						gui.side_bar_drag_source = inp.mouse_position[0]
						gui.side_bar_drag_original = gui.rspw

					if not inp.quick_drag:
						gui.cursor_want = 1

				# side drag update
				if gui.side_drag:
					if gui.rsp_on_left:
						offset = inp.mouse_position[0] - gui.side_bar_drag_source
					else:
						offset = gui.side_bar_drag_source - inp.mouse_position[0]

					target = gui.side_bar_drag_original + offset

					# Snap to album mode position if close
					if not prefs.album_mode and prefs.side_panel_layout == 1:
						if abs(target - gui.pref_gallery_w) < 35 * gui.scale:
							target = gui.pref_gallery_w

					# Reset max ratio if drag drops below ratio width
					if prefs.side_panel_layout == 0:
						if target < round((window_size[1] - gui.panelY - gui.panelBY) * gui.art_aspect_ratio):
							gui.art_max_ratio_lock = gui.art_aspect_ratio

						max_w = round(
							((window_size[1] - gui.panelY - gui.panelBY - 17 * gui.scale) * gui.art_max_ratio_lock)
							+ 17 * gui.scale
						)
						# 17 here is the art box inset value

					else:
						max_w = window_size[0]

					if not prefs.album_mode and target > max_w - 12 * gui.scale:
						target = max_w
						gui.rspw = target
						gui.rsp_full_lock = True

					else:
						gui.rspw = target
						gui.rsp_full_lock = False

					if prefs.album_mode:
						pass
						# gui.rspw = target

					if prefs.album_mode and gui.rspw < tauon.album_mode_art_size + 50 * gui.scale:
						target = tauon.album_mode_art_size + 50 * gui.scale

					# Prevent side bar getting too small
					target = max(target, 120 * gui.scale)

					# Remember size for this view mode
					if not prefs.album_mode:
						gui.pref_rspw = target
					else:
						gui.pref_gallery_w = target

					tauon.update_layout_do()

				# ALBUM GALLERY RENDERING:
				# Gallery view
				# C-AR

				if prefs.album_mode and not gui.custom_mode:
					# In custom mode the Album Gallery widget calls render_gallery()
					# itself (with the geometry vars pointed at its segment); running
					# the preset path too would fight it — each call recomputes
					# row_len from different geometry, so gui.last_row ping-pongs and
					# goto_album() re-locates (and resets the scroll) every frame.
					render_gallery()

				# End of gallery view
				# --------------------------------------------------------------------------
				# Main Playlist:
				if len(tauon.load_orders) > 0:
					for i, order in enumerate(tauon.load_orders):
						if order.stage == 2:
							target_pl = 0

							# Sort the tracks by track number
							tauon.sort_track_2(None, order.tracks)

							for p, playlist in enumerate(pctl.multi_playlist):
								if playlist.uuid_int == order.playlist:
									target_pl = p
									break
							else:
								del tauon.load_orders[i]
								logging.error("Target playlist lost")
								break

							if order.replace_stem:
								for ii, id in reversed(list(enumerate(pctl.multi_playlist[target_pl].playlist_ids))):
									pfp = pctl.get_track(id).parent_folder_path
									if pfp.startswith(order.target.replace("\\", "/")):
										if pfp.rstrip("/\\") == order.target.rstrip("/\\") or (
											len(pfp) > len(order.target)
											and pfp[len(order.target.rstrip("/\\"))] in ("/", "\\")
										):
											del pctl.multi_playlist[target_pl].playlist_ids[ii]

							# logging.info(order.tracks)
							if order.playlist_position is not None:
								# logging.info(order.playlist_position)
								pctl.multi_playlist[target_pl].playlist_ids[
									order.playlist_position : order.playlist_position
								] = order.tracks
							# else:

							else:
								pctl.multi_playlist[target_pl].playlist_ids += order.tracks

							pctl.update_shuffle_pool(pctl.multi_playlist[target_pl].uuid_int)

							gui.update += 2
							gui.pl_update += 2
							if order.notify and gui.message_box and len(tauon.load_orders) == 1:
								tauon.show_message(_("Rescan folders complete."), mode="done")
							tauon.reload()
							tauon.tree_view_box.clear_target_pl(target_pl)

							if order.play and order.tracks:
								for p, plst in enumerate(pctl.multi_playlist):
									if order.tracks[0] in plst.playlist_ids:
										target_pl = p
										break

								pctl.switch_playlist(target_pl)

								pctl.active_playlist_playing = pctl.active_playlist_viewing

								# If already in playlist, delete latest add
								if pctl.multi_playlist[target_pl].title == "Default":
									if pctl.default_playlist.count(order.tracks[0]) > 1:
										for q in reversed(range(len(pctl.default_playlist))):
											if pctl.default_playlist[q] == order.tracks[0]:
												del pctl.default_playlist[q]
												break

								pctl.jump(order.tracks[0], pl_position=pctl.default_playlist.index(order.tracks[0]))

								pctl.show_current(True, True, True, True, True)

							del tauon.load_orders[i]

							# Are there more orders for this playlist?
							# If not, decide on a name for the playlist
							for item in tauon.load_orders:
								if item.playlist == order.playlist:
									break
							else:
								if _("New Playlist") in pctl.multi_playlist[target_pl].title:
									tauon.auto_name_pl(target_pl)

								if prefs.auto_sort:
									if pctl.multi_playlist[target_pl].locked:
										tauon.show_message(_("Auto sort skipped because playlist is locked."))
									else:
										logging.info("Auto sorting")
										tauon.standard_sort(target_pl)
										tauon.year_sort(target_pl)

							if not tauon.load_orders:
								pctl.loading_in_progress = False
								pctl.notify_database_changed()
								gui.auto_play_import = False
								gui.album_artist_dict.clear()
							break

				# The custom layout owns the complete main canvas.  Its widgets render
				# their own tracklists, controls and panels below, so do not draw any
				# of the hidden preset playlist chrome first.
				if gui.show_playlist and not gui.custom_mode:
					# playlist hit test
					if (
						tauon.coll(
							(gui.playlist_left, gui.playlist_top, gui.plw, window_size[1] - gui.panelY - gui.panelBY)
						)
						and not inp.drag_mode
						and (
							inp.mouse_click
							or inp.mouse_wheel != 0
							or inp.right_click
							or inp.middle_click
							or inp.mouse_up
							or inp.mouse_down
						)
					):
						gui.pl_update = 1

					if gui.combo_mode and inp.mouse_wheel != 0:
						gui.pl_update = 1

					# MAIN PLAYLIST
					# C-PR

					if not gui.custom_mode:
						render_column_bar_input()

					# heart field test
					if gui.heart_fields:
						for field in gui.heart_fields:
							tauon.fields.add(field, tauon.update_playlist_call)

					if not gui.showcase_mode:
						showcase.timed_lyrics_edit.continuous = False

					if gui.pl_update > 0:
						gui.rendered_playlist_position = pctl.playlist_view_position

						gui.pl_update -= 1
						if gui.combo_mode:
							if gui.radio_view:
								tauon.radio_view.render()
							elif gui.showcase_mode:
								showcase.render()

							# else:
							#     combo_pl_render.full_render()
						else:
							gui.heart_fields.clear()
							ddt.begin_tracklist_count()
							playlist_render.full_render()
							ddt.end_tracklist_count()
					elif gui.combo_mode:
						if gui.radio_view:
							tauon.radio_view.render()
						elif gui.showcase_mode:
							showcase.render()
						# else:
						#     combo_pl_render.cache_render()
					else:
						playlist_render.cache_render()

					rect = (gui.playlist_left, gui.panelY, gui.plw, window_size[1] - (gui.panelBY + gui.panelY))

					if gui.ext_drop_mode and tauon.coll(rect):
						ddt.rect_si(rect, ColourRGBA(80, 200, 180, 255), round(3 * gui.scale))
					tauon.fields.add(rect)

					if prefs.shuffle_lock and inp.key_esc_press and tauon.is_level_zero():
						tauon.toggle_shuffle_layout()
						inp.key_esc_press = False
					elif gui.combo_mode and inp.key_esc_press and tauon.is_level_zero():
						tauon.exit_combo()

					if not gui.custom_mode:
						render_column_bar_draw()

					# Switch Vis:
					if (
						inp.right_click
						and tauon.coll(
							(window_size[0] - 130 * gui.scale - gui.offset_extra, 0, 125 * gui.scale, gui.panelY)
						)
						and not gui.top_bar_mode2
					):
						tauon.vis_menu.activate(
							None, (window_size[0] - 100 * gui.scale - gui.offset_extra, 30 * gui.scale)
						)
					elif (
						inp.right_click
						and tauon.top_panel.tabs_right_x < inp.mouse_position[0]
						and inp.mouse_position[1] < gui.panelY
						and inp.mouse_position[0] > tauon.top_panel.tabs_right_x
						and inp.mouse_position[0] < window_size[0] - 130 * gui.scale - gui.offset_extra
					):
						tauon.window_menu.activate(None, (inp.mouse_position[0], 30 * gui.scale))

					elif (
						inp.middle_click
						and tauon.top_panel.tabs_right_x < inp.mouse_position[0]
						and inp.mouse_position[1] < gui.panelY
						and inp.mouse_position[0] > tauon.top_panel.tabs_right_x
						and inp.mouse_position[0] < window_size[0] - gui.offset_extra
					):
						tauon.do_minimize_button()

					# edge_playlist.render(gui.playlist_left, gui.panelY, gui.plw, 2 * gui.scale)

					tauon.bottom_playlist2.render(
						gui.playlist_left, window_size[1] - gui.panelBY, gui.plw, 25 * gui.scale, bottom=True
					)
					# --------------------------------------------
					# ALBUM ART

					# Right side panel drawing

					# Custom layouts are composited later in the frame and own their
					# complete canvas.  Drawing the preset right-side panel here is not
					# visible, but its ArtBox path clears its target every frame, which
					# breaks MilkDrop's persistent render buffer in a custom widget.
					if gui.rsp and not prefs.album_mode and not gui.custom_mode:
						gui.showing_l_panel = False
						target_track = pctl.show_object()
						rsp_x = gui.rsp_x

						if inp.middle_click:
							if tauon.coll(
								(
									rsp_x,
									gui.panelY,
									gui.rspw,
									window_size[1] - gui.panelY - gui.panelBY,
								)
							):
								if (target_track and target_track.lyrics and prefs.show_lyrics_side) or (
									prefs.show_lyrics_side
									and prefs.prefer_synced_lyrics
									and target_track is not None
									and tauon.timed_lyrics_ren.generate(target_track)
								):
									prefs.show_lyrics_side ^= True
									prefs.side_panel_layout = 1
								elif prefs.side_panel_layout == 0:
									if (target_track and target_track.lyrics and not prefs.show_lyrics_side) or (
										prefs.prefer_synced_lyrics
										and target_track is not None
										and tauon.timed_lyrics_ren.generate(target_track)
									):
										prefs.show_lyrics_side = True
										prefs.side_panel_layout = 1
									else:
										prefs.side_panel_layout = 1
								else:
									prefs.side_panel_layout = 0

						if (
							prefs.show_lyrics_side
							and prefs.prefer_synced_lyrics
							and target_track is not None
							and tauon.timed_lyrics_ren.generate(target_track)
						):
							if prefs.show_side_lyrics_art_panel:
								gui.l_panel_h = round(200 * gui.scale)
								gui.l_panel_y = window_size[1] - (gui.panelBY + gui.l_panel_h)
								gui.showing_l_panel = True

								if not prefs.lyric_metadata_panel_top:
									tauon.timed_lyrics_ren.render(
										target_track.index,
										rsp_x + 9 * gui.scale,
										gui.panelY,
										side_panel=True,
										w=gui.rspw,
										h=window_size[1] - gui.panelY - gui.panelBY - gui.l_panel_h,
									)
									meta_box.l_panel(rsp_x, gui.l_panel_y, gui.rspw, gui.l_panel_h, target_track)
								else:
									tauon.timed_lyrics_ren.render(
										target_track.index,
										rsp_x + 9 * gui.scale,
										gui.panelY + gui.l_panel_h,
										side_panel=True,
										w=gui.rspw,
										h=window_size[1] - gui.panelY - gui.panelBY - gui.l_panel_h,
									)
									meta_box.l_panel(rsp_x, gui.panelY, gui.rspw, gui.l_panel_h, target_track)
							else:
								tauon.timed_lyrics_ren.render(
									target_track.index,
									rsp_x + 9 * gui.scale,
									gui.panelY,
									side_panel=True,
									w=gui.rspw,
									h=window_size[1] - gui.panelY - gui.panelBY,
								)

								if inp.right_click and tauon.coll(
									(
										rsp_x,
										gui.panelY,
										gui.rspw,
										window_size[1] - (gui.panelBY + gui.panelY),
									)
								):
									center_info_menu.activate(target_track)
						elif (
							prefs.show_lyrics_side
							and target_track is not None
							and target_track.lyrics
							and gui.rspw > 192 * gui.scale
						):
							if prefs.show_side_lyrics_art_panel:
								gui.l_panel_h = round(200 * gui.scale)
								gui.l_panel_y = window_size[1] - (gui.panelBY + gui.l_panel_h)
								gui.showing_l_panel = True

								if not prefs.lyric_metadata_panel_top:
									meta_box.lyrics(
										rsp_x,
										gui.panelY,
										gui.rspw,
										window_size[1] - gui.panelY - gui.panelBY - gui.l_panel_h,
										target_track,
									)
									meta_box.l_panel(rsp_x, gui.l_panel_y, gui.rspw, gui.l_panel_h, target_track)
								else:
									meta_box.lyrics(
										rsp_x,
										gui.panelY + gui.l_panel_h,
										gui.rspw,
										window_size[1] - (gui.panelY + gui.panelBY + gui.l_panel_h),
										target_track,
									)

									meta_box.l_panel(
										rsp_x,
										gui.panelY,
										gui.rspw,
										gui.l_panel_h,
										target_track,
										top_border=False,
									)
							else:
								meta_box.lyrics(
									rsp_x,
									gui.panelY,
									gui.rspw,
									window_size[1] - gui.panelY - gui.panelBY,
									target_track,
								)

						elif prefs.side_panel_layout == 0:
							boxw = gui.rspw
							boxh = gui.rspw

							if prefs.show_side_art:
								meta_box.draw(
									rsp_x,
									gui.panelY + boxh,
									gui.rspw,
									window_size[1] - gui.panelY - gui.panelBY - boxh,
									track=target_track,
								)

								boxh = min(boxh, window_size[1] - gui.panelY - gui.panelBY)

								tauon.art_box.draw(rsp_x, gui.panelY, boxw, boxh, target_track=target_track)

							else:
								meta_box.draw(
									rsp_x,
									gui.panelY,
									gui.rspw,
									window_size[1] - gui.panelY - gui.panelBY,
									track=target_track,
								)

						elif prefs.side_panel_layout == 1:
							h = window_size[1] - (gui.panelY + gui.panelBY)
							x = rsp_x
							y = gui.panelY
							w = gui.rspw

							ddt.clear_rect((x, y, w, h))
							ddt.rect((x, y, w, h), colours.side_panel_background)
							tauon.test_auto_lyrics(target_track)
							# Draw lyrics if available
							if (
								prefs.show_lyrics_side and target_track and target_track.lyrics
							):  # and not prefs.show_side_art:
								# meta_box.lyrics(x, y, w, h, target_track)
								if inp.right_click and tauon.coll((x, y, w, h)) and target_track:
									center_info_menu.activate(target_track)
							else:
								box_wide_w = round(w * 0.98)
								boxx = round(min(h * 0.7, w * 0.9))
								boxy = round(min(h * 0.7, w * 0.9))

								bx = (x + w // 2) - (boxx // 2)
								bx_wide = (x + w // 2) - (box_wide_w // 2)
								by = round(h * 0.1)

								bby = by + boxy

								# We want the text in the center, but slightly raised when area is large
								text_y = (
									y
									+ by
									+ boxy
									+ ((h - bby) // 2)
									- 44 * gui.scale
									- round((h - bby - 94 * gui.scale) * 0.08)
								)

								small_mode = False
								if window_size[1] < 550 * gui.scale:
									small_mode = True
									text_y = y + by + boxy + ((h - bby) // 2) - 38 * gui.scale

								text_x = x + w // 2

								if prefs.show_side_art:
									gui.art_drawn_rect = None
									default_border = (bx, by, boxx, boxy)
									coll_border = default_border

									tauon.art_box.draw(
										bx_wide,
										by,
										box_wide_w,
										boxy,
										target_track=target_track,
										tight_border=True,
										default_border=default_border,
									)

									if gui.art_drawn_rect:
										coll_border = gui.art_drawn_rect

									if inp.right_click and tauon.coll((x, y, w, h)) and not tauon.coll(coll_border):
										if tauon.is_level_zero(include_menus=False) and target_track:
											center_info_menu.activate(target_track)

								else:
									text_y = y + round(h * 0.40)
									if inp.right_click and tauon.coll((x, y, w, h)) and target_track:
										center_info_menu.activate(target_track)

								ww = w - 25 * gui.scale

								gui.showed_title = True

								if target_track:
									ddt.text_background_colour = colours.side_panel_background

									if pctl.playing_state == PlayingState.URL_STREAM and not radiobox.dummy_track.title:
										title = pctl.tag_meta
									else:
										title = target_track.title
										if not title:
											title = clean_string(target_track.filename)

									if small_mode:
										ddt.text(
											(text_x, text_y - 15 * gui.scale, 2),
											target_track.artist,
											colours.side_bar_line1,
											315,
											max_w=ww,
										)

										ddt.text(
											(text_x, text_y + 12 * gui.scale, 2),
											title,
											colours.side_bar_line1,
											216,
											max_w=ww,
										)

										line = " | ".join(
											filter(None, (target_track.album, target_track.date, target_track.genre))
										)
										ddt.text(
											(text_x, text_y + 35 * gui.scale, 2),
											line,
											colours.side_bar_line2,
											313,
											max_w=ww,
										)

									else:
										ddt.text(
											(text_x, text_y - 15 * gui.scale, 2),
											target_track.artist,
											colours.side_bar_line1,
											317,
											max_w=ww,
										)

										ddt.text(
											(text_x, text_y + 17 * gui.scale, 2),
											title,
											colours.side_bar_line1,
											218,
											max_w=ww,
										)

										line = " | ".join(
											filter(None, (target_track.album, target_track.date, target_track.genre))
										)
										ddt.text(
											(text_x, text_y + 45 * gui.scale, 2),
											line,
											colours.side_bar_line2,
											314,
											max_w=ww,
										)

					# Separation Line Drawing
					if gui.rsp and not gui.custom_mode:
						# Draw Highlight when mouse over
						if draw_sep_hl:
							sep_x = gui.rsp_split_x + 1 * gui.scale
							if gui.rsp_on_left or prefs.scroll_enable:
								sep_x = gui.rsp_split_x - 1 * gui.scale
							ddt.line(
								sep_x,
								gui.panelY + 1 * gui.scale,
								sep_x,
								window_size[1] - 50 * gui.scale,
								ColourRGBA(100, 100, 100, 70),
							)
							draw_sep_hl = False

				if (gui.artist_info_panel and not gui.combo_mode and not gui.custom_mode) and not (
					window_size[0] < 750 * gui.scale and prefs.album_mode
				):
					tauon.artist_info_box.draw(gui.playlist_left, gui.panelY, gui.plw, gui.artist_panel_height)

				# In custom mode the layout engine renders these panels (queue,
				# playlist list, artist list, folder navigator) as widgets; skip the
				# standard left side panel to avoid double-rendering the same objects.
				if gui.lsp and not gui.combo_mode and not gui.custom_mode:
					# left side panel
					h_estimate = (
						(tauon.playlist_box.tab_h + tauon.playlist_box.gap) * gui.scale * len(pctl.multi_playlist)
					) + 13 * gui.scale
					panel_x = gui.lsp_x

					full = window_size[1] - (gui.panelY + gui.panelBY)
					half = round(full / 2)

					gui.pl_box_h = full

					panel_rect = (panel_x, gui.panelY, gui.lspw, gui.pl_box_h)
					tauon.fields.add(panel_rect)

					if gui.force_side_on_drag and not inp.quick_drag and not tauon.coll(panel_rect):
						gui.force_side_on_drag = False
						tauon.update_layout_do()

					if (
						inp.quick_drag
						and not coll_point(gui.drag_source_position_persist, panel_rect)
						and not point_proximity_test(gui.drag_source_position, inp.mouse_position, 10 * gui.scale)
					):
						gui.force_side_on_drag = True
						if inp.mouse_up:
							tauon.update_layout_do()

					if prefs.left_panel_mode == "folder view" and not gui.force_side_on_drag:
						tauon.tree_view_box.render(panel_x, gui.panelY, gui.lspw, gui.pl_box_h)
					elif prefs.left_panel_mode == "artist list" and not gui.force_side_on_drag:
						tauon.artist_list_box.render(*panel_rect)
					else:
						preview_queue = False
						if (
							inp.quick_drag
							and tauon.coll(panel_rect)
							and not pctl.force_queue
							and prefs.show_playlist_list
							and prefs.hide_queue
						):
							preview_queue = True

						if pctl.force_queue or preview_queue or not prefs.hide_queue:
							if h_estimate < half:
								gui.pl_box_h = h_estimate
							else:
								gui.pl_box_h = half

							if preview_queue:
								gui.pl_box_h = round(full * 5 / 6)

						if prefs.left_panel_mode != "queue":
							tauon.playlist_box.draw(panel_x, gui.panelY, gui.lspw, gui.pl_box_h)
						else:
							gui.pl_box_h = 0

						if pctl.force_queue or preview_queue or not prefs.show_playlist_list or not prefs.hide_queue:
							tauon.queue_box.draw(panel_x, gui.panelY + gui.pl_box_h, gui.lspw, full - gui.pl_box_h)

							if prefs.show_playlist_list and gui.pl_box_h:
								# Separator line at the playlist list / queue seam
								rect = (panel_x, gui.panelY + gui.pl_box_h, gui.lspw, round(gui.scale * 2))
								ddt.rect(rect, ColourRGBA(0, 0, 0, 255))
								ddt.rect(rect, alpha_blend(ColourRGBA(255, 255, 255, 11), colours.queue_background))
						elif prefs.left_panel_mode == "queue":
							text = _("Queue is Empty")
							rect = (panel_x, gui.panelY + gui.pl_box_h, gui.lspw, full - gui.pl_box_h)
							ddt.rect(rect, colours.queue_background)
							ddt.text_background_colour = colours.queue_background
							ddt.text(
								(panel_x + (gui.lspw // 2), gui.panelY + gui.pl_box_h + 15 * gui.scale, 2),
								text,
								alpha_mod(colours.index_text, 200),
								212,
							)

				# ------------------------------------------------
				# Scroll Bar

				# if not prefs.scroll_enable:
				top = gui.panelY
				if gui.artist_info_panel:
					top += gui.artist_panel_height

				edge_top = top
				if gui.set_bar and gui.set_mode:
					edge_top += gui.set_height
				if not gui.custom_mode:
					tauon.edge_playlist2.render(gui.playlist_left, edge_top, gui.plw, 25 * gui.scale)

				if not gui.custom_mode:
					render_tracklist_scrollbar(
						gui.playlist_left, gui.plw, top,
						window_size[1] - gui.panelBY,
						window_size[1] - (30 + 22) * gui.scale)

				# NEW TOP BAR
				# C-TBR

				# In custom mode the layout engine renders the Top Panel widget
				# itself (reframed); skip the standard one to avoid double-rendering
				# the same stateful TopPanel object.
				if gui.mode == GuiMode.MAIN and not gui.custom_mode:
					tauon.top_panel.render()

				# RENDER EXTRA FRAME DOUBLE
				if colours.lm and not gui.custom_mode:
					if gui.lsp and not gui.combo_mode and not gui.compact_artist_list:
						ddt.rect(
							(
								gui.lsp_x + gui.lspw - 6 * gui.scale,
								gui.panelY,
								6 * gui.scale,
								round(window_size[1] - gui.panelY - gui.panelBY),
							),
							colours.grey(200),
						)
						ddt.rect(
							(
								gui.lsp_x + gui.lspw - 5 * gui.scale,
								gui.panelY - 1,
								4 * gui.scale,
								round(window_size[1] - gui.panelY - gui.panelBY) + 1,
							),
							colours.grey(245),
						)
					if gui.rsp and gui.show_playlist:
						w = gui.rsp_split_x
						ddt.rect(
							(
								w - round(3 * gui.scale),
								gui.panelY,
								6 * gui.scale,
								round(window_size[1] - gui.panelY - gui.panelBY),
							),
							colours.grey(200),
						)
						ddt.rect(
							(
								w - round(2 * gui.scale),
								gui.panelY - 1,
								4 * gui.scale,
								round(window_size[1] - gui.panelY - gui.panelBY) + 1,
							),
							colours.grey(245),
						)
					if gui.queue_frame_draw is not None:
						if gui.lsp:
							ddt.rect(
								(gui.lsp_x, gui.queue_frame_draw, gui.lspw - 6 * gui.scale, 6 * gui.scale), colours.grey(200)
							)
							ddt.rect(
								(gui.lsp_x, gui.queue_frame_draw + 1 * gui.scale, gui.lspw - 5 * gui.scale, 4 * gui.scale),
								colours.grey(250),
							)

						gui.queue_frame_draw = None

				# BOTTOM BAR!
				# C-BB

				ddt.text_background_colour = colours.bottom_panel_colour

				# In custom mode the layout engine renders the Playback panel widget
				# itself (reframed); skip the standard bottom bar to avoid
				# double-rendering the same stateful bar object.
				if gui.custom_mode:
					pass
				elif prefs.shuffle_lock:
					tauon.bottom_bar_ao1.render()
				else:
					tauon.bottom_bar1.render()

				if prefs.art_bg:
					tauon.style_overlay.display()
					# if inp.key_shift_down:
					#     ddt.rect_r(gui.seek_bar_rect,
					#                alpha_mod([150, 150, 150 ,255], 20), True)
					#     ddt.rect_r(gui.volume_bar_rect,
					#                alpha_mod(colours.volume_bar_fill, 100), True)

				tauon.style_overlay.hole_punches.clear()

				# Custom Layout System: composite over the standard layout once
				# panel rendering is done. Overlays (toasts, menus, dialogs) below
				# still draw on top. Inert unless custom mode is active.
				if gui.custom_mode:
					tauon.custom.render()

					# The top-panel visualiser is drawn over the custom layout at
					# the window's far right (absolute coords). Its right-click
					# "switch visualiser" menu is normally handled in the standard
					# top-panel input pass, which is skipped in custom mode — and
					# custom.handle_input() neutralised the click earlier in the
					# frame, only restoring it inside custom.render() above. Handle
					# it here (view mode only) so the menu works in custom layouts.
					if (
						not gui.custom_edit
						and inp.right_click
						and tauon.coll(
							(window_size[0] - 130 * gui.scale - gui.offset_extra, 0, 125 * gui.scale, gui.panelY)
						)
						and not gui.top_bar_mode2
					):
						tauon.vis_menu.activate(
							None, (window_size[0] - 100 * gui.scale - gui.offset_extra, 30 * gui.scale)
						)
						inp.right_click = False

				if gui.set_mode:
					if (
						tauon.rename_track_box.active is False
						and radiobox.active is False
						and gui.rename_playlist_box is False
						and gui.message_box is False
						and pref_box.enabled is False
						and gui.track_box is False
						and not gui.rename_folder_box
						and not gui.timed_lyrics_editing_now
						and not Menu.active
						and not tauon.artist_info_scroll.held
					):
						tauon.columns_tool_tip.render()
					else:
						tauon.columns_tool_tip.show = False

				# Overlay GUI ----------------------

				if gui.rename_playlist_box:
					tauon.rename_playlist_box.render()

				if gui.preview_artist:
					border = round(4 * gui.scale)
					ddt.rect(
						(
							gui.preview_artist_location[0] - border,
							gui.preview_artist_location[1] - border,
							tauon.artist_preview_render.size[0] + border * 2,
							tauon.artist_preview_render.size[0] + border * 2,
						),
						ColourRGBA(20, 20, 20, 255),
					)

					tauon.artist_preview_render.draw(gui.preview_artist_location[0], gui.preview_artist_location[1])
					if inp.mouse_click or inp.right_click or inp.mouse_wheel:
						gui.preview_artist = ""

				if gui.track_box:
					if (
						inp.key_return_press
						or inp.right_click
						or inp.key_esc_press
						or inp.backspace_press
						or keymaps.test("quick-find")
					):
						gui.track_box = False

						inp.key_return_press = False

					if gui.level_2_click:
						inp.mouse_click = True
					gui.level_2_click = False

					tc = pctl.master_library[gui.track_box_track_id]

					w = round(540 * gui.scale)
					h = round(240 * gui.scale)
					comment_mode = 0

					if len(tc.comment) > 0:
						h += 22 * gui.scale
						if window_size[0] > 599:
							w += 25 * gui.scale
						if ddt.get_text_w(tc.comment, 12) > 330 * gui.scale or "\n" in tc.comment:
							h += 80 * gui.scale
							if window_size[0] > 599:
								w += 30 * gui.scale
							comment_mode = 1

					x = round((window_size[0] / 2) - (w / 2))
					y = round((window_size[1] / 2) - (h / 2))

					x1 = int(x + 18 * gui.scale)
					x2 = int(x + 98 * gui.scale)

					value_font_a = 312
					value_font = 12

					# if inp.key_shift_down:
					#     value_font = 12
					key_colour_off = colours.box_text_label  # colours.grey_blend_bg(90)
					key_colour_on = colours.box_title_text
					value_colour = colours.box_sub_text
					path_colour = alpha_mod(value_colour, 240)

					# if colours.lm:
					#     key_colour_off = colours.grey(80)
					#     key_colour_on = colours.grey(120)
					#     value_colour = colours.grey(50)
					#     path_colour = colours.grey(70)

					ddt.rect_a(
						(x - 3 * gui.scale, y - 3 * gui.scale),
						(w + 6 * gui.scale, h + 6 * gui.scale),
						colours.box_border,
					)
					ddt.rect_a((x, y), (w, h), colours.box_background)
					ddt.text_background_colour = colours.box_background

					if inp.mouse_click and not tauon.coll([x, y, w, h]):
						gui.track_box = False
					else:
						art_size = int(115 * gui.scale)

						# if not tc.is_network: # Don't draw album art if from network location for better performance
						if comment_mode == 1:
							tauon.album_art_gen.display(
								tc, (int(x + w - 135 * gui.scale), int(y + 105 * gui.scale)), (art_size, art_size)
							)  # Mirror this size in auto theme #mark2233
						else:
							tauon.album_art_gen.display(
								tc, (int(x + w - 135 * gui.scale), int(y + h - 135 * gui.scale)), (art_size, art_size)
							)

						y -= int(24 * gui.scale)
						y1 = int(y + (40 * gui.scale))

						ext_rect = [
							x + w - round(38 * gui.scale),
							y + round(44 * gui.scale),
							round(38 * gui.scale),
							round(12 * gui.scale),
						]

						line = tc.file_ext
						ex_colour = ColourRGBA(130, 130, 130, 255)
						if line in tauon.formats.colours:
							ex_colour = tauon.formats.colours[line]

						if tc.file_ext in ("JELY", "TIDAL"):
							e_colour = ColourRGBA(130, 130, 130, 255)
							if tc.container is not None:
								line = tc.container.upper()
								if line in tauon.formats.colours:
									e_colour = tauon.formats.colours[line]

								ddt.rect(ext_rect, e_colour)
								colour = alpha_blend(ColourRGBA(10, 10, 10, 235), e_colour)
								if colour_value(e_colour) < 180:
									colour = alpha_blend(ColourRGBA(200, 200, 200, 235), e_colour)
								ddt.text(
									(int(x + w - 35 * gui.scale), round(y + (41) * gui.scale)),
									line,
									colour,
									211,
									bg=e_colour,
								)
								ext_rect[1] += 16 * gui.scale
								y += 16 * gui.scale

						ddt.rect(ext_rect, ex_colour)
						colour = alpha_blend(ColourRGBA(10, 10, 10, 235), ex_colour)
						if colour_value(ex_colour) < 180:
							colour = alpha_blend(ColourRGBA(200, 200, 200, 235), ex_colour)
						ddt.text(
							(int(x + w - 35 * gui.scale), round(y + 41 * gui.scale)),
							tc.file_ext,
							colour,
							211,
							bg=ex_colour,
						)

						if tc.is_cue:
							ext_rect[1] += 16 * gui.scale
							colour = ColourRGBA(218, 222, 73, 255)
							if tc.is_embed_cue:
								colour = ColourRGBA(252, 199, 55, 255)
							ddt.rect(ext_rect, colour)
							ddt.text(
								(int(x + w - 35 * gui.scale), int(y + (41 + 16) * gui.scale)),
								"CUE",
								alpha_blend(ColourRGBA(10, 10, 10, 235), colour),
								211,
								bg=colour,
							)

						rect = [x1, y1 + int(2 * gui.scale), 450 * gui.scale, 14 * gui.scale]
						tauon.fields.add(rect)
						if tauon.coll(rect):
							ddt.text((x1, y1), _("Title"), key_colour_on, 212)
							if inp.mouse_click:
								tauon.show_message(_("Copied text to clipboard"))
								copy_to_clipboard(tc.title)
								inp.mouse_click = False
						else:
							ddt.text((x1, y1), _("Title"), key_colour_off, 212)
						q = ddt.text(
							(x2, y1 - int(2 * gui.scale)), tc.title, value_colour, 314, max_w=w - 170 * gui.scale
						)

						if tauon.coll(rect):
							tauon.ex_tool_tip(x2 + 185 * gui.scale, y1, q, tc.title, 314)

						y1 += int(16 * gui.scale)

						rect = [x1, y1 + (2 * gui.scale), 450 * gui.scale, 14 * gui.scale]
						tauon.fields.add(rect)
						if tauon.coll(rect):
							ddt.text((x1, y1), _("Artist"), key_colour_on, 212)
							if inp.mouse_click:
								tauon.show_message(_("Copied text to clipboard"))
								copy_to_clipboard(tc.artist)
								inp.mouse_click = False
						else:
							ddt.text((x1, y1), _("Artist"), key_colour_off, 212)

						q = ddt.text(
							(x2, y1 - (1 * gui.scale)), tc.artist, value_colour, value_font_a, max_w=390 * gui.scale
						)

						if tauon.coll(rect):
							tauon.ex_tool_tip(x2 + 185 * gui.scale, y1, q, tc.artist, value_font_a)

						y1 += int(16 * gui.scale)

						rect = [x1, y1 + (2 * gui.scale), 450 * gui.scale, 14 * gui.scale]
						tauon.fields.add(rect)
						if tauon.coll(rect):
							ddt.text((x1, y1), _("Album"), key_colour_on, 212)
							if inp.mouse_click:
								tauon.show_message(_("Copied text to clipboard"))
								copy_to_clipboard(tc.album)
								inp.mouse_click = False
						else:
							ddt.text((x1, y1), _("Album"), key_colour_off, 212)

						q = ddt.text(
							(x2, y1 - 1 * gui.scale), tc.album, value_colour, value_font_a, max_w=390 * gui.scale
						)

						if tauon.coll(rect):
							tauon.ex_tool_tip(x2 + 185 * gui.scale, y1, q, tc.album, value_font_a)

						y1 += int(26 * gui.scale)

						rect = [x1, y1, 450 * gui.scale, 16 * gui.scale]
						tauon.fields.add(rect)
						path = tc.fullpath
						if tauon.windows:
							path = path.replace("/", "\\")
						if tauon.coll(rect):
							ddt.text((x1, y1), _("Path"), key_colour_on, 212)
							if inp.mouse_click:
								tauon.show_message(_("Copied text to clipboard"))
								copy_to_clipboard(path)
								inp.mouse_click = False
						else:
							ddt.text((x1, y1), _("Path"), key_colour_off, 212)

						q = ddt.text(
							(x2, y1 - int(3 * gui.scale)), clean_string(path), path_colour, 210, max_w=425 * gui.scale
						)

						if tauon.coll(rect):
							gui.frame_callback_list.append(TestTimer(0.71))
							if tauon.track_box_path_tool_timer.get() > 0.7:
								tauon.ex_tool_tip(x2 + 185 * gui.scale, y1, q, clean_string(tc.fullpath), 210)
						else:
							tauon.track_box_path_tool_timer.set()

						y1 += int(15 * gui.scale)

						if tc.samplerate != 0:
							ddt.text((x1, y1), _("Samplerate"), key_colour_off, 212, max_w=70 * gui.scale)

							line = str(tc.samplerate) + " Hz"

							off = ddt.text((x2, y1), line, value_colour, value_font)

							if tc.bit_depth > 0:
								line = str(tc.bit_depth) + " bit"
								ddt.text((x2 + off + 9 * gui.scale, y1), line, value_colour, 311)

						y1 += int(15 * gui.scale)

						if tc.bitrate not in (0, "", "0"):
							ddt.text((x1, y1), _("Bitrate"), key_colour_off, 212, max_w=70 * gui.scale)
							line = str(tc.bitrate)
							if tc.file_ext in ("FLAC", "OPUS", "APE", "WV"):
								line = "≈" + line
							line += " kbps"
							ddt.text((x2, y1), line, value_colour, 312)

						# -----------
						if tc.artist != tc.album_artist:
							x += int(170 * gui.scale)
							rect = [x + 7 * gui.scale, y1 + (2 * gui.scale), 220 * gui.scale, 14 * gui.scale]
							tauon.fields.add(rect)
							if tauon.coll(rect):
								ddt.text((x + (8 + 75) * gui.scale, y1, 1), _("Album Artist"), key_colour_on, 212)
								if inp.mouse_click:
									tauon.show_message(_("Copied text to clipboard"))
									copy_to_clipboard(tc.album_artist)
									inp.mouse_click = False
							else:
								ddt.text((x + (8 + 75) * gui.scale, y1, 1), _("Album Artist"), key_colour_off, 212)

							q = ddt.text(
								(x + (8 + 88) * gui.scale, y1),
								tc.album_artist,
								value_colour,
								value_font,
								max_w=120 * gui.scale,
							)
							if tauon.coll(rect):
								tauon.ex_tool_tip(x2 + 185 * gui.scale, y1, q, tc.album_artist, value_font)

							x -= int(170 * gui.scale)

						y1 += int(15 * gui.scale)

						rect = [x1, y1, 150 * gui.scale, 16 * gui.scale]
						tauon.fields.add(rect)
						if tauon.coll(rect):
							ddt.text((x1, y1), _("Duration"), key_colour_on, 212)
							if inp.mouse_click:
								copy_to_clipboard(time.strftime("%M:%S", time.gmtime(tc.length)).lstrip("0"))
								tauon.show_message(_("Copied text to clipboard"))
								inp.mouse_click = False
						else:
							ddt.text((x1, y1), _("Duration"), key_colour_off, 212)
						line = time.strftime("%M:%S", time.gmtime(tc.length))
						ddt.text((x2, y1), line, value_colour, value_font)

						# -----------
						if tc.track_total not in ("", "0"):
							x += int(170 * gui.scale)
							line = str(tc.track_number) + _(" of ") + str(tc.track_total)
							ddt.text((x + (8 + 75) * gui.scale, y1, 1), _("Track"), key_colour_off, 212)
							ddt.text((x + (8 + 88) * gui.scale, y1), line, value_colour, value_font)
							x -= int(170 * gui.scale)

						y1 += int(15 * gui.scale)
						# logging.info(tc.size)
						if tc.is_cue and (tc.parent_length if tc.parent_length is not None else 0) > 0 and (tc.parent_size if tc.parent_size is not None else 0) > 0:
							ddt.text((x1, y1), _("File size"), key_colour_off, 212, max_w=70 * gui.scale)
							estimate = (tc.length / tc.parent_length) * tc.parent_size
							line = f"≈{get_filesize_string(estimate, rounding=0)} / {get_filesize_string(tc.parent_size)}"
							ddt.text((x2, y1), line, value_colour, value_font)

						elif tc.size != 0:
							ddt.text((x1, y1), _("File size"), key_colour_off, 212, max_w=70 * gui.scale)
							ddt.text((x2, y1), get_filesize_string(tc.size), value_colour, value_font)

						# -----------
						if tc.disc_total not in ("", "0", 0):
							x += int(170 * gui.scale)
							line = str(tc.disc_number) + _(" of ") + str(tc.disc_total)
							ddt.text((x + (8 + 75) * gui.scale, y1, 1), _("Disc"), key_colour_off, 212)
							ddt.text((x + (8 + 88) * gui.scale, y1), line, value_colour, value_font)
							x -= int(170 * gui.scale)

						y1 += int(23 * gui.scale)

						rect = [x1, y1 + (2 * gui.scale), 150 * gui.scale, 14 * gui.scale]
						tauon.fields.add(rect)
						if tauon.coll(rect):
							ddt.text((x1, y1), _("Genre"), key_colour_on, 212)
							if inp.mouse_click:
								tauon.show_message(_("Copied text to clipboard"))
								copy_to_clipboard(tc.genre)
								inp.mouse_click = False
						else:
							ddt.text((x1, y1), _("Genre"), key_colour_off, 212)
						ddt.text((x2, y1), tc.genre, value_colour, value_font, max_w=290 * gui.scale)

						y1 += int(15 * gui.scale)

						rect = [x1, y1 + (2 * gui.scale), 150 * gui.scale, 14 * gui.scale]
						tauon.fields.add(rect)
						if tauon.coll(rect):
							ddt.text((x1, y1), _("Date"), key_colour_on, 212)
							if inp.mouse_click:
								tauon.show_message(_("Copied text to clipboard"))
								copy_to_clipboard(tc.date)
								inp.mouse_click = False
						else:
							ddt.text((x1, y1), _("Date"), key_colour_off, 212)
						ddt.text((x2, y1), d_date_display(tc), value_colour, value_font)

						if tc.composer and tc.composer != tc.artist:
							x += int(170 * gui.scale)
							rect = [x + 7 * gui.scale, y1 + (2 * gui.scale), 220 * gui.scale, 14 * gui.scale]
							tauon.fields.add(rect)
							if tauon.coll(rect):
								ddt.text((x + (8 + 75) * gui.scale, y1, 1), _("Composer"), key_colour_on, 212)
								if inp.mouse_click:
									tauon.show_message(_("Copied text to clipboard"))
									copy_to_clipboard(tc.album_artist)
									inp.mouse_click = False
							else:
								ddt.text((x + (8 + 75) * gui.scale, y1, 1), _("Composer"), key_colour_off, 212)
							q = ddt.text(
								(x + (8 + 88) * gui.scale, y1),
								tc.composer,
								value_colour,
								value_font,
								max_w=120 * gui.scale,
							)
							if tauon.coll(rect):
								tauon.ex_tool_tip(x2 + 185 * gui.scale, y1, q, tc.composer, value_font_a)

							x -= int(170 * gui.scale)

						y1 += int(23 * gui.scale)

						total = tauon.star_store.get(gui.track_box_track_id)

						ratio = 0

						if total > 0 and pctl.master_library[gui.track_box_track_id].length > 1:
							ratio = total / (tc.length - 1)

						ddt.text((x1, y1), _("Play count"), key_colour_off, 212, max_w=70 * gui.scale)
						ddt.text((x2, y1), str(int(ratio)), value_colour, value_font)

						y1 += int(15 * gui.scale)

						rect = [x1, y1, 150, 14]

						if tauon.coll(rect) and inp.key_shift_down and inp.mouse_wheel != 0:
							tauon.star_store.add(gui.track_box_track_id, 60 * inp.mouse_wheel)

						line = time.strftime("%H:%M:%S", time.gmtime(total))

						ddt.text((x1, y1), _("Play time"), key_colour_off, 212, max_w=70 * gui.scale)
						ddt.text((x2, y1), str(line), value_colour, value_font)

						# -------
						if tc.lyrics:
							if pctl.draw.button(_("Lyrics"), x1 + 200 * gui.scale, y1 - 10 * gui.scale):
								prefs.show_lyrics_showcase = True
								gui.track_box = False
								tauon.enter_showcase_view(track_id=gui.track_box_track_id)
								inp.mouse_click = False

						if len(tc.comment) > 0:
							y1 += 20 * gui.scale
							rect = [x1, y1 + (2 * gui.scale), 60 * gui.scale, 14 * gui.scale]
							# ddt.rect_r((x2, y1, 335, 10), [255, 20, 20, 255])
							tauon.fields.add(rect)
							if tauon.coll(rect):
								ddt.text((x1, y1), _("Comment"), key_colour_on, 212)
								if inp.mouse_click:
									tauon.show_message(_("Copied text to clipboard"))
									copy_to_clipboard(tc.comment)
									inp.mouse_click = False
							else:
								ddt.text((x1, y1), _("Comment"), key_colour_off, 212)
							# ddt.draw_text((x1, y1), "Comment", key_colour_off, 12)

							if (
								"\n" not in tc.comment
								and ("http://" in tc.comment or "www." in tc.comment or "https://" in tc.comment)
								and ddt.get_text_w(tc.comment, 12) < 335 * gui.scale
							):
								link_pa = tauon.draw_linked_text((x2, y1), tc.comment, value_colour, 12)
								link_rect = [
									x + 98 * gui.scale + link_pa[0],
									y1 - 2 * gui.scale,
									link_pa[1],
									20 * gui.scale,
								]

								tauon.fields.add(link_rect)
								if tauon.coll(link_rect):
									if not inp.mouse_click:
										gui.cursor_want = 3
									if inp.mouse_click:
										webbrowser.open(link_pa[2], new=2, autoraise=True)
										gui.track_box = True

							elif comment_mode == 1:
								ddt.text(
									(x + 18 * gui.scale, y1 + 18 * gui.scale, 4, w - 36 * gui.scale, 90 * gui.scale),
									tc.comment,
									value_colour,
									12,
								)
							else:
								ddt.text((x2, y1), tc.comment, value_colour, 12)

				if tauon.draw_border and gui.mode != GuiMode.MINI:
					tool_rect = [window_size[0] - 110 * gui.scale, 2, 95 * gui.scale, 45 * gui.scale]
					if prefs.left_window_control:
						tool_rect[0] = 0
					tauon.fields.add(tool_rect)
					if not gui.top_bar_mode2 or tauon.coll(tool_rect):
						tauon.draw_window_tools()

					if not gui.fullscreen and not gui.maximized:
						tauon.draw_window_border()

				tauon.fader.render()
				if pref_box.enabled:
					# rect = [0, 0, window_size[0], window_size[1]]
					# ddt.rect_r(rect, [0, 0, 0, 90], True)
					pref_box.render()
				elif not Path(tauon.prefs.playlist_folder_path).is_dir():
					tauon.prefs.playlist_folder_path = ""
					prefs.autoscan_playlist_folder = False

				if gui.rename_folder_box:
					if gui.level_2_click:
						inp.mouse_click = True

					gui.level_2_click = False

					w = 500 * gui.scale
					h = 127 * gui.scale
					x = int(window_size[0] / 2) - int(w / 2)
					y = int(window_size[1] / 2) - int(h / 2)

					ddt.rect_a(
						(x - 2 * gui.scale, y - 2 * gui.scale),
						(w + 4 * gui.scale, h + 4 * gui.scale),
						colours.box_border,
					)
					ddt.rect_a((x, y), (w, h), colours.box_background)

					ddt.text_background_colour = colours.box_background

					if inp.key_esc_press or (
						(inp.mouse_click or inp.right_click or inp.level_2_right_click) and not tauon.coll((x, y, w, h))
					):
						gui.rename_folder_box = False

					p = ddt.text(
						(x + 10 * gui.scale, y + 9 * gui.scale), _("Folder Modification"), colours.box_title_text, 213
					)
					input_h = 23 * gui.scale

					if tauon.rename_folder.text != prefs.rename_folder_template and pctl.draw.button(
						_("Default"), x + (300 - 63) * gui.scale, y + 11 * gui.scale, 70 * gui.scale, input_h
					):
						tauon.rename_folder.set_text(prefs.rename_folder_template)
						tauon.rename_folder.offset = 0

					tauon.rename_folder.draw(
						x + 14 * gui.scale,
						y + 41 * gui.scale,
						colours.box_input_text,
						width=300 * gui.scale,
					)

					ddt.rect_s(
						(x + 8 * gui.scale, y + 38 * gui.scale, 300 * gui.scale, 22 * gui.scale),
						colours.box_text_border,
						1 * gui.scale,
					)

					if (
						pctl.draw.button(
							_("Rename"),
							x + (8 + 300 + 10) * gui.scale,
							y + 38 * gui.scale,
							80 * gui.scale,
							input_h,
							tooltip=_("Renames the physical folder based on the template"),
						)
						or inp.level_2_enter
					):
						tauon.rename_parent(gui.rename_index, tauon.rename_folder.text)
						gui.rename_folder_box = False
						inp.mouse_click = False

					text = _("Trash")
					tt = _("Moves folder to system trash")
					if inp.key_shift_down:
						text = _("Delete")
						tt = _("Physically deletes folder from disk")
					if pctl.draw.button(
						text,
						x + (8 + 300 + 10) * gui.scale,
						y + 11 * gui.scale,
						80 * gui.scale,
						input_h,
						text_highlight_colour=colours.grey(255),
						background_highlight_colour=ColourRGBA(180, 60, 60, 255),
						press=inp.mouse_up,
						tooltip=tt,
					):
						if inp.key_shift_down:
							tauon.delete_folder(gui.rename_index, True)
						else:
							tauon.delete_folder(gui.rename_index)
						gui.rename_folder_box = False
						inp.mouse_click = False

					if tauon.move_folder_up(gui.rename_index):
						if pctl.draw.button(
							_("Raise"),
							x + 408 * gui.scale,
							y + 38 * gui.scale,
							80 * gui.scale,
							tooltip=_("Moves folder up 2 levels and deletes the old container folder"),
						):
							tauon.move_folder_up(gui.rename_index, True)
							inp.mouse_click = False

					to_clean = tauon.clean_folder(gui.rename_index)
					if to_clean > 0:
						if pctl.draw.button(
							"Clean (" + str(to_clean) + ")",
							x + 408 * gui.scale,
							y + 11 * gui.scale,
							80 * gui.scale,
							tooltip=_("Deletes some unnecessary files from folder"),
						):
							tauon.clean_folder(gui.rename_index, True)
							inp.mouse_click = False

					ddt.text((x + 10 * gui.scale, y + 65 * gui.scale), _("PATH"), colours.box_text_label, 212)
					line = (
						os.path.dirname(pctl.master_library[gui.rename_index].parent_folder_path.rstrip("\\/")).replace(
							"\\", "/"
						)
						+ "/"
					)
					line = tauon.right_trunc(line, 12, 420 * gui.scale)
					line = clean_string(line)
					ddt.text((x + 60 * gui.scale, y + 65 * gui.scale), line, colours.grey(220), 211)

					ddt.text((x + 10 * gui.scale, y + 83 * gui.scale), _("OLD"), colours.box_text_label, 212)
					line = pctl.master_library[gui.rename_index].parent_folder_name
					line = clean_string(line)
					ddt.text(
						(x + 60 * gui.scale, y + 83 * gui.scale), line, colours.grey(220), 211, max_w=420 * gui.scale
					)

					ddt.text((x + 10 * gui.scale, y + 101 * gui.scale), _("NEW"), colours.box_text_label, 212)
					line = parse_template2(tauon.rename_folder.text, pctl.master_library[gui.rename_index])
					ddt.text(
						(x + 60 * gui.scale, y + 101 * gui.scale), line, colours.grey(220), 211, max_w=420 * gui.scale
					)

				if tauon.rename_track_box.active:
					tauon.rename_track_box.render()

				if tauon.sub_lyrics_box.active:
					tauon.sub_lyrics_box.render()

				if tauon.export_playlist_box.active:
					tauon.export_playlist_box.render()

				if tauon.trans_edit_box.active:
					tauon.trans_edit_box.render()

				if radiobox.active:
					radiobox.render()

				tauon.milk_choose.render()

				if gui.message_box:
					tauon.message_box.render()

				tauon.preset_download_box.render()

				if prefs.show_nag:
					tauon.nagbox.draw()

				# SEARCH
				# if inp.key_ctrl_down and inp.key_v_press:
				# 	tauon.search_over.active = True

				tauon.search_over.render()
				search_over = tauon.search_over

				if keymaps.test("quick-find") and gui.quick_search_mode is False:
					if not tauon.search_over.active and not gui.box_over:
						gui.quick_search_mode = True
					if tauon.search_clear_timer.get() > 3:
						search_over.search_text.text = ""
					inp.input_text = ""
				elif (keymaps.test("quick-find") or (inp.key_esc_press and len(gui.editline) == 0)) or (
					inp.mouse_click and gui.quick_search_mode is True
				):
					gui.quick_search_mode = False
					search_over.search_text.text = ""

				# if (key_backslash_press or (inp.key_ctrl_down and key_f_press)) and gui.quick_search_mode is False:
				# 	if not tauon.search_over.active:
				# 		gui.quick_search_mode = True
				# 	if tauon.search_clear_timer.get() > 3:
				# 		search_over.search_text.text = ""
				# 	input_text = ""
				# elif ((key_backslash_press or (inp.key_ctrl_down and key_f_press)) or (
				# 		inp.key_esc_press and len(gui.editline) == 0)) or input.mouse_click and gui.quick_search_mode is True:
				# 	gui.quick_search_mode = False
				# 	search_over.search_text.text = ""

				if gui.quick_search_mode is True:
					rect2 = [0, window_size[1] - 85 * gui.scale, 420 * gui.scale, 25 * gui.scale]
					rect = [0, window_size[1] - 125 * gui.scale, 420 * gui.scale, 65 * gui.scale]
					rect[0] = int(window_size[0] / 2) - int(rect[2] / 2)
					rect2[0] = rect[0]

					ddt.rect(
						(rect[0] - 2, rect[1] - 2, rect[2] + 4, rect[3] + 4), colours.box_border
					)  # [220, 100, 5, 255]
					# ddt.rect_r((rect[0], rect[1], rect[2], rect[3]), [255,120,5,255], True)

					ddt.text_background_colour = colours.box_background
					# ddt.text_background_colour = ColourRGBA(255,120,5,255)
					# ddt.text_background_colour = ColourRGBA(220,100,5,255)
					ddt.rect(rect, colours.box_background)

					if len(inp.input_text) > 0:
						gui.search_index = -1

					if inp.backspace_press and search_over.search_text.text == "":
						gui.quick_search_mode = False

					if len(search_over.search_text.text) == 0:
						gui.search_error = False

					if len(search_over.search_text.text) != 0 and search_over.search_text.text[0] == "/":
						# if "/love" in search_over.search_text.text:
						#     line = "last.fm loved tracks from user. Format: /love <username>"
						# else:
						line = _("Folder filter mode. Enter path segment.")
						ddt.text(
							(rect[0] + 23 * gui.scale, window_size[1] - 87 * gui.scale),
							line,
							ColourRGBA(220, 220, 220, 100),
							312,
						)
					else:
						line = _("UP / DOWN to navigate. SHIFT + RETURN for new playlist.")
						if len(search_over.search_text.text) == 0:
							line = _("Quick find")
						ddt.text(
							(rect[0] + int(rect[2] / 2), window_size[1] - 87 * gui.scale, 2),
							line,
							colours.box_text_label,
							312,
						)

						# ddt.draw_text((rect[0] + int(rect[2] / 2), window_size[1] - 118 * gui.scale, 2), "Find",
						#           colours.grey(90), 214)

					# if len(pctl.track_queue) > 0:

					# if inp.input_text == 'A':
					#     search_text.text = pctl.playing_object().artist
					#     inp.input_text = ""

					if gui.search_error:
						ddt.rect([rect[0], rect[1], rect[2], 30 * gui.scale], ColourRGBA(180, 40, 40, 255))
						ddt.text_background_colour = ColourRGBA(
							180, 40, 40, 255
						)  # alpha_blend(ColourRGBA(255,0,0,25), ddt.text_background_colour)
					# if input.backspace_press:
					#     gui.search_error = False

					search_over.search_text.draw(
						rect[0] + 8 * gui.scale, rect[1] + 6 * gui.scale, colours.grey(250), font=213
					)

					if (
						inp.key_shift_down
						or (len(search_over.search_text.text) > 0 and search_over.search_text.text[0] == "/")
					) and inp.key_return_press:
						inp.key_return_press = False
						playlist = []
						if len(search_over.search_text.text) > 0:
							if search_over.search_text.text[0] == "/":
								if (
									search_over.search_text.text.lower() == "/random"
									or search_over.search_text.text.lower() == "/shuffle"
								):
									tauon.gen_500_random(pctl.active_playlist_viewing)
								elif (
									search_over.search_text.text.lower() == "/top"
									or search_over.search_text.text.lower() == "/most"
								):
									tauon.gen_top_100(pctl.active_playlist_viewing)
								elif (
									search_over.search_text.text.lower() == "/length"
									or search_over.search_text.text.lower() == "/duration"
									or search_over.search_text.text.lower() == "/len"
								):
									tauon.gen_sort_len(pctl.active_playlist_viewing)
								else:
									if search_over.search_text.text[-1] == "/":
										tt_title = search_over.search_text.text.replace("/", "")
									else:
										search_over.search_text.text = search_over.search_text.text.replace("/", "")
										tt_title = search_over.search_text.text
									search_over.search_text.text = search_over.search_text.text.lower()
									for item in pctl.default_playlist:
										if (
											search_over.search_text.text
											in pctl.master_library[item].parent_folder_path.lower()
										):
											playlist.append(item)
									if len(playlist) > 0:
										pctl.multi_playlist.append(
											tauon.pl_gen(title=tt_title, playlist_ids=copy.deepcopy(playlist))
										)
										pctl.switch_playlist(len(pctl.multi_playlist) - 1)

							else:
								search_terms = search_over.search_text.text.lower().split()
								for item in pctl.default_playlist:
									tr = pctl.get_track(item)
									line = " ".join(
										[
											tr.title,
											tr.artist,
											tr.album,
											tr.fullpath,
											tr.composer,
											tr.comment,
											tr.album_artist,
											(tr.artist_sort if tr.artist_sort is not None else ""),
										]
									).lower()

									# if prefs.diacritic_search and all([ord(c) < 128 for c in search_over.search_text.text]):
									#     line = str(unidecode(line))

									if all(word in line for word in search_terms):
										playlist.append(item)
								if len(playlist) > 0:
									pctl.multi_playlist.append(
										tauon.pl_gen(title=_("Search Results"), playlist_ids=copy.deepcopy(playlist))
									)
									pctl.gen_codes[pctl.pl_to_id(len(pctl.multi_playlist) - 1)] = (
										's"'
										+ pctl.multi_playlist[pctl.active_playlist_viewing].title
										+ '" f"'
										+ search_over.search_text.text
										+ '"'
									)
									pctl.switch_playlist(len(pctl.multi_playlist) - 1)
							search_over.search_text.text = ""
							gui.quick_search_mode = False

					if (
						(len(inp.input_text) > 0 and not gui.search_error)
						or inp.key_down_press is True
						or inp.backspace_press
						or gui.force_search
					):
						gui.pl_update = 1

						if gui.force_search:
							gui.search_index = 0

						if inp.backspace_press:
							gui.search_index = 0

						if len(search_over.search_text.text) > 0 and search_over.search_text.text[0] != "/":
							oi = gui.search_index

							while gui.search_index < len(pctl.default_playlist) - 1:
								gui.search_index += 1
								if gui.search_index > len(pctl.default_playlist) - 1:
									gui.search_index = 0

								search_terms = search_over.search_text.text.lower().split()
								tr = pctl.get_track(pctl.default_playlist[gui.search_index])
								line = " ".join(
									[
										tr.title,
										tr.artist,
										tr.album,
										tr.fullpath,
										tr.composer,
										tr.comment,
										tr.album_artist,
										(tr.artist_sort if tr.artist_sort is not None else ""),
									]
								).lower()

								# if prefs.diacritic_search and all([ord(c) < 128 for c in search_over.search_text.text]):
								#     line = str(unidecode(line))

								if all(word in line for word in search_terms):
									pctl.selected_in_playlist = gui.search_index
									if len(pctl.default_playlist) > 10 and gui.search_index > 10:
										pctl.playlist_view_position = gui.search_index - 7
										logging.debug("Position changed by search")
									else:
										pctl.playlist_view_position = 0

									if gui.combo_mode:
										pctl.show_selected()
									gui.search_error = False

									break

							else:
								gui.search_index = oi
								if len(inp.input_text) > 0 or gui.force_search:
									gui.search_error = True
								if inp.key_down_press:
									tauon.bottom_playlist2.pulse()

							gui.force_search = False

					if (
						inp.key_up_press is True
						and not inp.key_shiftr_down
						and not inp.key_shift_down
						and not inp.key_ctrl_down
						and not inp.key_rctrl_down
						and not inp.key_meta
						and not inp.key_lalt
						and not inp.key_ralt
					):
						gui.pl_update = 1
						oi = gui.search_index

						while gui.search_index > 1:
							gui.search_index -= 1
							gui.search_index = min(gui.search_index, len(pctl.default_playlist) - 1)
							search_terms = search_over.search_text.text.lower().split()
							line = (
								pctl.master_library[pctl.default_playlist[gui.search_index]].title.lower()
								+ pctl.master_library[pctl.default_playlist[gui.search_index]].artist.lower()
								+ pctl.master_library[pctl.default_playlist[gui.search_index]].album.lower()
								+ pctl.master_library[pctl.default_playlist[gui.search_index]].filename.lower()
							)

							if prefs.diacritic_search and all([ord(c) < 128 for c in search_over.search_text.text]):
								line = str(unidecode(line))

							if all(word in line for word in search_terms):
								pctl.selected_in_playlist = gui.search_index
								if len(pctl.default_playlist) > 10 and gui.search_index > 10:
									pctl.playlist_view_position = gui.search_index - 7
									logging.debug("Position changed by search")
								else:
									pctl.playlist_view_position = 0
								if gui.combo_mode:
									pctl.show_selected()
								break
						else:
							gui.search_index = oi
							tauon.edge_playlist2.pulse()

					if inp.key_return_press is True and gui.search_index > -1:
						gui.pl_update = 1
						pctl.jump(pctl.default_playlist[gui.search_index], gui.search_index)
						if prefs.album_mode:
							tauon.goto_album(pctl.playlist_playing_position)
						gui.quick_search_mode = False
						tauon.search_clear_timer.set()
				elif not tauon.search_over.active:
					if inp.key_up_press and (
						(
							not inp.key_shiftr_down
							and not inp.key_shift_down
							and not inp.key_ctrl_down
							and not inp.key_rctrl_down
							and not inp.key_meta
							and not inp.key_lalt
							and not inp.key_ralt
						)
						or (keymaps.test("shift-up"))
					):
						pctl.show_selected()
						gui.pl_update = 1

						if not keymaps.test("shift-up"):
							if pctl.selected_in_playlist > 0:
								pctl.selected_in_playlist -= 1
							gui.shift_selection = []

						if (
							pctl.playlist_view_position > 0
							and pctl.selected_in_playlist < pctl.playlist_view_position + 2
						):
							pctl.playlist_view_position -= 1
							logging.debug("Position changed by key up")

							tauon.scroll_hide_timer.set()
							gui.frame_callback_list.append(TestTimer(0.9))

						pctl.selected_in_playlist = min(pctl.selected_in_playlist, len(pctl.default_playlist))
						tauon.sync_track_box_to_selected()

					if pctl.selected_in_playlist < len(pctl.default_playlist) and (
						(
							inp.key_down_press
							and not inp.key_shiftr_down
							and not inp.key_shift_down
							and not inp.key_ctrl_down
							and not inp.key_rctrl_down
							and not inp.key_meta
							and not inp.key_lalt
							and not inp.key_ralt
						)
						or keymaps.test("shift-down")
					):
						pctl.show_selected()
						gui.pl_update = 1

						if not keymaps.test("shift-down"):
							if pctl.selected_in_playlist < len(pctl.default_playlist) - 1:
								pctl.selected_in_playlist += 1
							gui.shift_selection = []

						if (
							pctl.playlist_view_position < len(pctl.default_playlist)
							and pctl.selected_in_playlist
							> pctl.playlist_view_position + gui.playlist_view_length - 3 - gui.row_extra
						):
							pctl.playlist_view_position += 1
							logging.debug("Position changed by key down")

							tauon.scroll_hide_timer.set()
							gui.frame_callback_list.append(TestTimer(0.9))

						pctl.selected_in_playlist = max(pctl.selected_in_playlist, 0)
						tauon.sync_track_box_to_selected()

					if (
						inp.key_return_press
						and not pref_box.enabled
						and not radiobox.active
						and not tauon.trans_edit_box.active
						and not gui.timed_lyrics_editing_now
						and not (gui.showcase_mode and gui.timed_lyrics_edit_view)
					):
						gui.pl_update = 1
						if pctl.selected_in_playlist > len(pctl.default_playlist) - 1:
							pctl.selected_in_playlist = 0
							gui.shift_selection = []
						if pctl.default_playlist:
							pctl.jump(pctl.default_playlist[pctl.selected_in_playlist], pctl.selected_in_playlist)
							if prefs.album_mode:
								tauon.goto_album(pctl.playlist_playing_position)
			elif gui.mode == GuiMode.MINI:
				if (inp.key_shift_down and inp.mouse_click) or inp.middle_click:
					if prefs.mini_mode_mode == MiniModeMode.TAB:
						prefs.mini_mode_mode = MiniModeMode.SQUARE
						size = (int(330 * gui.scale), int(330 * gui.scale))
					else:
						prefs.mini_mode_mode = MiniModeMode.TAB
						size = (int(320 * gui.scale), int(90 * gui.scale))

					logical_size[0] = size[0]
					logical_size[1] = size[1]
					window_size[0] = size[0]
					window_size[1] = size[1]

					tauon._set_wayland_mini_mode_window_state(True, size[0], size[1], reset_tracking=False)
					sdl3.SDL_SetWindowMinimumSize(t_window, size[0], size[1])
					sdl3.SDL_SetWindowSize(t_window, size[0], size[1])

				if prefs.mini_mode_mode == MiniModeMode.SLATE:
					tauon.mini_mode3.render()
				elif prefs.mini_mode_mode == MiniModeMode.SIGNAL:
					tauon.mini_mode_signal.render()
				elif prefs.mini_mode_mode == MiniModeMode.TAB:
					tauon.mini_mode2.render()
				else:
					tauon.mini_mode.render()

			t = tauon.toast_love_timer.get()
			if t < 1.8 and gui.toast_love_object is not None:
				track = gui.toast_love_object

				ww = gui.playlist_left or 0

				rect = (ww + 5 * gui.scale, gui.panelY + 5 * gui.scale, 235 * gui.scale, 39 * gui.scale)
				tauon.fields.add(rect)

				if tauon.coll(rect):
					tauon.toast_love_timer.force_set(10)
				else:
					ddt.rect(grow_rect(rect, 2 * gui.scale), colours.box_border)
					ddt.rect(rect, colours.queue_card_background)

					# fqo = copy.copy(pctl.force_queue[-1])

					ddt.text_background_colour = colours.queue_card_background

					if gui.toast_love_added:
						text = _("Loved track")
						gui.heart_notify_icon.render(
							rect[0] + 9 * gui.scale, rect[1] + 8 * gui.scale, ColourRGBA(250, 100, 100, 255)
						)
					else:
						text = _("Un-Loved track")
						gui.heart_notify_break_icon.render(
							rect[0] + 9 * gui.scale, rect[1] + 7 * gui.scale, ColourRGBA(150, 150, 150, 255)
						)

					ddt.text_background_colour = colours.queue_card_background
					ddt.text((rect[0] + 42 * gui.scale, rect[1] + 3 * gui.scale), text, colours.box_text, 313)
					ddt.text(
						(rect[0] + 42 * gui.scale, rect[1] + 20 * gui.scale),
						f"{track.track_number}. {track.artist} - {track.title}".strip(".- "),
						colours.box_text_label,
						13,
						max_w=rect[2] - 50 * gui.scale,
					)

			t = tauon.queue_add_timer.get()
			if t < 2.5 and gui.toast_queue_object:
				track = pctl.get_track(gui.toast_queue_object.track_id)

				ww = gui.playlist_left or 0
				if tauon.search_over.active:
					ww = window_size[0] // 2 - (215 * gui.scale // 2)

				rect = (ww + 5 * gui.scale, gui.panelY + 5 * gui.scale, 215 * gui.scale, 39 * gui.scale)
				tauon.fields.add(rect)

				if tauon.coll(rect):
					tauon.queue_add_timer.force_set(10)
				elif len(pctl.force_queue) > 0:
					fqo = copy.copy(pctl.force_queue[-1])

					ddt.rect(grow_rect(rect, 2 * gui.scale), colours.box_border)
					ddt.rect(rect, colours.queue_card_background)

					ddt.text_background_colour = colours.queue_card_background
					top_text = _("Track")
					if gui.queue_toast_plural:
						top_text = "Album"
						fqo.type = QueueType.ALBUM
					if pctl.force_queue[-1].type == QueueType.ALBUM:
						top_text = "Album"

					tauon.queue_box.draw_card(
						rect[0] - 8 * gui.scale,
						0,
						160 * gui.scale,
						210 * gui.scale,
						rect[1] + 1 * gui.scale,
						track,
						fqo,
						True,
						False,
					)

					ddt.text_background_colour = colours.queue_card_background
					ddt.text(
						(rect[0] + rect[2] - 50 * gui.scale, rect[1] + 3 * gui.scale, 2),
						f"{top_text} added",
						colours.box_text_label,
						11,
					)
					ddt.text(
						(rect[0] + rect[2] - 50 * gui.scale, rect[1] + 15 * gui.scale, 2),
						"to queue",
						colours.box_text_label,
						11,
					)

			t = tauon.toast_mode_timer.get()
			if t < gui.toast_length:
				wid = ddt.get_text_w(gui.mode_toast_text, 313)
				wid = max(round(68 * gui.scale), wid)

				ww = round(7 * gui.scale)
				if gui.playlist_left and not gui.combo_mode:
					ww += gui.playlist_left

				rect = (ww + 8 * gui.scale, gui.panelY + 15 * gui.scale, wid + 20 * gui.scale, 25 * gui.scale)
				tauon.fields.add(rect)

				if tauon.coll(rect):
					tauon.toast_mode_timer.force_set(10)
				else:
					ddt.rect(grow_rect(rect, round(2 * gui.scale)), colours.grey(60))
					ddt.rect(rect, colours.queue_card_background)

					ddt.text_background_colour = colours.queue_card_background
					ddt.text(
						(rect[0] + (rect[2] // 2), rect[1] + 4 * gui.scale, 2),
						gui.mode_toast_text,
						colours.grey(230),
						313,
					)

			# Render Menus-------------------------------
			tauon.draw_popup_menus()

			if tauon.view_box.active:
				tauon.view_box.render()

			tauon.tool_tip.render()
			tauon.tool_tip2.render()

			if tauon.console.show:
				rect = (20 * gui.scale, 40 * gui.scale, 580 * gui.scale, 200 * gui.scale)
				ddt.rect(rect, ColourRGBA(0, 0, 0, 245))

				tauon.console.fps.tick()
				fps_rect = (rect[0] + rect[2] + 8 * gui.scale, rect[1], 70 * gui.scale, 22 * gui.scale)
				ddt.rect(fps_rect, ColourRGBA(0, 0, 0, 245))
				ddt.text(
					(fps_rect[0] + 8 * gui.scale, fps_rect[1] + 4 * gui.scale),
					f"{int(round(tauon.console.fps.get()))} FPS",
					ColourRGBA(120, 120, 120, 255),
					311,
					bg=ColourRGBA(5, 5, 5, 255),
				)

				if pctl.playing_state == PlayingState.PLAYING:
					gui.delay_frame(0.05)

				yy = rect[3] + 15 * gui.scale
				u = False
				for record in reversed(tauon.log.log_history):
					if yy < rect[1] + 5 * gui.scale:
						break

					text_colour = ColourRGBA(60, 255, 60, 255)
					message = tauon.log.format(record)

					t = record.created
					d = time.time() - t
					dt = time.localtime(t)

					fade = 255
					if d > 2:
						fade = 200

					text_colour = ColourRGBA(120, 120, 120, fade)
					if record.levelno == 10:
						text_colour = ColourRGBA(80, 80, 80, fade)
					if record.levelno == 30:
						text_colour = ColourRGBA(230, 190, 90, fade)
					if record.levelno == 40:
						text_colour = ColourRGBA(255, 120, 90, fade)
					if record.levelno == 50:
						text_colour = ColourRGBA(255, 90, 90, fade)

					time_colour = ColourRGBA(255, 80, 160, fade)

					w = ddt.text(
						(rect[0] + 10 * gui.scale, yy),
						time.strftime("%H:%M:%S", dt),
						time_colour,
						311,
						rect[2] - 60 * gui.scale,
						bg=ColourRGBA(5, 5, 5, 255),
					)

					ddt.text(
						(w + rect[0] + 17 * gui.scale, yy),
						message,
						text_colour,
						311,
						rect[2] - 60 * gui.scale,
						bg=ColourRGBA(5, 5, 5, 255),
					)
					yy -= 14 * gui.scale
				if u:
					gui.delay_frame(5)

				if pctl.draw.button("Copy", rect[0] + rect[2] - 55 * gui.scale, rect[1] + rect[3] - 30 * gui.scale):
					text = ""
					for record in tauon.log.log_history[-50:]:
						t = record.created
						dt = time.localtime(t)
						text += time.strftime("%H:%M:%S", dt) + " " + tauon.log.format(record) + "\n"
					copy_to_clipboard(text)
					tauon.show_message(_("Lines copied to clipboard"), mode="done")

				# Track stream/buffer status graph
				try:
					s_aud = tauon.aud
					if s_aud is not None and hasattr(s_aud, "get_stream_stats"):
						colour_buffered = ColourRGBA(45, 110, 220, 255)
						colour_decoded = ColourRGBA(50, 200, 110, 255)
						colour_position = ColourRGBA(255, 80, 160, 255)
						colour_pcm = ColourRGBA(240, 180, 60, 255)
						colour_meta = ColourRGBA(150, 95, 220, 255)
						colour_behind = ColourRGBA(58, 64, 90, 255)
						colour_track = ColourRGBA(38, 38, 44, 255)
						colour_text = ColourRGBA(140, 140, 150, 255)
						text_bg = ColourRGBA(5, 5, 5, 255)

						gx = rect[0]
						gy = rect[1] + rect[3] + 6 * gui.scale
						gw = rect[2]
						gh = 66 * gui.scale
						ddt.rect((gx, gy, gw, gh), ColourRGBA(0, 0, 0, 245))

						bar_x = round(gx + 10 * gui.scale)
						bar_w = round(gw - 20 * gui.scale)
						bar_y = round(gy + 8 * gui.scale)
						bar_h = round(13 * gui.scale)

						s_size = ctypes.c_longlong(0)
						s_start = ctypes.c_longlong(0)
						s_end = ctypes.c_longlong(0)
						s_pos = ctypes.c_longlong(0)
						s_meta = ctypes.c_longlong(0)
						s_net = ctypes.c_int(0)
						s_eof = ctypes.c_int(0)
						s_active = s_aud.get_stream_stats(
							ctypes.byref(s_size), ctypes.byref(s_start), ctypes.byref(s_end),
							ctypes.byref(s_pos), ctypes.byref(s_meta), ctypes.byref(s_net), ctypes.byref(s_eof))

						pcm_ms = s_aud.get_buffered_ms() if hasattr(s_aud, "get_buffered_ms") else 0

						# Byte map of the file, in playback order: playhead,
						# then decoded-but-not-yet-played (the PCM buffer
						# contents), then buffered data waiting on the decoder
						ddt.rect((bar_x, bar_y, bar_w, bar_h), colour_track)
						if s_active and s_size.value > 0:

							def stream_graph_x(v: int) -> int:
								return round(bar_x + bar_w * min(max(v / s_size.value, 0.0), 1.0))

							# Estimate the playhead's byte offset by rewinding
							# the decode position by the PCM buffer's duration
							# at the track's average audio byte rate
							pos_byte = s_pos.value
							if pctl.playing_length > 0:
								audio_bytes = max(s_size.value - s_meta.value, 1)
								pos_byte = s_pos.value - round(pcm_ms / 1000 * (audio_bytes / pctl.playing_length))
							pos_byte = min(max(pos_byte, s_start.value, s_meta.value), s_pos.value)

							window_x = stream_graph_x(s_start.value)
							window_e = stream_graph_x(s_end.value)
							decode_x = stream_graph_x(s_pos.value)
							play_x = stream_graph_x(pos_byte)

							# Already played but still kept in memory for quick back-seeks
							if play_x > window_x:
								ddt.rect((window_x, bar_y, play_x - window_x, bar_h), colour_behind)
							# Decoded ahead of playback (matches the PCM bar below)
							if decode_x > play_x:
								ddt.rect((play_x, bar_y, decode_x - play_x, bar_h), colour_decoded)
							# Downloaded, waiting on the decoder
							if window_e > decode_x:
								ddt.rect((decode_x, bar_y, window_e - decode_x, bar_h), colour_buffered)
							# Leading metadata block (tags/embedded art)
							if s_meta.value > 0:
								meta_x = stream_graph_x(s_meta.value)
								if meta_x > bar_x:
									ddt.rect((bar_x, bar_y, meta_x - bar_x, bar_h), colour_meta)
							# Playhead
							ddt.rect(
								(play_x, round(bar_y - 2 * gui.scale),
									max(round(1 * gui.scale), 1), round(bar_h + 4 * gui.scale)),
								colour_position)

						# Decoded PCM buffer fill (holds up to ~5s of audio)
						pcm_y = round(bar_y + bar_h + 6 * gui.scale)
						pcm_h = round(5 * gui.scale)
						ddt.rect((bar_x, pcm_y, bar_w, pcm_h), colour_track)
						ddt.rect((bar_x, pcm_y, round(bar_w * min(pcm_ms / 5000, 1.0)), pcm_h), colour_pcm)

						# Legend and numbers (values quantised to limit text cache churn)
						ty = pcm_y + pcm_h + 5 * gui.scale
						lx = bar_x
						for swatch, label in (
							(colour_position, _("Position")),
							(colour_meta, _("Metadata")),
							(colour_decoded, _("Decoded")),
							(colour_buffered, _("Buffered")),
							(colour_pcm, _("PCM {n}ms").format(n=pcm_ms - pcm_ms % 100)),
						):
							# Text y is relative to the baseline area; glyph caps sit
							# roughly 4-13px (scaled) below it, centre the swatch on that
							ddt.rect((round(lx), round(ty + 5 * gui.scale), round(7 * gui.scale), round(7 * gui.scale)), swatch)
							lw = ddt.text((lx + 11 * gui.scale, ty), label, colour_text, 311, bg=text_bg) or 0
							lx += lw + 20 * gui.scale

						if s_active:
							line = "NET" if s_net.value else "LOCAL"
							if s_size.value > 0:
								line += f"  {(s_end.value - s_start.value) / 1000000:.1f} / {s_size.value / 1000000:.1f} MB"
							if s_eof.value:
								line += "  EOF"
						else:
							line = _("No stream")
						stream_feeder = getattr(tauon, "stream_feeder", None)
						if stream_feeder is not None and stream_feeder.speed > 0:
							line += f"  |  {stream_feeder.speed / 1000:.0f} kB/s"
						ddt.text((bar_x + bar_w, ty, 1), line, colour_text, 311, bg=text_bg)
				except Exception:
					logging.exception("Stream status graph failed")

			if gui.cursor_is != gui.cursor_want:
				gui.cursor_is = gui.cursor_want

				if gui.cursor_is == 0:
					sdl3.SDL_SetCursor(gui.cursor_standard)
				elif gui.cursor_is == 1:
					sdl3.SDL_SetCursor(gui.cursor_shift)
				elif gui.cursor_is == 2:
					sdl3.SDL_SetCursor(gui.cursor_text)
				elif gui.cursor_is == 3:
					sdl3.SDL_SetCursor(gui.cursor_hand)
				elif gui.cursor_is == 4:
					sdl3.SDL_SetCursor(gui.cursor_br_corner)
				elif gui.cursor_is == 8:
					sdl3.SDL_SetCursor(gui.cursor_right_side)
				elif gui.cursor_is == 9:
					sdl3.SDL_SetCursor(gui.cursor_top_side)
				elif gui.cursor_is == 10:
					sdl3.SDL_SetCursor(gui.cursor_left_side)
				elif gui.cursor_is == 11:
					sdl3.SDL_SetCursor(gui.cursor_bottom_side)
				elif gui.cursor_is == 12:
					# Vertical-resize cursor (NS) — used by the Custom Layout boundaries.
					sdl3.SDL_SetCursor(gui.cursor_ns)

			tauon.input_sdl.test_capture_mouse()
			tauon.input_sdl.mouse_capture_want = False

			# # Quick view
			# quick_view_box.render()

			# Drag icon next to cursor
			if (
				inp.quick_drag
				and inp.mouse_down
				and not point_proximity_test(gui.drag_source_position, inp.mouse_position, 15 * gui.scale)
			):
				i_x, i_y = tauon.input_sdl.mouse()
				gui.drag_source_position = (0, 0)

				block_size = round(10 * gui.scale)
				x_offset = round(20 * gui.scale)
				y_offset = round(1 * gui.scale)

				if len(gui.shift_selection) == 1:  # Single track
					ddt.rect((i_x + x_offset, i_y + y_offset, block_size, block_size), ColourRGBA(160, 140, 235, 240))
				elif inp.key_ctrl_down:  # Add to queue undrouped
					small_block = round(6 * gui.scale)
					spacing = round(2 * gui.scale)
					ddt.rect((i_x + x_offset, i_y + y_offset, small_block, small_block), ColourRGBA(160, 140, 235, 240))
					ddt.rect(
						(i_x + x_offset + spacing + small_block, i_y + y_offset, small_block, small_block),
						ColourRGBA(160, 140, 235, 240),
					)
					ddt.rect(
						(i_x + x_offset, i_y + y_offset + spacing + small_block, small_block, small_block),
						ColourRGBA(160, 140, 235, 240),
					)
					ddt.rect(
						(
							i_x + x_offset + spacing + small_block,
							i_y + y_offset + spacing + small_block,
							small_block,
							small_block,
						),
						ColourRGBA(160, 140, 235, 240),
					)
					ddt.rect(
						(
							i_x + x_offset,
							i_y + y_offset + spacing + small_block + spacing + small_block,
							small_block,
							small_block,
						),
						ColourRGBA(160, 140, 235, 240),
					)
					ddt.rect(
						(
							i_x + x_offset + spacing + small_block,
							i_y + y_offset + spacing + small_block + spacing + small_block,
							small_block,
							small_block,
						),
						ColourRGBA(160, 140, 235, 240),
					)

				else:  # Multiple tracks
					long_block = round(25 * gui.scale)
					ddt.rect((i_x + x_offset, i_y + y_offset, block_size, long_block), ColourRGBA(160, 140, 235, 240))

				# gui.update += 1
				gui.update_on_drag = True

			# Drag pl tab next to cursor
			if (
				(tauon.playlist_box.drag)
				and inp.mouse_down
				and not point_proximity_test(gui.drag_source_position, inp.mouse_position, 10 * gui.scale)
			):
				i_x, i_y = tauon.input_sdl.mouse()
				gui.drag_source_position = (0, 0)
				ddt.rect(
					(i_x + 20 * gui.scale, i_y + 3 * gui.scale, int(50 * gui.scale), int(15 * gui.scale)),
					ColourRGBA(50, 50, 50, 225),
				)
				# ddt.rect_r((i_x + 20 * gui.scale, i_y + 1 * gui.scale, int(60 * gui.scale), int(15 * gui.scale)), [240, 240, 240, 255], True)
				# ddt.draw_text((i_x + 75 * gui.scale, i_y - 0 * gui.scale, 1), pctl.multi_playlist[tauon.playlist_box.drag_on].title, ColourRGBA(30, 30, 30, 255), 212, bg=[240, 240, 240, 255])
			if tauon.radio_view.drag and not point_proximity_test(
				tauon.radio_view.click_point, inp.mouse_position, round(4 * gui.scale)
			):
				ddt.rect(
					(
						inp.mouse_position[0] + round(8 * gui.scale),
						inp.mouse_position[1] - round(8 * gui.scale),
						48 * gui.scale,
						14 * gui.scale,
					),
					colours.grey(70),
				)
			if (gui.set_label_hold != -1) and inp.mouse_down:
				gui.update_on_drag = True

				if not point_proximity_test(gui.set_label_point, inp.mouse_position, 3):
					i_x, i_y = tauon.input_sdl.mouse()
					gui.set_label_point = (0, 0)

					w = ddt.get_text_w(gui.pl_st[gui.set_label_hold][0], 212)
					w = max(w, 45 * gui.scale)
					ddt.rect(
						(i_x + 25 * gui.scale, i_y + 1 * gui.scale, w + int(20 * gui.scale), int(15 * gui.scale)),
						ColourRGBA(240, 240, 240, 255),
					)
					ddt.text(
						(i_x + 25 * gui.scale + w + int(20 * gui.scale) - 4 * gui.scale, i_y - 0 * gui.scale, 1),
						gui.pl_st[gui.set_label_hold][0],
						ColourRGBA(30, 30, 30, 255),
						212,
						bg=ColourRGBA(240, 240, 240, 255),
					)

			inp.input_text = ""
			gui.update -= 1

			# logging.info("FRAME " + str(tauon.core_timer.get()))
			gui.update = min(gui.update, 1)
			gui.present = True

			sdl3.SDL_SetRenderTarget(renderer, None)
			if tauon.dream_room.active:
				# Dream Room: compose the frame as a 3D scene with the UI on a
				# monitor instead of blitting it fullscreen
				tauon.dream_room.render()
			else:
				sdl3.SDL_RenderTexture(renderer, gui.main_texture, None, gui.tracklist_texture_rect)

			if gui.turbo:
				gui.level_update = True

		# if gui.vis == 1 and pctl.playing_state != PlayingState.PLAYING and gui.level_peak != [0, 0] and gui.turbo:
		# 	# logging.info(gui.level_peak)
		# 	gui.time_passed = gui.level_time.hit()
		# 	if gui.time_passed > 1:
		# 		gui.time_passed = 0
		# 	while gui.time_passed > 0.01:
		# 		gui.level_peak[1] -= 0.5
		# 		if gui.level_peak[1] < 0:
		# 			gui.level_peak[1] = 0
		# 		gui.level_peak[0] -= 0.5
		# 		if gui.level_peak[0] < 0:
		# 			gui.level_peak[0] = 0
		# 		gui.time_passed -= 0.020
		# 	gui.level_update = True

		# gui.turbo = True
		# gui.vis = 5
		# gui.level_update = True

		if gui.level_update is True and not resize_mode and gui.mode != GuiMode.MINI and not tauon.dream_room.active:
			gui.level_update = False

			sdl3.SDL_SetRenderTarget(renderer, None)
			if not gui.present:
				sdl3.SDL_SetRenderDrawBlendMode(renderer, sdl3.SDL_BLENDMODE_NONE)
				sdl3.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0)
				sdl3.SDL_RenderClear(renderer)
				sdl3.SDL_SetRenderDrawBlendMode(renderer, sdl3.SDL_BLENDMODE_BLEND)
				sdl3.SDL_RenderTexture(renderer, gui.main_texture, None, gui.tracklist_texture_rect)
				gui.present = True

			if gui.vis == 5:
				# milky
				pass

			if gui.vis == 3:
				# Scrolling spectrogram

				# if not vis_update:
				#     logging.info("No UPDATE " + str(random.randint(1,50)))
				if len(gui.spec2_buffers) > 0 and gui.spec2_timer.get() > 0.04:
					# gui.spec2_timer.force_set(gui.spec2_timer.get() - 0.04)
					gui.spec2_timer.set()
					vis_update = True

				if len(gui.spec2_buffers) > 0 and vis_update:
					vis_update = False

					sdl3.SDL_SetRenderTarget(renderer, gui.spec2_tex)
					for i, value in enumerate(gui.spec2_buffers[0]):
						ddt.rect([gui.spec2_position, i, 1, 1], colours.vis_bg)

					del gui.spec2_buffers[0]

					gui.spec2_position += 1

					if gui.spec2_position > gui.spec2_w - 1:
						gui.spec2_position = 0

					sdl3.SDL_SetRenderTarget(renderer, None)

				#
				# else:
				#     logging.info("animation stall" + str(random.randint(1, 10)))

				if prefs.spec2_scroll:
					gui.spec2_source.x = 0
					gui.spec2_source.y = 0
					gui.spec2_source.w = gui.spec2_position
					gui.spec2_dest.x = gui.spec2_rec.x + gui.spec2_rec.w - gui.spec2_position
					gui.spec2_dest.w = gui.spec2_position
					sdl3.SDL_RenderTexture(renderer, gui.spec2_tex, gui.spec2_source, gui.spec2_dest)

					gui.spec2_source.x = gui.spec2_position
					gui.spec2_source.y = 0
					gui.spec2_source.w = gui.spec2_rec.w - gui.spec2_position
					gui.spec2_dest.x = gui.spec2_rec.x
					gui.spec2_dest.w = gui.spec2_rec.w - gui.spec2_position
					sdl3.SDL_RenderTexture(renderer, gui.spec2_tex, gui.spec2_source, gui.spec2_dest)
				else:
					sdl3.SDL_RenderTexture(renderer, gui.spec2_tex, None, gui.spec2_rec)

				if pref_box.enabled:
					# ddt.rect((gui.spec2_rec.x, gui.spec2_rec.y, gui.spec2_rec.w, gui.spec2_rec.h), ColourRGBA(0, 0, 0, 90))
					logging.info("spectrogram box")
					ddt.rect((gui.spec2_rec.x, gui.spec2_rec.y, gui.spec2_rec.w, gui.spec2_rec.h), colours.vis_bg)

			if gui.vis == 4 and gui.draw_vis4_top:
				showcase.render_vis(True)
				# gui.level_update = False

			if gui.vis == 2 and gui.spec is not None:
				# Standard spectrum visualiser
				if gui.update_spec == 0 and pctl.playing_state != PlayingState.PAUSED:
					if tauon.vis_decay_timer.get() > 0.007:  # Controls speed of decay after stop
						tauon.vis_decay_timer.set()
						for i in range(len(gui.spec)):
							if gui.s_spec[i] > 0:
								if gui.spec[i] > 0:
									gui.spec[i] -= 1
								gui.level_update = True
					else:
						gui.level_update = True

				if tauon.vis_rate_timer.get() > 0.027:  # Limit the change rate #to 60 fps
					tauon.vis_rate_timer.set()

					if spec_smoothing and pctl.playing_state != PlayingState.STOPPED:
						for i in range(len(gui.spec)):
							if gui.spec[i] > gui.s_spec[i]:
								gui.s_spec[i] += 1
								if abs(gui.spec[i] - gui.s_spec[i]) > 4:
									gui.s_spec[i] += 1
								if abs(gui.spec[i] - gui.s_spec[i]) > 6:
									gui.s_spec[i] += 1
								if abs(gui.spec[i] - gui.s_spec[i]) > 8:
									gui.s_spec[i] += 1
							elif gui.spec[i] == gui.s_spec[i]:
								pass
							elif gui.spec[i] < gui.s_spec[i] > 0:
								gui.s_spec[i] -= 1
								if abs(gui.spec[i] - gui.s_spec[i]) > 4:
									gui.s_spec[i] -= 1
								if abs(gui.spec[i] - gui.s_spec[i]) > 6:
									gui.s_spec[i] -= 1
								if abs(gui.spec[i] - gui.s_spec[i]) > 8:
									gui.s_spec[i] -= 1

						if pctl.playing_state == PlayingState.STOPPED and check_equal(gui.s_spec):
							gui.level_update = True
							time.sleep(0.008)
					else:
						gui.s_spec = gui.spec
				else:
					pass

				if not gui.test:
					sdl3.SDL_SetRenderTarget(renderer, gui.spec1_tex)

					# ddt.rect_r(gui.spec_rect, colours.top_panel_background, True)
					ddt.rect((0, 0, gui.spec_w, gui.spec_h), colours.vis_bg)

					# xx = 0
					gui.bar.x = 0
					on = 0

					sdl3.SDL_SetRenderDrawColor(
						renderer, colours.vis_colour.r, colours.vis_colour.g, colours.vis_colour.b, colours.vis_colour.a
					)

					for item in gui.s_spec:
						if on > 19:
							break
						on += 1

						item -= 1

						if item < 1:
							gui.bar.x += round(4 * gui.scale)
							continue

						item = min(item, 20)

						if gui.scale >= 2:
							item = round(item * gui.scale)

						gui.bar.y = 0 + gui.spec_h - item
						gui.bar.h = item

						sdl3.SDL_RenderFillRect(renderer, gui.bar)

						gui.bar.x += round(4 * gui.scale)

					if tauon.pref_box.enabled:
						ddt.rect((0, 0, gui.spec_w, gui.spec_h), ColourRGBA(0, 0, 0, 90))

					sdl3.SDL_SetRenderTarget(renderer, None)
					sdl3.SDL_RenderTexture(renderer, gui.spec1_tex, None, gui.spec1_rec)

			if gui.vis == 1:
				if prefs.backend == Backend.GSTREAMER or True:
					if pctl.playing_state in (PlayingState.PLAYING, PlayingState.URL_STREAM):
						# gui.level_update = True
						while tauon.level_train and tauon.level_train[0][0] < time.time():
							l = tauon.level_train[0][1]
							r = tauon.level_train[0][2]

							gui.level_peak[0] = max(r, gui.level_peak[0])
							gui.level_peak[1] = max(l, gui.level_peak[1])

							del tauon.level_train[0]

					else:
						tauon.level_train.clear()

				sdl3.SDL_SetRenderTarget(renderer, gui.spec_level_tex)

				x = window_size[0] - 20 * gui.scale - gui.offset_extra
				y = gui.level_y
				w = gui.level_w
				s = gui.level_s

				y = 0

				gui.spec_level_rec.x = round(x - 70 * gui.scale)
				ddt.rect_a((0, 0), (79 * gui.scale, 18 * gui.scale), colours.grey(10))

				x = round(gui.level_ww - 9 * gui.scale)
				y = 10 * gui.scale

				if prefs.backend == Backend.GSTREAMER or True:
					if gui.level_peak[0] > 0 or gui.level_peak[1] > 0:
						# gui.level_update = True
						if pctl.playing_time < 1:
							gui.delay_frame(0.032)

						if pctl.playing_state in (PlayingState.PLAYING, PlayingState.URL_STREAM):
							t = gui.level_decay_timer.hit()
							decay = 14 * t
							gui.level_peak[1] -= decay
							gui.level_peak[0] -= decay
						elif pctl.playing_state in (PlayingState.STOPPED, PlayingState.PAUSED):
							gui.level_update = True
							time.sleep(0.016)
							t = gui.level_decay_timer.hit()
							decay = 16 * t
							gui.level_peak[1] -= decay
							gui.level_peak[0] -= decay

				for t in range(12):
					met = False if gui.level_peak[0] < t else True
					if gui.level_peak[0] < 0.2:
						met = False
					if gui.level_meter_colour_mode == 1:
						if not met:
							cc = ColourRGBA(15, 10, 20, 255)
						else:
							cc = colorsys.hls_to_rgb(0.68 + (t * 0.015), 0.4, 0.7)
							cc = ColourRGBA(int(cc[0] * 255), int(cc[1] * 255), int(cc[2] * 255), 255)
					elif gui.level_meter_colour_mode == 2:
						if not met:
							cc = ColourRGBA(11, 11, 13, 255)
						else:
							cc = colorsys.hls_to_rgb(0.63 - (t * 0.015), 0.4, 0.7)
							cc = ColourRGBA(int(cc[0] * 255), int(cc[1] * 255), int(cc[2] * 255), 255)
					elif gui.level_meter_colour_mode == 3:
						if not met:
							cc = ColourRGBA(12, 6, 0, 255)
						else:
							cc = colorsys.hls_to_rgb(0.11 - (t * 0.010), 0.4, 0.7 + (t * 0.02))
							cc = ColourRGBA(int(cc[0] * 255), int(cc[1] * 255), int(cc[2] * 255), 255)
					elif gui.level_meter_colour_mode == 4:
						if not met:
							cc = ColourRGBA(10, 10, 10, 255)
						else:
							cc = colorsys.hls_to_rgb(0.3 - (t * 0.03), 0.4, 0.7 + (t * 0.02))
							cc = ColourRGBA(int(cc[0] * 255), int(cc[1] * 255), int(cc[2] * 255), 255)
					elif t < 7:
						cc = colours.level_green
						if met is False:
							cc = colours.level_1_bg
					elif t < 10:
						cc = colours.level_yellow
						if met is False:
							cc = colours.level_2_bg
					else:
						cc = colours.level_red
						if met is False:
							cc = colours.level_3_bg
					if gui.level > 0 and pctl.playing_state != PlayingState.STOPPED:
						pass
					ddt.rect_a(((x - (w * t) - (s * t)), y), (w, w), cc)

				y -= 7 * gui.scale
				for t in range(12):
					met = not gui.level_peak[1] < t
					if gui.level_peak[1] < 0.2:
						met = False

					if gui.level_meter_colour_mode == 1:
						if not met:
							cc = ColourRGBA(15, 10, 20, 255)
						else:
							cc = colorsys.hls_to_rgb(0.68 + (t * 0.015), 0.4, 0.7)
							cc = ColourRGBA(int(cc[0] * 255), int(cc[1] * 255), int(cc[2] * 255), 255)

					elif gui.level_meter_colour_mode == 2:
						if not met:
							cc = ColourRGBA(11, 11, 13, 255)
						else:
							cc = colorsys.hls_to_rgb(0.63 - (t * 0.015), 0.4, 0.7)
							cc = ColourRGBA(int(cc[0] * 255), int(cc[1] * 255), int(cc[2] * 255), 255)

					elif gui.level_meter_colour_mode == 3:
						if not met:
							cc = ColourRGBA(12, 6, 0, 255)
						else:
							cc = colorsys.hls_to_rgb(0.11 - (t * 0.010), 0.4, 0.7 + (t * 0.02))
							cc = ColourRGBA(int(cc[0] * 255), int(cc[1] * 255), int(cc[2] * 255), 255)

					elif gui.level_meter_colour_mode == 4:
						if not met:
							cc = ColourRGBA(10, 10, 10, 255)
						else:
							cc = colorsys.hls_to_rgb(0.3 - (t * 0.03), 0.4, 0.7 + (t * 0.02))
							cc = ColourRGBA(int(cc[0] * 255), int(cc[1] * 255), int(cc[2] * 255), 255)

					elif t < 7:
						cc = colours.level_green
						if met is False:
							cc = colours.level_1_bg
					elif t < 10:
						cc = colours.level_yellow
						if met is False:
							cc = colours.level_2_bg
					else:
						cc = colours.level_red
						if met is False:
							cc = colours.level_3_bg

					if gui.level > 0 and pctl.playing_state != PlayingState.STOPPED:
						pass
					ddt.rect_a(((x - (w * t) - (s * t)), y), (w, w), cc)

				sdl3.SDL_SetRenderTarget(renderer, None)
				sdl3.SDL_RenderTexture(renderer, gui.spec_level_tex, None, gui.spec_level_rec)

		if gui.present:
			sdl3.SDL_SetRenderTarget(renderer, None)
			tauon.render_rounded_corners()
			sdl3.SDL_RenderPresent(renderer)

			gui.present = False

		# -------------------------------------------------------------------------------------------
		# Misc things to update every tick

		# Update d-bus metadata on Linux
		if (pctl.playing_state in (PlayingState.PLAYING, PlayingState.URL_STREAM)) and pctl.mpris is not None:
			pctl.mpris.update_progress()

		# GUI time ticker update
		if (pctl.playing_state in (PlayingState.PLAYING, PlayingState.URL_STREAM)) and gui.lowered is False:
			if int(pctl.playing_time) != int(pctl.last_playing_time):
				pctl.last_playing_time = pctl.playing_time
				tauon.bottom_bar1.seek_time = pctl.playing_time
				if not prefs.power_save or window_is_focused(tauon.t_window):
					gui.update = 1

		# Auto save play times to disk
		if pctl.total_playtime - time_last_save > 600:
			try:
				if bag.should_save_state:
					logging.info("Auto save playtime")
					with atomic_save(user_directory / "star.p") as file:
						pickle.dump(tauon.star_store.db, file, protocol=pickle.HIGHEST_PROTOCOL)
				else:
					logging.info("Dev mode, skip auto saving playtime")
			except PermissionError:
				logging.exception("Permission error encountered while writing database")
				tauon.show_message(_("Permission error encountered while writing database"), "error")
			except Exception:
				logging.exception("Unknown error encountered while writing database")
			time_last_save = pctl.total_playtime

		# Always render at least one frame per minute (to avoid SDL bugs I guess)
		if tauon.min_render_timer.get() > 60:
			tauon.min_render_timer.set()
			gui.pl_update = 1
			gui.update += 1

		# Save power if the window is minimized
		if gui.lowered:
			time.sleep(0.2)

	# Send scrobble if pending
	if tauon.lfm_scrobbler.queue and not tauon.lfm_scrobbler.running:
		tauon.lfm_scrobbler.start_queue()
		logging.info("Sending scrobble before close...")

	if gui.mode == GuiMode.MAIN:
		tauon.old_window_position = get_window_position(t_window)

	sdl3.SDL_DestroyTexture(gui.main_texture)
	sdl3.SDL_DestroyTexture(gui.tracklist_texture)
	sdl3.SDL_DestroyTexture(gui.spec2_tex)
	sdl3.SDL_DestroyTexture(gui.spec1_tex)
	sdl3.SDL_DestroyTexture(gui.spec_level_tex)
	ddt.clear_text_cache()
	tauon.clear_img_cache(False)

	sdl3.SDL_DestroyWindow(t_window)

	pctl.playerCommand = "unload"
	pctl.playerCommandReady = True

	if prefs.reload_play_state and pctl.playing_state in (PlayingState.PLAYING, PlayingState.PAUSED):
		logging.info("Saving play state...")
		prefs.reload_state = (pctl.playing_state, pctl.playing_time)
	else:
		prefs.reload_state = None

	if bag.should_save_state:
		with atomic_save(user_directory / "star.p") as file:
			pickle.dump(tauon.star_store.db, file, protocol=pickle.HIGHEST_PROTOCOL)
		with atomic_save(user_directory / "album-star.p") as file:
			pickle.dump(tauon.album_star_store.db, file, protocol=pickle.HIGHEST_PROTOCOL)

	gui.gallery_positions[pctl.pl_to_id(pctl.active_playlist_viewing)] = gui.album_scroll_px
	tauon.save_state()

	date = datetime.date.today()
	if bag.should_save_state:
		with atomic_save(user_directory / "star.p.backup") as file:
			pickle.dump(tauon.star_store.db, file, protocol=pickle.HIGHEST_PROTOCOL)
		with atomic_save(user_directory / f"star.p.backup{date.month!s}") as file:
			pickle.dump(tauon.star_store.db, file, protocol=pickle.HIGHEST_PROTOCOL)

	if tauon.stream_proxy and tauon.stream_proxy.download_running:
		logging.info("Stopping stream...")
		tauon.stream_proxy.stop()
		time.sleep(2)

	try:
		if tauon.thread_manager.player_lock.locked():
			tauon.thread_manager.player_lock.release()
	except RuntimeError as e:
		if str(e) == "release unlocked lock":
			logging.error("RuntimeError: Attempted to release already unlocked player_lock")  # noqa: TRY400
		else:
			logging.exception("Unknown RuntimeError trying to release player_lock")
	except Exception:
		logging.exception("Unknown error trying to release player_lock")

	if tauon.radio_server is not None:
		try:
			tauon.radio_server.server_close()
		except Exception:
			logging.exception("Failed to close radio server")

	if sys.platform == "win32":
		if pctl.smtc:
			pctl.sm.unload()
	elif sys.platform == "darwin":
		if getattr(bag, "nowplaying_helper", None) is not None:
			try:
				bag.nowplaying_helper.stop()
			except Exception:
				logging.exception("Failed to stop macOS Now Playing helper")
	elif tauon.de_notify_support:
		try:
			tauon.song_notification.close()
			tauon.g_tc_notify.close()
			Notify.uninit()
		except Exception:
			logging.exception("uninit notification error")

	try:
		tauon.instance_lock.close()
	except Exception:
		logging.exception("No lock object to close")

	# sdl3.IMG_Quit()
	# sdl3.SDL_QuitSubSystem(sdl3.SDL_INIT_EVERYTHING)
	sdl3.SDL_Quit()
	# logging.info("SDL unloaded")

	exit_timer = Timer()
	exit_timer.set()

	if not tauon.quick_close:
		while tauon.thread_manager.check_playback_running():
			time.sleep(0.2)
			if exit_timer.get() > 2:
				logging.warning("Phazor unload timeout")
				break

		while tauon.lfm_scrobbler.running:
			time.sleep(0.2)
			tauon.lfm_scrobbler.running = False
			if exit_timer.get() > 15:
				logging.warning("Scrobble wait timeout")
				break

	if tauon.sleep_lock is not None:
		del tauon.sleep_lock
	if tauon.shutdown_lock is not None:
		del tauon.shutdown_lock
	if tauon.play_lock is not None:
		del tauon.play_lock

	cache_dir = tmp_cache_dir()
	if os.path.isdir(cache_dir):
		# This check can be Windows only, lazy deletes are fine on Linux/macOS
		if sys.platform == "win32":
			while tauon.cachement.running:
				logging.warning("Waiting for caching to stop before deleting cache directory…")
				time.sleep(0.2)
		logging.info("Clearing tmp cache")
		shutil.rmtree(cache_dir)

	logging.info("Bye!")


# type_enforced.Enforcer(sys.modules[__name__])
