From 8f158e4656d5003e38106f2e0e9d79dc8323217b Mon Sep 17 00:00:00 2001
From: Erica <tolvukisa@gmail.com>
Date: Sun, 21 Nov 2021 01:16:03 +0000
Subject: [PATCH] Added theming and auto color picking. - Added a config
 section called "theme" - Added config values `accent_color`, `bar_color`,
 `time_color`, `bar_head` and `bar_body` - Added a `color_update_needed` flag
 - Added a function `getDominantColor` to get the dominant color from the
 current album art - Added a separate color pair for the time stamp - Fixed
 variable error referenced before assignment - Changed default progress bar
 look

---
 bin/miniplayer | 214 +++++++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 200 insertions(+), 14 deletions(-)

diff --git a/bin/miniplayer b/bin/miniplayer
index 1d7e496..a9c76f1 100755
--- a/bin/miniplayer
+++ b/bin/miniplayer
@@ -10,6 +10,8 @@ import time
 import configparser
 import ueberzug.lib.v0 as ueberzug
 from PIL import Image, ImageDraw
+from colorthief import ColorThief
+import math
 
 
 # Get config
@@ -33,7 +35,15 @@ if "art" not in config.sections():
 if "mpd" not in config.sections():
     config["mpd"] = {"host": "localhost",
                      "port": "6600",
-                     }
+                    }
+
+if "theme" not in config.sections():
+    config["theme"] = {"accent_color": "yellow",
+                       "bar_color":    "green",
+                       "time_color":   "yellow",
+                       "bar_body":     "─",
+                       "bar_head":     "╶"
+                      }
 
 # Initialise keybindings
 default_bindings = {">":     "next_track",
@@ -57,6 +67,7 @@ deprecated_field_notice = ("The config option `{field}` under the `player` "
                            "section is deprecated and will be removed in the "
                            "future. Use the config option `{field}` under the "
                            "`art` section instead.")
+
 deprecated_fields_present = False
 for field in ["music_directory", "image_method"]:
     if config.has_option("player", field):
@@ -82,8 +93,9 @@ for key, action in default_bindings.items():
         keybindings[key] = action
 
 player_config = config["player"]
-art_config = config["art"]
-mpd_config = config["mpd"]
+art_config    = config["art"]
+mpd_config    = config["mpd"]
+theme_config  = config["theme"]
 
 
 # FPS
@@ -132,11 +144,35 @@ def albumArtSize(album_space, window_width):
     return image_width_px, image_width, image_height
 
 
+def colorNameToCurses(name):
+    """
+    A function that takes in the name of a color and returns
+    the corresponding curses.COLOR_{name}
+    """
 
+    converter = {
+        "black":    0,
+        "red":      1,
+        "green":    2,
+        "yellow":   3,
+        "blue":     4,
+        "magenta":  5,
+        "cyan":     6,
+        "white":    -1,
+        "auto":     "auto"
+    }
+
+    if name not in converter.keys():
+        return None
+    else:
+        return converter[name]
 
 
 class Player:
     def __init__(self):
+        # Error variable
+        self.error = None
+
         # Curses initialisation
         self.stdscr = curses.initscr()
         self.stdscr.nodelay(True)
@@ -150,8 +186,55 @@ class Player:
 
         curses.start_color()
         curses.use_default_colors()
-        curses.init_pair(1, curses.COLOR_GREEN, -1)
-        curses.init_pair(2, curses.COLOR_YELLOW, -1)
+
+        # Color initialisation
+        self.AUTO_COLOR = curses.COLOR_WHITE
+
+        
+        # Figure out which pairs we need to auto color
+        self.auto_pairs = []
+
+        color_array = [
+            theme_config.get("accent_color", "yellow"),
+            theme_config.get("time_color",   "yellow"), 
+            theme_config.get("bar_color",    "green" )
+        ]
+
+        i = 0
+        while i < len(color_array):
+            # convert color
+            color = color_array[i]
+            color = colorNameToCurses(color)
+
+            # check for auto color
+            if color == "auto":
+                color = self.AUTO_COLOR
+                self.auto_pairs.append(i+1)     
+
+            # Verify that the color is ok
+            if color is None:
+                self.error = f"'{color_array[i]}' is not a valid color!"
+                raise ValueError(self.error)
+
+            color_array[i] = color
+
+            i += 1
+
+        # Init pairs
+        curses.init_pair(1, color_array[0],     -1) # accent color
+        curses.init_pair(2, color_array[1],     -1) # time color
+        curses.init_pair(3, color_array[2],     -1) # bar color
+
+        # Get progress bar characters
+        self.bar_body = theme_config.get("bar_body", "─")
+        self.bar_head = theme_config.get("bar_head", "╶")
+
+        # Check if bar head and bar body are of proper length
+        if len(self.bar_body) != 1:
+            raise ValueError("Progress bar body must be 1 character long! Not '{bar_body}'")
+    
+        if len(self.bar_head) != 1:
+            raise ValueError("Progress bar head must be 1 character long! Not '{bar_head}'")
 
         # MPD init
         self.client = MPDClient()
@@ -214,6 +297,9 @@ class Player:
         # Update needed flag
         self.update_needed = False
 
+        # Color update needed flag
+        self.color_update_needed = bool(self.auto_pairs)
+
         # Flag to check if any music has been played
         self.has_music_been_played = False
 
@@ -224,6 +310,98 @@ class Player:
         self.selected_song = 0
 
 
+    def getDominantColor(self):
+        """
+        A function that finds the dominant color in the album art,
+        finds the closest curses color to that and sets the foreground
+        color.
+        """
+
+        COLOR_COUNT = 5
+
+        def colorScore(r, g, b):
+            """
+            A function that takes in the RGB representation of a
+            color and assigns it a score based on its perceived
+            brightness and saturation.
+
+            The score is calculated as follows:
+                1. Convert the RGB values to HSV saturation
+                2. Calculate brightness with
+                        0.2126*r + 0.7152*g + 0.0722*b
+                3. Check if either the brightness is below
+                   a threshold or the color is not very
+                   saturated. If so, assign it a score of
+                        mean(saturation, brightness) / 100
+                4. Otherwise, assign it a score of
+                        sqrt(brightness^2 * saturation)
+            """
+            # Map RGB to [0,1]
+            r /= 255
+            g /= 255
+            b /= 255
+
+            maximum = max(r, g, b)
+            minimum = min(r, g, b)
+
+            # Calculate saturation
+            saturation = 0
+            if maximum > 0:
+                saturation = (maximum - minimum) / (maximum)
+
+            # Calculate brightness
+            brightness = (0.2126*r + 0.7152*g + 0.0722*b)
+
+            # Brightness and saturation thresholds
+            if brightness < 0.35 or saturation < 0.1:
+                return 0.01 * (brightness + saturation) / 2
+            else:
+                return math.sqrt(brightness**2 * saturation)
+
+        def weight(x):
+            """
+            A weight function to transform the color scores based
+            on their frequency
+            """
+            return 1/(x+1)**2
+            
+
+        # Init thief
+        color_thief = ColorThief(self.album_art_loc)
+
+        # Get palette
+        try:
+            palette = color_thief.get_palette(color_count=COLOR_COUNT, quality=100)
+        except OSError:
+            self.color_update_needed = True
+            return
+
+        # Create weight list and assign weights
+        weight_list = [weight(x/COLOR_COUNT) for x in range(COLOR_COUNT)]
+        palette = list(zip(weight_list, palette))
+
+        # Sort palette based on score
+        palette = sorted(
+            palette, 
+            reverse=True, 
+            key=lambda x: x[0]*colorScore(*x[1])
+        )
+
+        # Get the dominant color
+        dominant_color = palette[0][1]
+
+        # Cast the color to curses range
+        col = [round(1000 * val/255) for val in dominant_color]
+
+        # Set the color
+        curses.init_color(self.AUTO_COLOR, *col)
+        
+        for pair in self.auto_pairs:
+            curses.init_pair(pair, self.AUTO_COLOR, -1)
+
+        self.color_update_needed = False
+
+
     def playlistFits(self, height, width):
         """
         A function that checks if the playlist display should be drawn
@@ -487,6 +665,10 @@ class Player:
             self.album, self.artist, self.title = self.getSongInfo(song)
             self.getAlbumArt(song["file"])
             self.last_song = song
+            
+            # Update dominant color
+            if self.auto_pairs:
+                self.getDominantColor()
 
             return 0
 
@@ -645,8 +827,8 @@ class Player:
 
         self.art_win.addstr(
             self.text_start + 2, 0,
-            "-"*(int((self.art_window_width - 1) * self.progress)) + ">",
-            curses.color_pair(1)
+            self.bar_body*(int((self.art_window_width - 1) * self.progress)) + self.bar_head,
+            curses.color_pair(3)
         )
 
         # Duration string
@@ -703,10 +885,10 @@ class Player:
             pair = 0
 
             if playlist_item == current_song:
-                pair = curses.color_pair(2)
+                pair = curses.color_pair(1)
 
             if playlist_item == playlist[selected_pos]:
-                pair = curses.color_pair(2) | curses.A_REVERSE
+                pair = curses.color_pair(1) | curses.A_REVERSE
 
             # Move and write text
             try:
@@ -899,6 +1081,9 @@ class Player:
                         self.hideAlbumArt()
                         self.drawHelp()
 
+                    if self.color_update_needed:
+                        self.getDominantColor()
+
                     self.update_needed = False
 
                 # Update control_cycle once a second if it is not 0
@@ -913,22 +1098,23 @@ class Player:
                 i = (i + 1) % FPS
 
         except KeyboardInterrupt:
-            error = False
+            self.error = False or self.error
         except pixcat.terminal.KittyAnswerTimeout:
-            error = "Kitty did not answer in time. Are you using Kitty?"
+            self.error = "Kitty did not answer in time. Are you using Kitty?"
         finally:
             curses.nocbreak()
             curses.endwin()
             self.client.close()
             self.client.disconnect()
-            if error:
-                print(error)
+            if self.error:
+                print(self.error)
 
 
 try:
     player = Player()
     player.loop()
 except ConnectionRefusedError:
+    print(f"Could not connect to mpd on {MPDHOST}:{MPDPORT}")
+finally:
     curses.nocbreak()
     curses.endwin()
-    print(f"Could not connect to mpd on {MPDHOST}:{MPDPORT}")