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 `${Spicetify.SVGIcons[icon]}`; + } + 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 `${Spicetify.SVGIcons[icon]}`; - } - 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 })