"""
This script is intended to be called from OBS Studio. Provides
mouse-based zoom and tracking for desktop/monitor/window/game sources.
For more information please visit:
https://github.com/tryptech/obs-zoom-and-follow
"""

description = (
    "Crops and resizes a source to simulate a zoomed in tracked to"\
    " the mouse.\n\n"
    + "Set activation hotkey in Settings.\n\n"
    + "Active Border enables lazy/smooth tracking; border size"\
    "calculated as percent of smallest dimension. "
    + "Border of 50% keeps mouse locked in the center of the zoom"\
    " frame\n\n"
    + "By tryptech (@yo_tryptech / tryptech#1112)\n\n"
    + "v2022.09.26"
)

import obspython as obs 
import pywinctl as pwc # version >=0.0.38
from platform import system
from math import sqrt
from json import load, loads

c = pwc.getMousePos
get_position = lambda: [c().x, c().y]
zoom_id_tog = None
follow_id_tog = None
new_source = True
ZOOM_NAME_TOG = "zoom.toggle"
FOLLOW_NAME_TOG = "follow.toggle"
ZOOM_DESC_TOG = "Enable/Disable Mouse Zoom"
FOLLOW_DESC_TOG = "Enable/Disable Mouse Follow"
USE_MANUAL_MONITOR_SIZE = "Manual Monitor Size"


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

class CursorWindow:
    flag = lock = track = update = True
    zi_timer = zo_timer = 0
    windows = window_titles = monitor = window = window_handle \
        = window_name = ''
    monitors = pwc.getAllScreens()
    monitors_key = list(dict.keys(monitors))
    monitor_override = manual_offset = monitor_size_override = False
    monitor_override_id = ''
    source_w = source_h = source_x = source_y = zoom_x = zoom_y \
        = source_x_override = source_y_override = 0
    refresh_rate = int(obs.obs_get_frame_interval_ns()/1000000)
    source_name = source_type = ''
    zoom_w = 1280
    zoom_h = 720
    active_border = 0.15
    max_speed = 160
    smooth = 1.0
    zoom_time = 300

    def update_sources(self):
        """
        Update the list of Windows and Monitors from PyWinCtl
        """
        self.windows = pwc.getAllWindows()
        self.monitors = pwc.getAllScreens()
        self.monitors_key = list(dict.keys(self.monitors))

    def update_window_dim(self, window):
        """
        Update the stored window dimensions to those of the selected
        window

        :param window: Window with new dimensions
        """
        print("Updating stored dimensions to match current dimensions")
        if window != None:
            # FIXME: on macos get window bounds results in an error and
            # does not work
            # NSInternalInconsistencyException - NSWindow drag regions
            # should only be invalidated on the Main Thread!
            window_dim = window.getClientFrame()
            if (self.source_w != window_dim.right - window_dim.left
                or self.source_h != window_dim.bottom - window_dim.top
                or self.source_x != window_dim.left
                or self.source_y != window_dim.top):    
                print("OLD")
                print("Width, Height, X, Y")
                print(f"{self.source_w}, {self.source_h}, {self.source_x},"\
                    f" {self.source_y}")
                self.source_w = window_dim.right - window_dim.left
                self.source_h = window_dim.bottom - window_dim.top
                self.source_x = window_dim.left
                self.source_y = window_dim.top
                print("NEW")
                print("Width, Height, X, Y")
                print(f"{self.source_w}, {self.source_h}, {self.source_x},"\
                    f" {self.source_y}")
            else:
                print("Dimensions did not change")

    def update_monitor_dim(self, monitor):
        """
        Update the stored dimensions based on the selected monitor

        :param monitor: Single monitor as returned from the PyWinCtl
            Monitor function getAllScreens()
        """
        if self.monitor_size_override:
            print("Manual monitor size enabled")
            print("Dimensions set to:")
            print("Width, Height, X, Y")
            self.source_x = monitor['pos'].x
            self.source_y = monitor['pos'].y
            print(f"{self.source_w}, {self.source_h}, {self.source_x}, \
                {self.source_y}")
        else:
            print(f"Updating stored dimensions to match monitor's dimensions | {monitor}")
            if (self.source_w != monitor['size'].width
                or self.source_h != monitor['size'].height
                or self.source_x != monitor['pos'].x
                or self.source_y != monitor['pos'].y):
                print("OLD")
                print("Width, Height, X, Y")
                print(f"{self.source_w}, {self.source_h}, {self.source_x}, \
                    {self.source_y}")
                self.source_w = monitor['size'].width
                self.source_h = monitor['size'].height
                self.source_x = monitor['pos'].x
                self.source_y = monitor['pos'].y
                print("NEW")
                print("Width, Height, X, Y")
                print(f"{self.source_w}, {self.source_h}, {self.source_x}, \
                    {self.source_y}")
            else:
                print("Dimensions did not change")

    def window_capture_mac(self, data):
        """
        Window capture for macOS
        macos uses an exclusive property 'window_name' pywinctl does not
        report application windows correctly for macos yet, so we must
        capture based on the actual window name and not based on the
        application like we do for windows.
        """
                    
        self.window_name = data.get('window_name')

    def monitor_capture_mac(self, data):
        """
        The 'display' property is an index value and not the true
        monitor id. It is only returned when there is more than one
        monitor on your system. We will assume that the order of the
        monitors returned from pywinctl are in the same order that OBS
        is assigning the display index value.
        """
        monitor_index = data.get('display', 0)
        print(f"Retrieving monitor {monitor_index}")
        for monitor in self.monitors.items():
            if (monitor['id'] == monitor_index):
                print(f"Found monitor {monitor['id']} | {monitor}")
                self.update_monitor_dim(monitor)

    def window_capture_gen(self, data):
        """
        TODO: More Linux testing, specifically with handles Windows
        capture for Windows and Linux. In Windows, application data is
        stored as "Title:WindowClass:Executable"
        """
        try:
            # Assuming the OBS data is formatted correctly, we should
            # be able to identify the window
            # If New Source/Init
            # If Handle Exists
            # Else
            if new_source:
                # If new source selected / OBS initialize
                # Build window, window_handle, and
                # window_name
                print("New Source")
                print("Retrieving target window info from OBS")
                self.window_name = data['window'].split(":")[0]
                print(f"Searching for: {self.window_name}")
                for w in self.windows:
                    if w.title == self.window_name:
                        window_match = w
                        self.window_handle = w.getHandle()
                new_source = False
                print(f"Window Match: {window_match.title}")
                print("Window Match Handle:"\
                    f" {str(self.window_handle)}")
            if self.window_handle != '':
                # If window handle is already stored
                # Get window based on handle
                # Check if name needs changing
                print(f"Handle exists: {str(self.window_handle)}")
                handle_match = False
                for w in self.windows:
                    if w.getHandle() == self.window_handle:
                        handle_match = True
                        print(f"Found Handle: {str(w.getHandle())} | {self.window}")
                        window_match = w
                        if window_match.title != self.window:
                            print("Changing target title")
                            print(f"Old Title: {self.window_name}")
                            self.window_name = w.title
                            print(f"New Title: {self.window_name}")
                if handle_match == False:
                    # TODO: If the handle no longer exists,
                    # eg. Window or App closed
                    raise
            else:
                print("I don't know how it gets here.")
                window_match = None
                # TODO: 
        except:
            print(f"Source {self.source_name} has changed." \
                " Select new source window")
            window_match = None
        return window_match

    def monitor_capture_gen(self, data):
        """
        If monitor override, update with monitor override
        Else if no monitor ID, monitor does not exist
        Else search for the monitor and update
        """
        monitor_id = data.get('monitor', None)
        if len(self.monitors.items()) == 1:
            print("Only one monitor detected. Forcing override.")
            for monitor in self.monitors.items():
                self.update_monitor_dim(monitor[1])
        elif self.monitor_override is True:
            print(f"Monitor Override: {self.monitor_override}")
            for monitor in self.monitors.items():
                if monitor[0] == self.monitors_key[
                    self.monitor_override_id]:
                    self.update_monitor_dim(monitor[1])
        elif monitor_id == None:
            print(f"Key 'monitor' does not exist in {data}")
        else:
            print(f"Searching for monitor {monitor_id}")
            for monitor in self.monitors.items():
                if (monitor[1]['id'] == monitor_id):
                    print(f"Found monitor {monitor[1]['id']} | {monitor}")
                    self.update_monitor_dim(monitor[1])

    def update_source_size(self):
        """
        Adjusts the source size variables based on the source given
        """
        global new_source

        try:
            # Try to pull the data for the source object
            # OBS stores the monitor index/window target in the
            # window/game/display sources settings
            # Info is stored in a JSON format
            source = obs.obs_get_source_by_name(self.source_name)
            source_settings = obs.obs_source_get_settings(source)
            data = loads(obs.obs_data_get_json(source_settings))
        except:
            # If it cannot be pulled, it is likely one of the following:
            #   The source no longer exists
            #   The source's name has changed
            #   OBS does not have the sources loaded yet when launching
            #       the script on start

            print("Source '" + self.source_name + "' not found.")
            if len(self.window_name) == 0:
                # OBS does not have the sources loaded yet when
                # launching the script on start
                self.source_name = ''
            print(obs.obs_get_source_by_name(self.source_name))
        else:
            # If the source data is pulled, it exists. Therefore other
            # information must also exists. Source Type is pulled to 
            # determine if the source is a display, game, or window

            print(f"Source loaded successfully: {self.source_type}")
            self.source_type = obs.obs_source_get_id(source)
            print(f"Source Type: f{self.source_type}")
            if (self.source_type in { 'window_capture','game_capture' }):
                window_match = ''
                if 'window_name' in data:
                    self.window_capture_mac(data)
                elif 'window' in data:
                    window_match = self.window_capture_gen(data)
                if window_match is not None:
                    print("Proceeding to resize")
                    self.window = pwc.getWindowsWithTitle(self.window_name)[0]
                    self.update_window_dim(self.window)
            elif (self.source_type == 'monitor_capture'):
                self.monitor_capture_gen(data)
            elif (self.source_type == 'display_capture'):
                self.monitor_capture_mac(data)
            if (self.manual_offset
                or self.monitor_size_override):
                self.source_x += self.source_x_override
                self.source_y += self.source_y_override

    def resetZI(self):
        """
        Reset the zoom-in timer
        """
        self.zi_timer = 0

    def resetZO(self):
        """
        Reset the zoom-out timer
        """
        self.zo_timer = 0

    def cubic_in_out(self, p):
        """
        Cubic in/out easing function. Accelerates until halfway, then
        decelerates.

        :param p: Linear temporal percent progress through easing from
            0 to 1
        :return: Adjusted percent progress
        """
        if p < 0.5:
            return 4 * p * p * p
        else:
            f = (2 * p) - 2
            return 0.5 * f * f * f + 1

    def check_offset(self, arg1, arg2, smooth):
        """
        Checks if a given value is offset from pivot value and provides
        an adjustment towards the pivot based on a smoothing factor

        :param arg1: Pivot value
        :param arg2: Checked value
        :param smooth: Smoothing factor; larger values adjusts more
            smoothly 
        :return: Adjustment value
        """
        result = round((arg1 - arg2) / smooth + 1)
        return int(result)

    def follow(self, mousePos):
        """
        Updates the position of the zoom window.

        :param mousePos: [x,y] position of the mouse on the canvas of
            all connected displays
        :return: If the zoom window was moved
        """
        track = False

        if ((mousePos[0] - (self.source_x + self.source_w) < 1)
            and (mousePos[0] - self.source_x > -1)):
            if ((mousePos[1] - (self.source_y + self.source_h) < 1)
                and (mousePos[1] - self.source_y > -1)):
                    track = True

        if not track:
            return track

        move = False

        # Find shortest dimension (usually height)
        borderScale = min(self.zoom_w, self.zoom_h)
        # Get active zone edges
        zoom_edge_left = (  self.zoom_x
                            + int(self.active_border * borderScale))
        zoom_edge_right = ( self.zoom_x
                            + self.zoom_w
                            - int(self.active_border * borderScale))
        zoom_edge_top = (   self.zoom_y
                            + int(self.active_border * borderScale))
        zoom_edge_bottom = (self.zoom_y
                            + self.zoom_h
                            - int(self.active_border * borderScale))

        # Clamp zone edges at center
        if zoom_edge_right < zoom_edge_left:
            zoom_edge_left = self.zoom_x + int(self.zoom_w/2.0)
            zoom_edge_right = zoom_edge_left

        if zoom_edge_bottom < zoom_edge_top:
            zoom_edge_top = self.zoom_y + int(self.zoom_h/2.0)
            zoom_edge_bottom = zoom_edge_top

        # Set smoothing values
        smoothFactor = 1 if self.update else int((self.smooth * 9) / 10 + 1)

        # Set x and y zoom offset
        x_o = mousePos[0] - self.source_x
        y_o = mousePos[1] - self.source_y

        # Set x and y zoom offset
        offset_x = offset_y = 0

        if x_o < zoom_edge_left:
            offset_x = self.check_offset(x_o, zoom_edge_left, smoothFactor)
            move = True
        elif x_o > zoom_edge_right:
            offset_x = self.check_offset(x_o, zoom_edge_right, smoothFactor)
            move = True

        if y_o < zoom_edge_top:
            offset_y = self.check_offset(y_o, zoom_edge_top, smoothFactor)
            move = True
        elif y_o > zoom_edge_bottom:
            offset_y = self.check_offset(y_o, zoom_edge_bottom, smoothFactor)
            move = True

        # Max speed clamp
        #if not self.update:
        speed_h = sqrt((offset_x**2)+(offset_y**2))
        speed_factor = max(self.max_speed, speed_h)/float(self.max_speed)
        if not self.update:
            offset_x /= speed_factor
            offset_y /= speed_factor

        self.zoom_x += offset_x
        self.zoom_y += offset_y
        if (self.active_border < 0.5):
            self.check_pos()

        return move

    def check_pos(self):
        """
        Checks if zoom window exceeds window dimensions and clamps it if
        true
        """
        if not self.monitor_size_override:
            x_min = 0
            x_max = self.source_w - self.zoom_w
            y_min = 0
            y_max = self.source_h - self.zoom_h
        else:
            x_min = self.source_x_override
            x_max = self.source_w - self.zoom_w + self.source_x_override
            y_min = self.source_y_override
            y_max = self.source_h - self.zoom_h + self.source_y_override

        if self.zoom_x < x_min:
            self.zoom_x = x_min
        elif self.zoom_x > x_max:
            self.zoom_x = x_max
        if self.zoom_y < y_min:
            self.zoom_y = y_min
        elif self.zoom_y > y_max:
            self.zoom_y = y_max

    def set_crop(self, inOut):
        """
        Set dimensions of the crop filter used for zooming

        :param inOut: direction of the filter zoom, in or out
        """
        totalFrames = int(self.zoom_time / self.refresh_rate)

        source = obs.obs_get_source_by_name(self.source_name)
        crop = obs.obs_source_get_filter_by_name(source, "ZoomCrop")

        if crop is None:  # create filter
            _s = obs.obs_data_create()
            obs.obs_data_set_bool(_s, "relative", False)
            f = obs.obs_source_create_private("crop_filter",
                "ZoomCrop", _s)
            obs.obs_source_filter_add(source, f)
            obs.obs_source_release(f)
            obs.obs_data_release(_s)

        s = obs.obs_source_get_settings(crop)
        i = obs.obs_data_set_int

        if inOut == 0:
            self.resetZI()
            if self.zo_timer < totalFrames:
                self.zo_timer += 1
                time = self.cubic_in_out(self.zo_timer / totalFrames)
                i(s, "left", int(((1 - time) * self.zoom_x)))
                i(s, "top", int(((1 - time) * self.zoom_y)))
                i(
                    s,
                    "cx",
                    self.zoom_w + int(time * (self.source_w - self.zoom_w)),
                )
                i(
                    s,
                    "cy",
                    self.zoom_h + int(time * (self.source_h - self.zoom_h)),
                )
                self.update = True
            else:
                i(s, "left", 0)
                i(s, "top", 0)
                i(s, "cx", self.source_w)
                i(s, "cy", self.source_h)
                self.update = False
        else:
            self.resetZO()
            if self.zi_timer < totalFrames:
                self.zi_timer += 1
                time = self.cubic_in_out(self.zi_timer / totalFrames)
                i(s, "left", int(time * self.zoom_x))
                i(s, "top", int(time * self.zoom_y))
                i(
                    s,
                    "cx",
                    self.source_w - int(time * (self.source_w - self.zoom_w)),
                )
                i(
                    s,
                    "cy",
                    self.source_h - int(time * (self.source_h - self.zoom_h)),
                )
                self.update = True if time < 0.8 else False
            else:
                i(s, "left", int(self.zoom_x))
                i(s, "top", int(self.zoom_y))
                i(s, "cx", int(self.zoom_w))
                i(s, "cy", int(self.zoom_h))
                self.update = False

        obs.obs_source_update(crop, s)

        obs.obs_data_release(s)
        obs.obs_source_release(source)
        obs.obs_source_release(crop)
        if (inOut == 0) and (self.zo_timer >= totalFrames):
            obs.remove_current_callback()

    def tracking(self):
        """
        Tracking state function
        """
        if self.lock:
            if self.track or self.update:
                self.follow(get_position())
        self.set_crop(int(self.lock))

    def tick(self):
        """
        Containing function that is run every frame
        """
        self.tracking()


zoom = CursorWindow()


# -------------------------------------------------------------------
def script_description():
    return description


def script_defaults(settings):
    obs.obs_data_set_default_string(settings, "source", "")
    obs.obs_data_set_default_bool(settings, 
        "Manual Monitor Override", False)
    obs.obs_data_set_default_bool(settings, "Manual Offset", False)
    obs.obs_data_set_default_int(settings, "Width", 1280)
    obs.obs_data_set_default_int(settings, "Height", 720)
    obs.obs_data_set_default_double(settings, "Border", 0.15)
    obs.obs_data_set_default_int(settings, "Speed", 160)
    obs.obs_data_set_default_double(settings, "Smooth", 1.0)
    obs.obs_data_set_default_int(settings, "Zoom", 300)
    obs.obs_data_set_default_int(settings, "Manual X Offset", 0)
    obs.obs_data_set_default_int(settings, "Manual Y Offset", 0)


def script_update(settings):
    global new_source

    source_string = obs.obs_data_get_string(settings, "source")
    if source_string == "":
        zoom.source_name = zoom.source_type = ""
        return

    [source, source_type] = source_string.split("||")
    if zoom.source_name != source:
        zoom.source_name = source
        zoom.source_type = source_type
        new_source = True
    if new_source:
        print("Source update")
        zoom.update_sources()
        sources = obs.obs_enum_sources()
        if len(sources) == 0:
            print("No sources, likely OBS startup.")
    else:
        print("Non-initial update")
        zoom.update_source_size()
    print("Source Name: " + zoom.source_name)
    zoom.monitor_override = obs.obs_data_get_bool(settings,
        "Manual Monitor Override")
    zoom.monitor_override_id = obs.obs_data_get_int(settings, "monitor")
    zoom.monitor_size_override = obs.obs_data_get_bool(settings,
        "Manual Monitor Dim")
    if zoom.monitor_size_override:
        zoom.source_w = obs.obs_data_get_int(settings, "Monitor Width")
        zoom.source_h = obs.obs_data_get_int(settings, "Monitor Height")
    zoom.manual_offset = obs.obs_data_get_bool(settings, "Manual Offset")
    zoom.zoom_w = obs.obs_data_get_int(settings, "Width")
    zoom.zoom_h = obs.obs_data_get_int(settings, "Height")
    zoom.active_border = obs.obs_data_get_double(settings, "Border")
    zoom.max_speed = obs.obs_data_get_int(settings, "Speed")
    zoom.smooth = obs.obs_data_get_double(settings, "Smooth")
    zoom.zoom_time = obs.obs_data_get_double(settings, "Zoom")
    if zoom.monitor_size_override or zoom.manual_offset:
        zoom.source_x_override = obs.obs_data_get_int(settings,
            "Manual X Offset")
        zoom.source_y_override = obs.obs_data_get_int(settings,
            "Manual Y Offset")
    else:
        zoom.source_x_override = 0
        zoom.source_y_override = 0


def populate_list_property_with_source_names(list_property):
    global new_source
    
    print("Updating Source List")
    zoom.update_sources()
    sources = obs.obs_enum_sources()
    print(f"System: {system()}")
    if sources is not None:
        obs.obs_property_list_clear(list_property)
        obs.obs_property_list_add_string(list_property, "", "")
        for source in sources:
            if system() == "Darwin":
                print(f"{obs.obs_source_get_name(source)} | {source}")
            source_type = obs.obs_source_get_id(source)
            print("DEBUG:", source_type)
            if source_type in { "monitor_capture", "window_capture",
                                "game_capture","xshm_input", "display_capture" }:
                name_val = name = obs.obs_source_get_name(source)
                name = name + "||" + source_type
                obs.obs_property_list_add_string(list_property, name_val, name)
    obs.source_list_release(sources)
    new_source = True
    print(f"New source: {str(new_source)}")


def populate_list_property_with_monitors(list_property):
    print("Updating Monitor List")
    if zoom.monitors is not None:
        obs.obs_property_list_clear(list_property)
        obs.obs_property_list_add_int(list_property, "", -1)
        monitor_index = 0
        for monitor in zoom.monitors:
            screen_size = pwc.getScreenSize(monitor)
            obs.obs_property_list_add_int(list_property,
                f"{monitor}: {screen_size.width} x {screen_size.height}",
                monitor_index)
            monitor_index += 1
    print("Monitor override list updated")


def callback(props, prop, *args):
    prop_name = obs.obs_property_name(prop)
    monitor_override = obs.obs_properties_get(props, "Manual Monitor Override")
    monitor_size_override = obs.obs_properties_get(props, "Manual Monitor Dim")
    refresh_monitor = obs.obs_properties_get(props, "Refresh monitors")
    source_type = zoom.source_type
    if prop_name == "source":
        if source_type in {'monitor_capture', 'display_capture'}:
            obs.obs_property_set_visible(monitor_override,True)
            obs.obs_property_set_visible(refresh_monitor,True)
            obs.obs_property_set_visible(monitor_size_override,True)
        else:
            obs.obs_property_set_visible(monitor_override,False)
            obs.obs_property_set_visible(refresh_monitor,False)
            obs.obs_property_set_visible(monitor_size_override,False)
    obs.obs_property_set_visible(
        obs.obs_properties_get(props, "Monitor Width"),
        zoom.monitor_size_override)
    obs.obs_property_set_visible(
        obs.obs_properties_get(props, "Monitor Height"),
        zoom.monitor_size_override)
    obs.obs_property_set_visible(
        obs.obs_properties_get(props, "Manual X Offset"),
        zoom.manual_offset)
    obs.obs_property_set_visible(
        obs.obs_properties_get(props, "Manual Y Offset"),
        zoom.manual_offset)
    monitor = obs.obs_properties_get(props, "monitor")
    obs.obs_property_set_visible(monitor,zoom.monitor_override
        and obs.obs_property_visible(monitor_override))
    return True


def script_properties():
    props = obs.obs_properties_create()

    zs = obs.obs_properties_add_list(
        props,
        "source",
        "Zoom Source",
        obs.OBS_COMBO_TYPE_LIST,
        obs.OBS_COMBO_FORMAT_STRING,
    )
    populate_list_property_with_source_names(zs)

    obs.obs_properties_add_button(props, "Refresh sources",
        "Refresh list of sources",
    lambda props,prop: True if callback(props, zs) else True)
    
    monitor_override = obs.obs_properties_add_bool(props,
        "Manual Monitor Override", "Enable Monitor Override")
    
    m = obs.obs_properties_add_list(
        props,
        "monitor",
        "Monitor Override",
        obs.OBS_COMBO_TYPE_LIST,
        obs.OBS_COMBO_FORMAT_INT,
    )
    populate_list_property_with_monitors(m)

    rm = obs.obs_properties_add_button(props,
        "Refresh monitors", "Refresh list of monitors", lambda props,
        prop: True if callback(props, zs) else True)

    mon_size = obs.obs_properties_add_bool(props,
        "Manual Monitor Dim", "Enable Manual Monitor Dimensions")

    mon_w = obs.obs_properties_add_int(props,
        "Monitor Width", "Manual Monitor Width", -8000, 8000, 1)
    mon_h = obs.obs_properties_add_int(props,
        "Monitor Height", "Manual Monitor Height", -8000, 8000, 1)

    offset = obs.obs_properties_add_bool(props,
        "Manual Offset", "Enable Manual Offset")

    mx = obs.obs_properties_add_int(props,
        "Manual X Offset", "Manual X Offset", -8000, 8000, 1)
    my = obs.obs_properties_add_int(props,
        "Manual Y Offset", "Manual Y Offset", -8000, 8000, 1)

    obs.obs_properties_add_int(props,
        "Width", "Zoom Window Width", 320, 3840, 1)
    obs.obs_properties_add_int(props,
        "Height", "Zoom Window Height", 240, 3840, 1)
    obs.obs_properties_add_float_slider(props,
        "Border", "Active Border", 0, 0.5, 0.01)
    obs.obs_properties_add_int(props,
        "Speed", "Max Scroll Speed", 0, 540, 10)
    obs.obs_properties_add_float_slider(props,
        "Smooth", "Smooth", 0, 10, 1.00)
    obs.obs_properties_add_int_slider(props,
        "Zoom", "Zoom Duration (ms)", 0, 1000, 1)

    mon_show = (True if
        zoom.source_type in { 'monitor_capture', 'display_capture' }
        else False)
        
    obs.obs_property_set_visible(monitor_override, mon_show)
    obs.obs_property_set_visible(m, zoom.monitor_override)
    obs.obs_property_set_visible(rm, zoom.monitor_override)
    obs.obs_property_set_visible(mon_h, zoom.monitor_override)
    obs.obs_property_set_visible(mon_w, zoom.monitor_override)
    obs.obs_property_set_visible(mx, zoom.manual_offset)
    obs.obs_property_set_visible(my, zoom.manual_offset)
    
    obs.obs_property_set_modified_callback(zs, callback)
    obs.obs_property_set_modified_callback(monitor_override, callback)
    obs.obs_property_set_modified_callback(mon_size, callback)
    obs.obs_property_set_modified_callback(offset, callback)
    return props


def script_load(settings):
    global zoom_id_tog

    load_settings = loads(obs.obs_data_get_json(settings))
    try:
        [source, source_type] = load_settings['source'].split("||")
        [zoom.source_name, zoom.source_type] = [source, source_type]
    except:
        print(f"Key 'source' does not exist | {load_settings}")

    zoom_id_tog = obs.obs_hotkey_register_frontend(
        ZOOM_NAME_TOG, ZOOM_DESC_TOG, toggle_zoom
    )
    hotkey_save_array = obs.obs_data_get_array(settings, ZOOM_NAME_TOG)
    obs.obs_hotkey_load(zoom_id_tog, hotkey_save_array)
    obs.obs_data_array_release(hotkey_save_array)

    global follow_id_tog
    follow_id_tog = obs.obs_hotkey_register_frontend(
        FOLLOW_NAME_TOG, FOLLOW_DESC_TOG, toggle_follow
    )
    hotkey_save_array = obs.obs_data_get_array(settings, FOLLOW_NAME_TOG)
    obs.obs_hotkey_load(follow_id_tog, hotkey_save_array)
    obs.obs_data_array_release(hotkey_save_array)
    zoom.update_sources()
    zoom.new_source = True


def script_unload():
    obs.obs_hotkey_unregister(toggle_zoom)
    obs.obs_hotkey_unregister(toggle_follow)


def script_save(settings):
    hotkey_save_array = obs.obs_hotkey_save(zoom_id_tog)
    obs.obs_data_set_array(settings, ZOOM_NAME_TOG, hotkey_save_array)
    obs.obs_data_array_release(hotkey_save_array)

    hotkey_save_array = obs.obs_hotkey_save(follow_id_tog)
    obs.obs_data_set_array(settings, FOLLOW_NAME_TOG, hotkey_save_array)
    obs.obs_data_array_release(hotkey_save_array)


def toggle_zoom(pressed):
    if pressed:
        if new_source:
            zoom.update_sources()
        if zoom.source_name != "" and zoom.flag:
            zoom.update_source_size()
            obs.timer_add(zoom.tick, zoom.refresh_rate)
            zoom.lock = True
            zoom.flag = False
        elif not zoom.flag:
            zoom.flag = True
            zoom.lock = False
        print(f"Zoom: {zoom.lock}")
        if zoom.lock:
            print(f"Mouse position: {get_position()}")


def toggle_follow(pressed):
    if pressed:
        if zoom.track:
            zoom.track = False
        elif not zoom.track:
            zoom.track = True
        print(f"Tracking: {zoom.track}")
