diff --git a/css-map.json b/css-map.json
index 73a86e387d..ed66e89b30 100644
--- a/css-map.json
+++ b/css-map.json
@@ -1218,5 +1218,20 @@
"N5cWYDvyLrfnyMZuqQHo": "npv-nowPlayingBar-left",
"gIobRDHAxkAvUaF4_OOL": "npv-nowPlayingBar-center",
"FTi9QEhetf4Q4__5sb4S": "npv-nowPlayingBar-right",
- "Gw7E7MkWci1ttQhb4EK0": "npv-exitFullScreenButton-button"
+ "Gw7E7MkWci1ttQhb4EK0": "npv-exitFullScreenButton-button",
+ "KAq2kDjXj2VS4eXrFL4i": "main-userWidget-box",
+ "LKfKy7bXKmlkMEANVJMS": "main-avatar-avatar",
+ "nRSfonXHVr6utXYgk2Ui": "main-globalNav-mainNav",
+ "Z6t_8rA6LOBrX3huqRJG": "main-globalNav-historyButtonsContainer",
+ "VizXsWMIuNfKGN5pMyox": "main-globalNav-historyButtons",
+ "rBX1EWVZ2EaPwP4y1Gkd": "main-globalNav-icon",
+ "W02pQCvfy5Bin7z4EAzo": "main-globalNav-searchSection",
+ "QrpHSphgBSqzODEHqr_t": "main-globalNav-searchContainer",
+ "jdlOKroADlFeZZQeTdp8": "main-globalNav-link-icon",
+ "PvsV2JgJRDME1vDn6IJL": "main-globalNav-searchInputSection",
+ "fksI89zEXwqKWm1O6sJm": "main-globalNav-searchInputContainer",
+ "OomFKn3bsxs5JfNUoWhz": "main-globalNav-buddyFeed",
+ "rQ6LXqVlEOGZdGIG0LgP": "main-contextMenu-menuItem",
+ "mWj8N7D_OlsbDgtQx5GW": "main-contextMenu-menuItemButton",
+ "NbcaczStd8vD2rHWwaKv": "main-contextMenu-menu"
}
diff --git a/jsHelper/expFeatures.js b/jsHelper/expFeatures.js
index dbe730e225..289fc2ab69 100644
--- a/jsHelper/expFeatures.js
+++ b/jsHelper/expFeatures.js
@@ -273,7 +273,7 @@ ${Spicetify.SVGIcons.search}
new Spicetify.Menu.Item(
"Experimental features",
- true,
+ false,
() => {
Spicetify.PopupModal.display({
title: "Experimental features",
diff --git a/jsHelper/spicetifyWrapper.js b/jsHelper/spicetifyWrapper.js
index 068e0841bb..9cef546094 100644
--- a/jsHelper/spicetifyWrapper.js
+++ b/jsHelper/spicetifyWrapper.js
@@ -429,6 +429,9 @@ window.Spicetify = {
} catch {}
});
const functionModules = modules.filter(module => typeof module === "function");
+ const exportedReactObjects = Object.groupBy(modules.filter(Boolean), x => x.$$typeof);
+ const exportedMemos = exportedReactObjects[Symbol.for("react.memo")];
+ const exportedMemoFRefs = exportedMemos.filter(m => m.type.$$typeof === Symbol.for("react.forward_ref"));
const knownMenuTypes = ["album", "show", "artist", "track"];
const menus = modules
@@ -551,6 +554,7 @@ window.Spicetify = {
Routes: functionModules.find(m => m.toString().match(/\([\w$]+\)\{let\{children:[\w$]+,location:[\w$]+\}=[\w$]+/)),
Route: functionModules.find(m => m.toString().match(/^function [\w$]+\([\w$]+\)\{\(0,[\w$]+\.[\w$]+\)\(\!1\)\}$/)),
StoreProvider: functionModules.find(m => m.toString().includes("notifyNestedSubs") && m.toString().includes("serverState")),
+ Navigation: exportedMemoFRefs.find(m => m.type.render.toString().includes("navigationalRoot")),
...Object.fromEntries(menus)
},
ReactHook: {
@@ -848,6 +852,7 @@ window.Spicetify = {
})();
Spicetify.Events.webpackLoaded.fire();
+ refreshNavLinks?.();
})();
Spicetify.Events = (() => {
@@ -1391,6 +1396,26 @@ Spicetify.SVGIcons = {
return document.head.appendChild(fontStyle);
})();
+function parseIcon(icon, size = 16) {
+ if (icon && Spicetify.SVGIcons[icon]) {
+ return ``;
+ }
+ return icon || "";
+}
+
+function createIconComponent(icon, size = 16) {
+ return Spicetify.React.createElement(
+ Spicetify.ReactComponent.IconComponent,
+ {
+ iconSize: size,
+ dangerouslySetInnerHTML: {
+ __html: parseIcon(icon)
+ }
+ },
+ null
+ );
+}
+
Spicetify.ContextMenuV2 = (() => {
const registeredItems = new Map();
@@ -1409,26 +1434,6 @@ Spicetify.ContextMenuV2 = (() => {
return [uris, uids, contextUri];
}
- function parseIcon(icon) {
- if (icon && Spicetify.SVGIcons[icon]) {
- return ``;
- }
- return icon || "";
- }
-
- function createIconComponent(icon) {
- return Spicetify.React.createElement(
- Spicetify.ReactComponent.IconComponent,
- {
- iconSize: "16",
- dangerouslySetInnerHTML: {
- __html: parseIcon(icon)
- }
- },
- null
- );
- }
-
// these classes bridge the gap between react and js, insuring reactivity
class Item {
constructor({ children, disabled = false, leadingIcon, trailingIcon, divider, onClick, shouldAdd = () => true }) {
@@ -1753,47 +1758,27 @@ Spicetify.ContextMenu = (() => {
return { Item, SubMenu };
})();
-Spicetify._cloneSidebarItem = (list, isLibX = false) => {
- function findChild(parent, key, value) {
- if (!parent.props) {
- return null;
- }
-
- if (value && parent.props[key]?.includes(value)) {
- return parent;
- }
- if (!parent.props.children) {
- return null;
- }
- if (Array.isArray(parent.props.children)) {
- for (const child of parent.props.children) {
- const ele = findChild(child, key, value);
- if (ele) {
- return ele;
- }
- }
- } else if (parent.props.children) {
- return findChild(parent.props.children, key, value);
- }
- return null;
- }
-
- function conditionalAppend(baseClassname, activeClassname, location) {
- if (Spicetify.Platform?.History?.location?.pathname.startsWith(location)) {
- return `${baseClassname} ${activeClassname}`;
- }
+let navLinkFactoryCtx = null;
+let refreshNavLinks = null;
- return baseClassname;
- }
+Spicetify._renderNavLinks = (list, isTouchScreenUi) => {
+ const [refreshCount, refresh] = Spicetify.React.useReducer(x => x + 1, 0);
+ refreshNavLinks = refresh;
- if (!Spicetify.React) {
- setTimeout(Spicetify._cloneSidebarItem, 10, list, isLibX);
+ if (
+ !Spicetify.ReactComponent.ButtonTertiary ||
+ !Spicetify.ReactComponent.Navigation ||
+ !Spicetify.ReactComponent.TooltipWrapper ||
+ !Spicetify.Platform.History ||
+ !Spicetify.Platform.LocalStorageAPI
+ ) {
return;
}
- const React = Spicetify.React;
- const reactObjs = [];
- const sidebarIsCollapsed = Spicetify.Platform?.LocalStorageAPI?.getItem?.("ylx-sidebar-state") === 1;
+ const navLinkFactory = isTouchScreenUi ? NavLinkGlobal : NavLinkSidebar;
+
+ if (!navLinkFactoryCtx) navLinkFactoryCtx = Spicetify.React.createContext(null);
+ const registered = [];
for (const app of list) {
let manifest;
@@ -1815,90 +1800,69 @@ Spicetify._cloneSidebarItem = (list, isLibX = false) => {
}
const icon = manifest.icon || "";
const activeIcon = manifest["active-icon"] || icon;
+ const appRoutePath = `/${app}`;
+ registered.push({ appProper, appRoutePath, icon, activeIcon });
+ }
- const appLink = `/${app}`;
- let obj;
- let link;
-
- if (isLibX) {
- link = findChild(Spicetify._sidebarXItemToClone, "className", "main-yourLibraryX-navLink");
- obj = React.cloneElement(
- Spicetify._sidebarXItemToClone,
- null,
- React.cloneElement(
- Spicetify._sidebarXItemToClone.props.children,
- {
- label: sidebarIsCollapsed ? appProper : ""
- },
- React.cloneElement(
- link,
- {
- to: appLink,
- isActive: (e, { pathname: t }) => t.startsWith(appLink),
- className: conditionalAppend("link-subtle main-yourLibraryX-navLink", "main-yourLibraryX-navLinkActive", appLink)
- },
- React.createElement(Spicetify.ReactComponent.IconComponent, {
- className: "home-icon",
- iconSize: "24",
- dangerouslySetInnerHTML: {
- __html: icon
- }
- }),
- React.createElement(Spicetify.ReactComponent.IconComponent, {
- className: "home-active-icon",
- iconSize: "24",
- dangerouslySetInnerHTML: {
- __html: activeIcon
- }
- }),
- !sidebarIsCollapsed &&
- React.createElement(
- Spicetify.ReactComponent.TextComponent,
- {
- variant: "balladBold"
- },
- appProper
- )
- )
- )
- );
- } else {
- link = findChild(Spicetify._sidebarItemToClone, "className", "main-navBar-navBarLink");
- obj = React.cloneElement(
- Spicetify._sidebarItemToClone,
- null,
- React.cloneElement(
- link,
- {
- to: appLink,
- isActive: (e, { pathname: t }) => t.startsWith(appLink),
- className: conditionalAppend("link-subtle main-navBar-navBarLink", "main-navBar-navBarLinkActive", appLink)
- },
- React.createElement("div", {
- className: "icon collection-icon",
- dangerouslySetInnerHTML: {
- __html: icon
- }
- }),
- React.createElement("div", {
- className: "icon collection-active-icon",
- dangerouslySetInnerHTML: {
- __html: activeIcon
- }
- }),
- React.createElement(
- "span",
- {
- className: "ellipsis-one-line main-type-mestoBold"
- },
- appProper
- )
- )
- );
- }
- reactObjs.push(obj);
+ return Spicetify.React.createElement(
+ navLinkFactoryCtx.Provider,
+ { value: navLinkFactory },
+ registered.map(NavLinkElement => Spicetify.React.createElement(NavLink, NavLinkElement, null))
+ );
+};
+
+const NavLink = ({ appProper, appRoutePath, icon, activeIcon }) => {
+ const isActive = Spicetify.Platform.History.location.pathname?.startsWith(appRoutePath);
+ const createIcon = () => createIconComponent(isActive ? activeIcon : icon, 24);
+
+ const NavLinkFactory = Spicetify.React.useContext(navLinkFactoryCtx);
+ if (!NavLinkFactory) {
+ return;
}
- return reactObjs;
+
+ return Spicetify.React.createElement(NavLinkFactory, { appProper, appRoutePath, createIcon, isActive }, null);
+};
+
+const NavLinkSidebar = ({ appProper, appRoutePath, createIcon, isActive }) => {
+ const isSidebarCollapsed = Spicetify.Platform.LocalStorageAPI.getItem("ylx-sidebar-state") === 1;
+
+ return Spicetify.React.createElement(
+ "li",
+ { className: "main-yourLibraryX-navItem InvalidDropTarget" },
+ Spicetify.React.createElement(
+ Spicetify.ReactComponent.TooltipWrapper,
+ { label: isSidebarCollapsed ? appProper : null, disabled: !isSidebarCollapsed, placement: "right" },
+ Spicetify.React.createElement(
+ Spicetify.ReactComponent.Navigation,
+ {
+ to: appRoutePath,
+ referrer: "other",
+ className: Spicetify.classnames("link-subtle", "main-yourLibraryX-navLink", {
+ "main-yourLibraryX-navLinkActive": isActive
+ }),
+ onClick: () => undefined,
+ "aria-label": appProper
+ },
+ createIcon(),
+ !isSidebarCollapsed && Spicetify.React.createElement(Spicetify.ReactComponent.TextWrapper, { variant: "bodyMediumBold" }, appProper)
+ )
+ )
+ );
+};
+
+const NavLinkGlobal = ({ appProper, appRoutePath, createIcon, isActive }) => {
+ return Spicetify.React.createElement(
+ Spicetify.ReactComponent.TooltipWrapper,
+ { label: appProper },
+ Spicetify.React.createElement(Spicetify.ReactComponent.ButtonTertiary, {
+ iconOnly: createIcon,
+ className: Spicetify.classnames("bWBqSiXEceAj1SnzqusU", "main-globalNav-link-icon", "cUwQnQoE3OqXqSYLT0hv", {
+ voA9ZoTTlPFyLpckNw3S: isActive
+ }),
+ "aria-label": appProper,
+ onClick: () => Spicetify.Platform.History.push(appRoutePath)
+ })
+ );
};
class _HTMLGenericModal extends HTMLElement {
diff --git a/src/apply/apply.go b/src/apply/apply.go
index 3daede5fd0..e7137199b5 100644
--- a/src/apply/apply.go
+++ b/src/apply/apply.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "regexp"
"strings"
"github.com/spicetify/spicetify-cli/src/utils"
@@ -288,19 +289,7 @@ func insertCustomApp(jsPath string, flags Flag) {
return fmt.Sprintf("%s%s", appEleMap, submatches[0])
})
- utils.Replace(
- &content,
- `(?:\w+(?:\(\))?\.createElement|\([\w$\.,]+\))\("li",\{className:[\w$\.]+\}?,(?:children:)?[\w$\.,()]+\(\w+,\{uri:"spotify:user:@:collection",to:"/collection"`,
- func(submatches ...string) string {
- return fmt.Sprintf("Spicetify._sidebarItemToClone=%s", submatches[0])
- })
-
- utils.Replace(
- &content,
- `(?:\w+(?:\(\))?\.createElement|\([\w$.,_]+\))\("li",{className:[-\w".${}()?!:, ]+,children:(?:\w+(?:\(\))?\.createElement|\([\w$.,_]+\))\([\w$._]+,{label:[-\w".${}()?!:, ]+,(\w+:[-\w".${}()?!&: ]+,)*children:(?:\w+(?:\(\))?\.createElement|\([\w$.,_]+\))\([\w$._]+,\{to:"/search"`,
- func(submatches ...string) string {
- return fmt.Sprintf("Spicetify._sidebarXItemToClone=%s", submatches[0])
- })
+ content = insertNavLink(content, appNameArray)
utils.ReplaceOnce(
&content,
@@ -309,36 +298,6 @@ func insertCustomApp(jsPath string, flags Flag) {
return fmt.Sprintf("%s%s", submatches[0], cssEnableMap)
})
- sidebarItemMatch := utils.SeekToCloseParen(
- content,
- `\("li",\{className:[\w$\.]+\}?,(?:children:)?[\w$\.,()]+\(\w+,\{uri:"spotify:user:@:collection",to:"/collection"`,
- '(', ')')
-
- // Prevent breaking on future Spotify update
- if sidebarItemMatch != "" {
- content = strings.Replace(
- content,
- sidebarItemMatch,
- sidebarItemMatch+",Spicetify._cloneSidebarItem(["+appNameArray+"])",
- 1)
- }
-
- sidebarXItemMatch := utils.SeekToCloseParen(
- content,
- `\("li",{className:[-\w".${}()?!:, ]+,children:(?:\w+(?:\(\))?\.createElement|\([\w$.,_]+\))\([\w$._]+,{label:[-\w".${}()?!:, ]+,(\w+:[-\w".${}()?!&: ]+,)*children:(?:\w+(?:\(\))?\.createElement|\([\w$.,_]+\))\([\w$._]+,\{to:"/search"`,
- '(', ')')
-
- // Prevent breaking on future Spotify update
- if sidebarXItemMatch != "" {
- content = strings.Replace(
- content,
- sidebarXItemMatch,
- sidebarXItemMatch+",Spicetify._cloneSidebarItem(["+appNameArray+"],true)",
- 1)
- } else {
- utils.PrintWarning("Sidebar X item not found, ignoring")
- }
-
if flags.SidebarConfig {
utils.ReplaceOnce(
&content,
@@ -361,6 +320,41 @@ func insertCustomApp(jsPath string, flags Flag) {
})
}
+func findMatchingPos(str string, start int, direction int, pair []string, scopes int) int {
+ l := scopes
+ i := start + direction
+
+ for l > 0 {
+ c := string(str[i])
+ i += direction
+ if c == pair[0] {
+ l++
+ } else if c == pair[1] {
+ l--
+ }
+ }
+
+ return i
+}
+
+func insertNavLink(str string, appNameArray string) string {
+ // Library X
+ re := regexp.MustCompile(`\("li",\{[^\{]*\{[^\{]*\{to:"\/search`)
+ loc := re.FindStringIndex(str)
+ if loc == nil {
+ return str
+ }
+ index := findMatchingPos(str, loc[0], 1, []string{"(", ")"}, 1)
+ str = str[:index] + ",Spicetify._renderNavLinks([" + appNameArray + "], false)," + str[index:]
+
+ // Global Navbar
+ utils.ReplaceOnce(&str, `(,[a-zA-Z_\$][\w\$]*===(?:[a-zA-Z_\$][\w\$]*\.){2}HOME_NEXT_TO_NAVIGATION&&.+?)\]`, func(submatches ...string) string {
+ return submatches[1] + ",Spicetify._renderNavLinks([" + appNameArray + "], true)]"
+ })
+
+ return str
+}
+
func insertHomeConfig(jsPath string, flags Flag) {
if !flags.HomeConfig {
return
diff --git a/src/utils/utils.go b/src/utils/utils.go
index 2298b7385b..a4e18ba841 100644
--- a/src/utils/utils.go
+++ b/src/utils/utils.go
@@ -179,7 +179,9 @@ func ReplaceOnce(str *string, pattern string, repl func(submatches ...string) st
if firstMatch {
firstMatch = false
submatches := re.FindStringSubmatch(match)
- return repl(submatches...)
+ if submatches != nil {
+ return repl(submatches...)
+ }
}
return match
})