├── .gitattributes ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── .gitignore ├── @types └── spicetify.d.ts ├── LICENSE ├── README.md ├── TODO.md ├── adblock ├── README.md ├── adblock.js ├── adblock.ts ├── assets │ └── preview.png └── tsconfig.json ├── biome.json ├── featureshuffle ├── .DS_Store ├── README.md ├── featureshuffle.js └── featureshuffle.png ├── formatColors ├── README.md ├── formatColors.js └── formatColors.png ├── manifest.json ├── old-sidebar ├── README.md └── oldSidebar.js ├── package.json ├── phraseToPlaylist ├── README.md ├── phraseToPlaylist.js ├── phraseToPlaylist.png ├── phraseToPlaylist.tsx └── tsconfig.json ├── pnpm-lock.yaml ├── songstats ├── README.md ├── songstats.js └── songstats.png ├── tsconfig.json ├── wikify ├── README.md ├── wikify.js └── wikify.png └── writeify ├── README.md ├── writeify.js └── writeify.png /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Report errors or unexpected behavior 3 | title: "bug: " 4 | labels: [bug] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: 🔍 Is there already an issue for your problem? 9 | description: Please make sure you are not creating an already submitted Issue. Check closed issues as well, because your issue may have already been fixed. 10 | options: 11 | - label: I have checked older issues, open and closed 12 | required: true 13 | - type: textarea 14 | attributes: 15 | label: ℹ Environment / Computer Info 16 | description: Please provide the details of the system Spicetify is running on. 17 | value: | 18 | - Spotify version: 19 | - Spicetify version: 20 | placeholder: | 21 | - Spotify version: Spotify for Windows (64 bit) 1.2.37.701.ge66eb7bc 22 | - Spicetify version: 2.36.11 23 | render: Markdown 24 | validations: 25 | required: true 26 | - type: input 27 | id: extension 28 | attributes: 29 | label: 📦 Extension name 30 | description: Please provide the name of the extension you are having issues with. 31 | placeholder: ex. adblock 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: 📝 Description 37 | description: List steps to reproduce the error and details on what happens and what you expected to happen. 38 | validations: 39 | required: true 40 | - type: textarea 41 | attributes: 42 | label: 📸 Screenshots 43 | description: Place any screenshots of the issue here if possible 44 | validations: 45 | required: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: ✨ Feature Request 2 | description: Request a new feature or enhancement 3 | title: "request: " 4 | labels: [request] 5 | body: 6 | - type: input 7 | id: extension 8 | attributes: 9 | label: 📦 Extension name 10 | description: Please provide the name of the extension you are having issues with. 11 | placeholder: ex. adblock 12 | - type: textarea 13 | id: description 14 | attributes: 15 | label: 📝 Provide a description of the new feature 16 | description: What is the expected behavior of the proposed feature? What is the scenario this would be used? 17 | validations: 18 | required: true 19 | 20 | - type: textarea 21 | id: additional-information 22 | attributes: 23 | label: ➕ Additional Information 24 | description: Give us some additional information on the feature request like proposed solutions, links, screenshots, etc. 25 | 26 | - type: markdown 27 | attributes: 28 | value: If you'd like to see this feature implemented, add a 👍 reaction to this post. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | node_modules -------------------------------------------------------------------------------- /@types/spicetify.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Spicetify { 2 | type Icon = 3 | | "album" 4 | | "artist" 5 | | "block" 6 | | "brightness" 7 | | "car" 8 | | "chart-down" 9 | | "chart-up" 10 | | "check" 11 | | "check-alt-fill" 12 | | "chevron-left" 13 | | "chevron-right" 14 | | "chromecast-disconnected" 15 | | "clock" 16 | | "collaborative" 17 | | "computer" 18 | | "copy" 19 | | "download" 20 | | "downloaded" 21 | | "edit" 22 | | "enhance" 23 | | "exclamation-circle" 24 | | "external-link" 25 | | "facebook" 26 | | "follow" 27 | | "fullscreen" 28 | | "gamepad" 29 | | "grid-view" 30 | | "heart" 31 | | "heart-active" 32 | | "instagram" 33 | | "laptop" 34 | | "library" 35 | | "list-view" 36 | | "location" 37 | | "locked" 38 | | "locked-active" 39 | | "lyrics" 40 | | "menu" 41 | | "minimize" 42 | | "minus" 43 | | "more" 44 | | "new-spotify-connect" 45 | | "offline" 46 | | "pause" 47 | | "phone" 48 | | "play" 49 | | "playlist" 50 | | "playlist-folder" 51 | | "plus-alt" 52 | | "plus2px" 53 | | "podcasts" 54 | | "projector" 55 | | "queue" 56 | | "repeat" 57 | | "repeat-once" 58 | | "search" 59 | | "search-active" 60 | | "shuffle" 61 | | "skip-back" 62 | | "skip-back15" 63 | | "skip-forward" 64 | | "skip-forward15" 65 | | "soundbetter" 66 | | "speaker" 67 | | "spotify" 68 | | "subtitles" 69 | | "tablet" 70 | | "ticket" 71 | | "twitter" 72 | | "visualizer" 73 | | "voice" 74 | | "volume" 75 | | "volume-off" 76 | | "volume-one-wave" 77 | | "volume-two-wave" 78 | | "watch" 79 | | "x"; 80 | type Variant = 81 | | "bass" 82 | | "forte" 83 | | "brio" 84 | | "altoBrio" 85 | | "alto" 86 | | "canon" 87 | | "celloCanon" 88 | | "cello" 89 | | "ballad" 90 | | "balladBold" 91 | | "viola" 92 | | "violaBold" 93 | | "mesto" 94 | | "mestoBold" 95 | | "metronome" 96 | | "finale" 97 | | "finaleBold" 98 | | "minuet" 99 | | "minuetBold"; 100 | type SemanticColor = 101 | | "textBase" 102 | | "textSubdued" 103 | | "textBrightAccent" 104 | | "textNegative" 105 | | "textWarning" 106 | | "textPositive" 107 | | "textAnnouncement" 108 | | "essentialBase" 109 | | "essentialSubdued" 110 | | "essentialBrightAccent" 111 | | "essentialNegative" 112 | | "essentialWarning" 113 | | "essentialPositive" 114 | | "essentialAnnouncement" 115 | | "decorativeBase" 116 | | "decorativeSubdued" 117 | | "backgroundBase" 118 | | "backgroundHighlight" 119 | | "backgroundPress" 120 | | "backgroundElevatedBase" 121 | | "backgroundElevatedHighlight" 122 | | "backgroundElevatedPress" 123 | | "backgroundTintedBase" 124 | | "backgroundTintedHighlight" 125 | | "backgroundTintedPress" 126 | | "backgroundUnsafeForSmallTextBase" 127 | | "backgroundUnsafeForSmallTextHighlight" 128 | | "backgroundUnsafeForSmallTextPress"; 129 | type ColorSet = 130 | | "base" 131 | | "brightAccent" 132 | | "negative" 133 | | "warning" 134 | | "positive" 135 | | "announcement" 136 | | "invertedDark" 137 | | "invertedLight" 138 | | "mutedAccent" 139 | | "overMedia"; 140 | type ColorSetBackgroundColors = { 141 | base: string; 142 | highlight: string; 143 | press: string; 144 | }; 145 | type ColorSetNamespaceColors = { 146 | announcement: string; 147 | base: string; 148 | brightAccent: string; 149 | negative: string; 150 | positive: string; 151 | subdued: string; 152 | warning: string; 153 | }; 154 | type ColorSetBody = { 155 | background: ColorSetBackgroundColors & { 156 | elevated: ColorSetBackgroundColors; 157 | tinted: ColorSetBackgroundColors; 158 | unsafeForSmallText: ColorSetBackgroundColors; 159 | }; 160 | decorative: { 161 | base: string; 162 | subdued: string; 163 | }; 164 | essential: ColorSetNamespaceColors; 165 | text: ColorSetNamespaceColors; 166 | }; 167 | type Metadata = Partial>; 168 | type ContextTrack = { 169 | uri: string; 170 | uid?: string; 171 | metadata?: Metadata; 172 | }; 173 | type PlayerState = { 174 | timestamp: number; 175 | context: PlayerContext; 176 | index: PlayerIndex; 177 | item: PlayerTrack; 178 | shuffle: boolean; 179 | repeat: number; 180 | speed: number; 181 | positionAsOfTimestamp: number; 182 | duration: number; 183 | hasContext: boolean; 184 | isPaused: boolean; 185 | isBuffering: boolean; 186 | restrictions: Restrictions; 187 | previousItems?: PlayerTrack[]; 188 | nextItems?: PlayerTrack[]; 189 | playbackQuality: PlaybackQuality; 190 | playbackId: string; 191 | sessionId: string; 192 | signals?: any[]; 193 | }; 194 | type PlayerContext = { 195 | uri: string; 196 | url: string; 197 | metadata: { 198 | "player.arch": string; 199 | }; 200 | }; 201 | type PlayerIndex = { 202 | pageURI?: string | null; 203 | pageIndex: number; 204 | itemIndex: number; 205 | }; 206 | type PlayerTrack = { 207 | type: string; 208 | uri: string; 209 | uid: string; 210 | name: string; 211 | mediaType: string; 212 | duration: { 213 | milliseconds: number; 214 | }; 215 | album: Album; 216 | artists?: ArtistsEntity[]; 217 | isLocal: boolean; 218 | isExplicit: boolean; 219 | is19PlusOnly: boolean; 220 | provider: string; 221 | metadata: TrackMetadata; 222 | images?: ImagesEntity[]; 223 | }; 224 | type TrackMetadata = { 225 | artist_uri: string; 226 | entity_uri: string; 227 | iteration: string; 228 | title: string; 229 | "collection.is_banned": string; 230 | "artist_uri:1": string; 231 | "collection.in_collection": string; 232 | image_small_url: string; 233 | "collection.can_ban": string; 234 | is_explicit: string; 235 | album_disc_number: string; 236 | album_disc_count: string; 237 | track_player: string; 238 | album_title: string; 239 | "collection.can_add": string; 240 | image_large_url: string; 241 | "actions.skipping_prev_past_track": string; 242 | page_instance_id: string; 243 | image_xlarge_url: string; 244 | marked_for_download: string; 245 | "actions.skipping_next_past_track": string; 246 | context_uri: string; 247 | "artist_name:1": string; 248 | has_lyrics: string; 249 | interaction_id: string; 250 | image_url: string; 251 | album_uri: string; 252 | album_artist_name: string; 253 | album_track_number: string; 254 | artist_name: string; 255 | duration: string; 256 | album_track_count: string; 257 | popularity: string; 258 | }; 259 | type Album = { 260 | type: string; 261 | uri: string; 262 | name: string; 263 | images?: ImagesEntity[]; 264 | }; 265 | type ImagesEntity = { 266 | url: string; 267 | label: string; 268 | }; 269 | type ArtistsEntity = { 270 | type: string; 271 | uri: string; 272 | name: string; 273 | }; 274 | type Restrictions = { 275 | canPause: boolean; 276 | canResume: boolean; 277 | canSeek: boolean; 278 | canSkipPrevious: boolean; 279 | canSkipNext: boolean; 280 | canToggleRepeatContext: boolean; 281 | canToggleRepeatTrack: boolean; 282 | canToggleShuffle: boolean; 283 | disallowPausingReasons?: string[]; 284 | disallowResumingReasons?: string[]; 285 | disallowSeekingReasons?: string[]; 286 | disallowSkippingPreviousReasons?: string[]; 287 | disallowSkippingNextReasons?: string[]; 288 | disallowTogglingRepeatContextReasons?: string[]; 289 | disallowTogglingRepeatTrackReasons?: string[]; 290 | disallowTogglingShuffleReasons?: string[]; 291 | disallowTransferringPlaybackReasons?: string[]; 292 | }; 293 | type PlaybackQuality = { 294 | bitrateLevel: number; 295 | strategy: number; 296 | targetBitrateLevel: number; 297 | targetBitrateAvailable: boolean; 298 | hifiStatus: number; 299 | }; 300 | namespace Player { 301 | /** 302 | * Register a listener `type` on Spicetify.Player. 303 | * 304 | * On default, `Spicetify.Player` always dispatch: 305 | * - `songchange` type when player changes track. 306 | * - `onplaypause` type when player plays or pauses. 307 | * - `onprogress` type when track progress changes. 308 | * - `appchange` type when user changes page. 309 | */ 310 | function addEventListener(type: string, callback: (event?: Event) => void): void; 311 | function addEventListener(type: "songchange", callback: (event?: Event & { data: PlayerState }) => void): void; 312 | function addEventListener(type: "onplaypause", callback: (event?: Event & { data: PlayerState }) => void): void; 313 | function addEventListener(type: "onprogress", callback: (event?: Event & { data: number }) => void): void; 314 | function addEventListener( 315 | type: "appchange", 316 | callback: ( 317 | event?: Event & { 318 | data: { 319 | /** 320 | * App href path 321 | */ 322 | path: string; 323 | /** 324 | * App container 325 | */ 326 | container: HTMLElement; 327 | }; 328 | } 329 | ) => void 330 | ): void; 331 | /** 332 | * Skip to previous track. 333 | */ 334 | function back(): void; 335 | /** 336 | * An object contains all information about current track and player. 337 | */ 338 | const data: PlayerState; 339 | /** 340 | * Decrease a small amount of volume. 341 | */ 342 | function decreaseVolume(): void; 343 | /** 344 | * Dispatches an event at `Spicetify.Player`. 345 | * 346 | * On default, `Spicetify.Player` always dispatch 347 | * - `songchange` type when player changes track. 348 | * - `onplaypause` type when player plays or pauses. 349 | * - `onprogress` type when track progress changes. 350 | * - `appchange` type when user changes page. 351 | */ 352 | function dispatchEvent(event: Event): void; 353 | const eventListeners: { 354 | [key: string]: Array<(event?: Event) => void>; 355 | }; 356 | /** 357 | * Convert milisecond to `mm:ss` format 358 | * @param milisecond 359 | */ 360 | function formatTime(milisecond: number): string; 361 | /** 362 | * Return song total duration in milisecond. 363 | */ 364 | function getDuration(): number; 365 | /** 366 | * Return mute state 367 | */ 368 | function getMute(): boolean; 369 | /** 370 | * Return elapsed duration in milisecond. 371 | */ 372 | function getProgress(): number; 373 | /** 374 | * Return elapsed duration in percentage (0 to 1). 375 | */ 376 | function getProgressPercent(): number; 377 | /** 378 | * Return current Repeat state (No repeat = 0/Repeat all = 1/Repeat one = 2). 379 | */ 380 | function getRepeat(): number; 381 | /** 382 | * Return current shuffle state. 383 | */ 384 | function getShuffle(): boolean; 385 | /** 386 | * Return track heart state. 387 | */ 388 | function getHeart(): boolean; 389 | /** 390 | * Return current volume level (0 to 1). 391 | */ 392 | function getVolume(): number; 393 | /** 394 | * Increase a small amount of volume. 395 | */ 396 | function increaseVolume(): void; 397 | /** 398 | * Return a boolean whether player is playing. 399 | */ 400 | function isPlaying(): boolean; 401 | /** 402 | * Skip to next track. 403 | */ 404 | function next(): void; 405 | /** 406 | * Pause track. 407 | */ 408 | function pause(): void; 409 | /** 410 | * Resume track. 411 | */ 412 | function play(): void; 413 | /** 414 | * Play a track, playlist, album, etc. immediately 415 | * @param uri Spotify URI 416 | * @param context 417 | * @param options 418 | */ 419 | function playUri(uri: string, context?: any, options?: any): Promise; 420 | /** 421 | * Unregister added event listener `type`. 422 | * @param type 423 | * @param callback 424 | */ 425 | function removeEventListener(type: string, callback: (event?: Event) => void): void; 426 | /** 427 | * Seek track to position. 428 | * @param position can be in percentage (0 to 1) or in milisecond. 429 | */ 430 | function seek(position: number): void; 431 | /** 432 | * Turn mute on/off 433 | * @param state 434 | */ 435 | function setMute(state: boolean): void; 436 | /** 437 | * Change Repeat mode 438 | * @param mode `0` No repeat. `1` Repeat all. `2` Repeat one track. 439 | */ 440 | function setRepeat(mode: number): void; 441 | /** 442 | * Turn shuffle on/off. 443 | * @param state 444 | */ 445 | function setShuffle(state: boolean): void; 446 | /** 447 | * Set volume level 448 | * @param level 0 to 1 449 | */ 450 | function setVolume(level: number): void; 451 | /** 452 | * Seek to previous `amount` of milisecond 453 | * @param amount in milisecond. Default: 15000. 454 | */ 455 | function skipBack(amount?: number): void; 456 | /** 457 | * Seek to next `amount` of milisecond 458 | * @param amount in milisecond. Default: 15000. 459 | */ 460 | function skipForward(amount?: number): void; 461 | /** 462 | * Toggle Heart (Favourite) track state. 463 | */ 464 | function toggleHeart(): void; 465 | /** 466 | * Toggle Mute/No mute. 467 | */ 468 | function toggleMute(): void; 469 | /** 470 | * Toggle Play/Pause. 471 | */ 472 | function togglePlay(): void; 473 | /** 474 | * Toggle No repeat/Repeat all/Repeat one. 475 | */ 476 | function toggleRepeat(): void; 477 | /** 478 | * Toggle Shuffle/No shuffle. 479 | */ 480 | function toggleShuffle(): void; 481 | } 482 | /** 483 | * Adds a track or array of tracks to prioritized queue. 484 | */ 485 | function addToQueue(uri: ContextTrack[]): Promise; 486 | /** 487 | * @deprecated 488 | */ 489 | const BridgeAPI: any; 490 | /** 491 | * @deprecated 492 | */ 493 | const CosmosAPI: any; 494 | /** 495 | * Async wrappers of CosmosAPI 496 | */ 497 | namespace CosmosAsync { 498 | type Method = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT" | "SUB"; 499 | interface Error { 500 | code: number; 501 | error: string; 502 | message: string; 503 | stack?: string; 504 | } 505 | 506 | type Headers = Record; 507 | type Body = Record; 508 | 509 | interface Response { 510 | body: any; 511 | headers: Headers; 512 | status: number; 513 | uri?: string; 514 | } 515 | 516 | function head(url: string, headers?: Headers): Promise; 517 | function get(url: string, body?: Body, headers?: Headers): Promise; 518 | function post(url: string, body?: Body, headers?: Headers): Promise; 519 | function put(url: string, body?: Body, headers?: Headers): Promise; 520 | function del(url: string, body?: Body, headers?: Headers): Promise; 521 | function patch(url: string, body?: Body, headers?: Headers): Promise; 522 | function sub( 523 | url: string, 524 | callback: (b: Response["body"]) => void, 525 | onError?: (e: Error) => void, 526 | body?: Body, 527 | headers?: Headers 528 | ): Promise; 529 | function postSub( 530 | url: string, 531 | body: Body | null, 532 | callback: (b: Response["body"]) => void, 533 | onError?: (e: Error) => void 534 | ): Promise; 535 | function request(method: Method, url: string, body?: Body, headers?: Headers): Promise; 536 | function resolve(method: Method, url: string, body?: Body, headers?: Headers): Promise; 537 | } 538 | /** 539 | * Fetch interesting colors from URI. 540 | * @param uri Any type of URI that has artwork (playlist, track, album, artist, show, ...) 541 | */ 542 | function colorExtractor(uri: string): Promise<{ 543 | DESATURATED: string; 544 | LIGHT_VIBRANT: string; 545 | PROMINENT: string; 546 | VIBRANT: string; 547 | VIBRANT_NON_ALARMING: string; 548 | }>; 549 | /** 550 | * @deprecated 551 | */ 552 | function getAblumArtColors(): any; 553 | /** 554 | * Fetch track analyzed audio data. 555 | * Beware, not all tracks have audio data. 556 | * @param uri is optional. Leave it blank to get current track 557 | * or specify another track uri. 558 | */ 559 | function getAudioData(uri?: string): Promise; 560 | /** 561 | * Set of APIs method to register, deregister hotkeys/shortcuts 562 | */ 563 | namespace Keyboard { 564 | type ValidKey = 565 | | "BACKSPACE" 566 | | "TAB" 567 | | "ENTER" 568 | | "SHIFT" 569 | | "CTRL" 570 | | "ALT" 571 | | "CAPS" 572 | | "ESCAPE" 573 | | "SPACE" 574 | | "PAGE_UP" 575 | | "PAGE_DOWN" 576 | | "END" 577 | | "HOME" 578 | | "ARROW_LEFT" 579 | | "ARROW_UP" 580 | | "ARROW_RIGHT" 581 | | "ARROW_DOWN" 582 | | "INSERT" 583 | | "DELETE" 584 | | "A" 585 | | "B" 586 | | "C" 587 | | "D" 588 | | "E" 589 | | "F" 590 | | "G" 591 | | "H" 592 | | "I" 593 | | "J" 594 | | "K" 595 | | "L" 596 | | "M" 597 | | "N" 598 | | "O" 599 | | "P" 600 | | "Q" 601 | | "R" 602 | | "S" 603 | | "T" 604 | | "U" 605 | | "V" 606 | | "W" 607 | | "X" 608 | | "Y" 609 | | "Z" 610 | | "WINDOW_LEFT" 611 | | "WINDOW_RIGHT" 612 | | "SELECT" 613 | | "NUMPAD_0" 614 | | "NUMPAD_1" 615 | | "NUMPAD_2" 616 | | "NUMPAD_3" 617 | | "NUMPAD_4" 618 | | "NUMPAD_5" 619 | | "NUMPAD_6" 620 | | "NUMPAD_7" 621 | | "NUMPAD_8" 622 | | "NUMPAD_9" 623 | | "MULTIPLY" 624 | | "ADD" 625 | | "SUBTRACT" 626 | | "DECIMAL_POINT" 627 | | "DIVIDE" 628 | | "F1" 629 | | "F2" 630 | | "F3" 631 | | "F4" 632 | | "F5" 633 | | "F6" 634 | | "F7" 635 | | "F8" 636 | | "F9" 637 | | "F10" 638 | | "F11" 639 | | "F12" 640 | | ";" 641 | | "=" 642 | | " | " 643 | | "-" 644 | | "." 645 | | "/" 646 | | "`" 647 | | "[" 648 | | "\\" 649 | | "]" 650 | | '"' 651 | | "~" 652 | | "!" 653 | | "@" 654 | | "#" 655 | | "$" 656 | | "%" 657 | | "^" 658 | | "&" 659 | | "*" 660 | | "(" 661 | | ")" 662 | | "_" 663 | | "+" 664 | | ":" 665 | | "<" 666 | | ">" 667 | | "?" 668 | | "|"; 669 | type KeysDefine = 670 | | string 671 | | { 672 | key: string; 673 | ctrl?: boolean; 674 | shift?: boolean; 675 | alt?: boolean; 676 | meta?: boolean; 677 | }; 678 | const KEYS: Record; 679 | function registerShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void): void; 680 | function registerIsolatedShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void): void; 681 | function registerImportantShortcut(keys: KeysDefine, callback: (event: KeyboardEvent) => void): void; 682 | function _deregisterShortcut(keys: KeysDefine): void; 683 | function deregisterImportantShortcut(keys: KeysDefine): void; 684 | function changeShortcut(keys: KeysDefine, newKeys: KeysDefine): void; 685 | } 686 | 687 | /** 688 | * @deprecated 689 | */ 690 | const LiveAPI: any; 691 | 692 | namespace LocalStorage { 693 | /** 694 | * Empties the list associated with the object of all key/value pairs, if there are any. 695 | */ 696 | function clear(): void; 697 | /** 698 | * Get key value 699 | */ 700 | function get(key: string): string | null; 701 | /** 702 | * Delete key 703 | */ 704 | function remove(key: string): void; 705 | /** 706 | * Set new value for key 707 | */ 708 | function set(key: string, value: string): void; 709 | } 710 | /** 711 | * To create and prepend custom menu item in profile menu. 712 | */ 713 | namespace Menu { 714 | /** 715 | * Create a single toggle. 716 | */ 717 | class Item { 718 | constructor(name: string, isEnabled: boolean, onClick: (self: Item) => void, icon?: Icon | string); 719 | name: string; 720 | isEnabled: boolean; 721 | /** 722 | * Change item name 723 | */ 724 | setName(name: string): void; 725 | /** 726 | * Change item enabled state. 727 | * Visually, item would has a tick next to it if its state is enabled. 728 | */ 729 | setState(isEnabled: boolean): void; 730 | /** 731 | * Change icon 732 | */ 733 | setIcon(icon: Icon | string): void; 734 | /** 735 | * Item is only available in Profile menu when method "register" is called. 736 | */ 737 | register(): void; 738 | /** 739 | * Stop item to be prepended into Profile menu. 740 | */ 741 | deregister(): void; 742 | } 743 | 744 | /** 745 | * Create a sub menu to contain Item toggles. 746 | * `Item`s in `subItems` array shouldn't be registered. 747 | */ 748 | class SubMenu { 749 | constructor(name: string, subItems: Item[]); 750 | name: string; 751 | /** 752 | * Change SubMenu name 753 | */ 754 | setName(name: string): void; 755 | /** 756 | * Add an item to sub items list 757 | */ 758 | addItem(item: Item); 759 | /** 760 | * Remove an item from sub items list 761 | */ 762 | removeItem(item: Item); 763 | /** 764 | * SubMenu is only available in Profile menu when method "register" is called. 765 | */ 766 | register(): void; 767 | /** 768 | * Stop SubMenu to be prepended into Profile menu. 769 | */ 770 | deregister(): void; 771 | } 772 | } 773 | 774 | /** 775 | * Keyboard shortcut library 776 | * 777 | * Documentation: https://craig.is/killing/mice v1.6.5 778 | * 779 | * Spicetify.Keyboard is wrapper of this library to be compatible with legacy Spotify, 780 | * so new extension should use this library instead. 781 | */ 782 | function Mousetrap(element?: any): void; 783 | 784 | /** 785 | * Contains vast array of internal APIs. 786 | * Please explore in Devtool Console. 787 | */ 788 | const Platform: any; 789 | /** 790 | * Queue object contains list of queuing tracks, 791 | * history of played tracks and current track metadata. 792 | */ 793 | const Queue: { 794 | nextTracks: any[]; 795 | prevTracks: any[]; 796 | queueRevision: string; 797 | track: any; 798 | }; 799 | /** 800 | * Remove a track or array of tracks from current queue. 801 | */ 802 | function removeFromQueue(uri: ContextTrack[]): Promise; 803 | /** 804 | * Display a bubble of notification. Useful for a visual feedback. 805 | * @param message Message to display. Can use inline HTML for styling. 806 | * @param isError If true, bubble will be red. Defaults to false. 807 | * @param msTimeout Time in milliseconds to display the bubble. Defaults to Spotify's value. 808 | */ 809 | function showNotification(message: React.ReactNode, isError?: boolean, msTimeout?: number): void; 810 | /** 811 | * Set of APIs method to parse and validate URIs. 812 | */ 813 | class URI { 814 | constructor(type: string, props: any); 815 | public type: string; 816 | public hasBase62Id: boolean; 817 | 818 | public id?: string; 819 | public disc?: any; 820 | public args?: any; 821 | public category?: string; 822 | public username?: string; 823 | public track?: string; 824 | public artist?: string; 825 | public album?: string; 826 | public duration?: number; 827 | public query?: string; 828 | public country?: string; 829 | public global?: boolean; 830 | public context?: string | typeof URI | null; 831 | public anchor?: string; 832 | public play?: any; 833 | public toplist?: any; 834 | 835 | /** 836 | * 837 | * @return The URI representation of this uri. 838 | */ 839 | toURI(): string; 840 | 841 | /** 842 | * 843 | * @return The URI representation of this uri. 844 | */ 845 | toString(): string; 846 | 847 | /** 848 | * Get the URL path of this uri. 849 | * 850 | * @param opt_leadingSlash True if a leading slash should be prepended. 851 | * @return The path of this uri. 852 | */ 853 | toURLPath(opt_leadingSlash: boolean): string; 854 | 855 | /** 856 | * 857 | * @param origin The origin to use for the URL. 858 | * @return The URL string for the uri. 859 | */ 860 | toURL(origin?: string): string; 861 | 862 | /** 863 | * Clones a given SpotifyURI instance. 864 | * 865 | * @return An instance of URI. 866 | */ 867 | clone(): URI | null; 868 | 869 | /** 870 | * Gets the path of the URI object by removing all hash and query parameters. 871 | * 872 | * @return The path of the URI object. 873 | */ 874 | getPath(): string; 875 | 876 | /** 877 | * The various URI Types. 878 | * 879 | * Note that some of the types in this enum are not real URI types, but are 880 | * actually URI particles. They are marked so. 881 | * 882 | */ 883 | static Type: { 884 | AD: string; 885 | ALBUM: string; 886 | GENRE: string; 887 | QUEUE: string; 888 | APPLICATION: string; 889 | ARTIST: string; 890 | ARTIST_TOPLIST: string; 891 | ARTIST_CONCERTS: string; 892 | AUDIO_FILE: string; 893 | COLLECTION: string; 894 | COLLECTION_ALBUM: string; 895 | COLLECTION_ARTIST: string; 896 | COLLECTION_MISSING_ALBUM: string; 897 | COLLECTION_TRACK_LIST: string; 898 | CONCERT: string; 899 | CONTEXT_GROUP: string; 900 | DAILY_MIX: string; 901 | EMPTY: string; 902 | EPISODE: string; 903 | /** URI particle; not an actual URI. */ 904 | FACEBOOK: string; 905 | FOLDER: string; 906 | FOLLOWERS: string; 907 | FOLLOWING: string; 908 | IMAGE: string; 909 | INBOX: string; 910 | INTERRUPTION: string; 911 | LIBRARY: string; 912 | LIVE: string; 913 | ROOM: string; 914 | EXPRESSION: string; 915 | LOCAL: string; 916 | LOCAL_TRACK: string; 917 | LOCAL_ALBUM: string; 918 | LOCAL_ARTIST: string; 919 | MERCH: string; 920 | MOSAIC: string; 921 | PLAYLIST: string; 922 | PLAYLIST_V2: string; 923 | PRERELEASE: string; 924 | PROFILE: string; 925 | PUBLISHED_ROOTLIST: string; 926 | RADIO: string; 927 | ROOTLIST: string; 928 | SEARCH: string; 929 | SHOW: string; 930 | SOCIAL_SESSION: string; 931 | SPECIAL: string; 932 | STARRED: string; 933 | STATION: string; 934 | TEMP_PLAYLIST: string; 935 | TOPLIST: string; 936 | TRACK: string; 937 | TRACKSET: string; 938 | USER_TOPLIST: string; 939 | USER_TOP_TRACKS: string; 940 | UNKNOWN: string; 941 | MEDIA: string; 942 | QUESTION: string; 943 | POLL: string; 944 | }; 945 | 946 | /** 947 | * Creates a new URI object from a parsed string argument. 948 | * 949 | * @param str The string that will be parsed into a URI object. 950 | * @throws TypeError If the string argument is not a valid URI, a TypeError will 951 | * be thrown. 952 | * @return The parsed URI object. 953 | */ 954 | static fromString(str: string): URI; 955 | 956 | /** 957 | * Parses a given object into a URI instance. 958 | * 959 | * Unlike URI.fromString, this function could receive any kind of value. If 960 | * the value is already a URI instance, it is simply returned. 961 | * Otherwise the value will be stringified before parsing. 962 | * 963 | * This function also does not throw an error like URI.fromString, but 964 | * instead simply returns null if it can't parse the value. 965 | * 966 | * @param value The value to parse. 967 | * @return The corresponding URI instance, or null if the 968 | * passed value is not a valid value. 969 | */ 970 | static from(value: any): URI | null; 971 | 972 | /** 973 | * Checks whether two URI:s refer to the same thing even though they might 974 | * not necessarily be equal. 975 | * 976 | * These two Playlist URIs, for example, refer to the same playlist: 977 | * 978 | * spotify:user:napstersean:playlist:3vxotOnOGDlZXyzJPLFnm2 979 | * spotify:playlist:3vxotOnOGDlZXyzJPLFnm2 980 | * 981 | * @param baseUri The first URI to compare. 982 | * @param refUri The second URI to compare. 983 | * @return Whether they shared idenitity 984 | */ 985 | static isSameIdentity(baseUri: URI | string, refUri: URI | string): boolean; 986 | 987 | /** 988 | * Returns the hex representation of a Base62 encoded id. 989 | * 990 | * @param id The base62 encoded id. 991 | * @return The hex representation of the base62 id. 992 | */ 993 | static idToHex(id: string): string; 994 | 995 | /** 996 | * Returns the base62 representation of a hex encoded id. 997 | * 998 | * @param hex The hex encoded id. 999 | * @return The base62 representation of the id. 1000 | */ 1001 | static hexToId(hex: string): string; 1002 | 1003 | /** 1004 | * Creates a new 'album' type URI. 1005 | * 1006 | * @param id The id of the album. 1007 | * @param disc The disc number of the album. 1008 | * @return The album URI. 1009 | */ 1010 | static albumURI(id: string, disc: number): URI; 1011 | 1012 | /** 1013 | * Creates a new 'application' type URI. 1014 | * 1015 | * @param id The id of the application. 1016 | * @param args An array containing the arguments to the app. 1017 | * @return The application URI. 1018 | */ 1019 | static applicationURI(id: string, args: string[]): URI; 1020 | 1021 | /** 1022 | * Creates a new 'artist' type URI. 1023 | * 1024 | * @param id The id of the artist. 1025 | * @return The artist URI. 1026 | */ 1027 | static artistURI(id: string): URI; 1028 | 1029 | /** 1030 | * Creates a new 'collection' type URI. 1031 | * 1032 | * @param username The non-canonical username of the rootlist owner. 1033 | * @param category The category of the collection. 1034 | * @return The collection URI. 1035 | */ 1036 | static collectionURI(username: string, category: string): URI; 1037 | 1038 | /** 1039 | * Creates a new 'collection-album' type URI. 1040 | * 1041 | * @param username The non-canonical username of the rootlist owner. 1042 | * @param id The id of the album. 1043 | * @return The collection album URI. 1044 | */ 1045 | static collectionAlbumURI(username: string, id: string): URI; 1046 | 1047 | /** 1048 | * Creates a new 'collection-artist' type URI. 1049 | * 1050 | * @param username The non-canonical username of the rootlist owner. 1051 | * @param id The id of the artist. 1052 | * @return The collection artist URI. 1053 | */ 1054 | static collectionAlbumURI(username: string, id: string): URI; 1055 | 1056 | /** 1057 | * Creates a new 'concert' type URI. 1058 | * 1059 | * @param id The id of the concert. 1060 | * @return The concert URI. 1061 | */ 1062 | static concertURI(id: string): URI; 1063 | 1064 | /** 1065 | * Creates a new 'episode' type URI. 1066 | * 1067 | * @param id The id of the episode. 1068 | * @return The episode URI. 1069 | */ 1070 | static episodeURI(id: string): URI; 1071 | 1072 | /** 1073 | * Creates a new 'folder' type URI. 1074 | * 1075 | * @param id The id of the folder. 1076 | * @return The folder URI. 1077 | */ 1078 | static folderURI(id: string): URI; 1079 | 1080 | /** 1081 | * Creates a new 'local-album' type URI. 1082 | * 1083 | * @param artist The artist of the album. 1084 | * @param album The name of the album. 1085 | * @return The local album URI. 1086 | */ 1087 | static localAlbumURI(artist: string, album: string): URI; 1088 | 1089 | /** 1090 | * Creates a new 'local-artist' type URI. 1091 | * 1092 | * @param artist The name of the artist. 1093 | * @return The local artist URI. 1094 | */ 1095 | static localArtistURI(artist: string): URI; 1096 | 1097 | /** 1098 | * Creates a new 'playlist-v2' type URI. 1099 | * 1100 | * @param id The id of the playlist. 1101 | * @return The playlist URI. 1102 | */ 1103 | static playlistV2URI(id: string): URI; 1104 | 1105 | /** 1106 | * Creates a new 'prerelease' type URI. 1107 | * 1108 | * @param id The id of the prerelease. 1109 | * @return The prerelease URI. 1110 | */ 1111 | static prereleaseURI(id: string): URI; 1112 | 1113 | /** 1114 | * Creates a new 'profile' type URI. 1115 | * 1116 | * @param username The non-canonical username of the rootlist owner. 1117 | * @param args A list of arguments. 1118 | * @return The profile URI. 1119 | */ 1120 | static profileURI(username: string, args: string[]): URI; 1121 | 1122 | /** 1123 | * Creates a new 'search' type URI. 1124 | * 1125 | * @param query The unencoded search query. 1126 | * @return The search URI 1127 | */ 1128 | static searchURI(query: string): URI; 1129 | 1130 | /** 1131 | * Creates a new 'show' type URI. 1132 | * 1133 | * @param id The id of the show. 1134 | * @return The show URI. 1135 | */ 1136 | static showURI(id: string): URI; 1137 | 1138 | /** 1139 | * Creates a new 'station' type URI. 1140 | * 1141 | * @param args An array of arguments for the station. 1142 | * @return The station URI. 1143 | */ 1144 | static stationURI(args: string[]): URI; 1145 | 1146 | /** 1147 | * Creates a new 'track' type URI. 1148 | * 1149 | * @param id The id of the track. 1150 | * @param anchor The point in the track formatted as mm:ss 1151 | * @param context An optional context URI 1152 | * @param play Toggles autoplay 1153 | * @return The track URI. 1154 | */ 1155 | static trackURI(id: string, anchor: string, context?: string, play?: boolean): URI; 1156 | 1157 | /** 1158 | * Creates a new 'user-toplist' type URI. 1159 | * 1160 | * @param username The non-canonical username of the toplist owner. 1161 | * @param toplist The toplist type. 1162 | * @return The user-toplist URI. 1163 | */ 1164 | static userToplistURI(username: string, toplist: string): URI; 1165 | 1166 | static isAd(uri: URI | string): boolean; 1167 | static isAlbum(uri: URI | string): boolean; 1168 | static isGenre(uri: URI | string): boolean; 1169 | static isQueue(uri: URI | string): boolean; 1170 | static isApplication(uri: URI | string): boolean; 1171 | static isArtist(uri: URI | string): boolean; 1172 | static isArtistToplist(uri: URI | string): boolean; 1173 | static isArtistConcerts(uri: URI | string): boolean; 1174 | static isAudioFile(uri: URI | string): boolean; 1175 | static isCollection(uri: URI | string): boolean; 1176 | static isCollectionAlbum(uri: URI | string): boolean; 1177 | static isCollectionArtist(uri: URI | string): boolean; 1178 | static isCollectionMissingAlbum(uri: URI | string): boolean; 1179 | static isCollectionTrackList(uri: URI | string): boolean; 1180 | static isConcert(uri: URI | string): boolean; 1181 | static isContextGroup(uri: URI | string): boolean; 1182 | static isDailyMix(uri: URI | string): boolean; 1183 | static isEmpty(uri: URI | string): boolean; 1184 | static isEpisode(uri: URI | string): boolean; 1185 | static isFacebook(uri: URI | string): boolean; 1186 | static isFolder(uri: URI | string): boolean; 1187 | static isFollowers(uri: URI | string): boolean; 1188 | static isFollowing(uri: URI | string): boolean; 1189 | static isImage(uri: URI | string): boolean; 1190 | static isInbox(uri: URI | string): boolean; 1191 | static isInterruption(uri: URI | string): boolean; 1192 | static isLibrary(uri: URI | string): boolean; 1193 | static isLive(uri: URI | string): boolean; 1194 | static isRoom(uri: URI | string): boolean; 1195 | static isExpression(uri: URI | string): boolean; 1196 | static isLocal(uri: URI | string): boolean; 1197 | static isLocalTrack(uri: URI | string): boolean; 1198 | static isLocalAlbum(uri: URI | string): boolean; 1199 | static isLocalArtist(uri: URI | string): boolean; 1200 | static isMerch(uri: URI | string): boolean; 1201 | static isMosaic(uri: URI | string): boolean; 1202 | static isPlaylist(uri: URI | string): boolean; 1203 | static isPlaylistV2(uri: URI | string): boolean; 1204 | static isPrerelease(uri: URI | string): boolean; 1205 | static isProfile(uri: URI | string): boolean; 1206 | static isPublishedRootlist(uri: URI | string): boolean; 1207 | static isRadio(uri: URI | string): boolean; 1208 | static isRootlist(uri: URI | string): boolean; 1209 | static isSearch(uri: URI | string): boolean; 1210 | static isShow(uri: URI | string): boolean; 1211 | static isSocialSession(uri: URI | string): boolean; 1212 | static isSpecial(uri: URI | string): boolean; 1213 | static isStarred(uri: URI | string): boolean; 1214 | static isStation(uri: URI | string): boolean; 1215 | static isTempPlaylist(uri: URI | string): boolean; 1216 | static isToplist(uri: URI | string): boolean; 1217 | static isTrack(uri: URI | string): boolean; 1218 | static isTrackset(uri: URI | string): boolean; 1219 | static isUserToplist(uri: URI | string): boolean; 1220 | static isUserTopTracks(uri: URI | string): boolean; 1221 | static isUnknown(uri: URI | string): boolean; 1222 | static isMedia(uri: URI | string): boolean; 1223 | static isQuestion(uri: URI | string): boolean; 1224 | static isPoll(uri: URI | string): boolean; 1225 | static isPlaylistV1OrV2(uri: URI | string): boolean; 1226 | } 1227 | 1228 | /** 1229 | * Create custom menu item and prepend to right click context menu 1230 | */ 1231 | namespace ContextMenu { 1232 | type OnClickCallback = (uris: string[], uids?: string[], contextUri?: string) => void; 1233 | type ShouldAddCallback = (uris: string[], uids?: string[], contextUri?: string) => boolean; 1234 | 1235 | // Single context menu item 1236 | class Item { 1237 | /** 1238 | * List of valid icons to use. 1239 | */ 1240 | static readonly iconList: Icon[]; 1241 | constructor(name: string, onClick: OnClickCallback, shouldAdd?: ShouldAddCallback, icon?: Icon, disabled?: boolean); 1242 | name: string; 1243 | icon: Icon | string; 1244 | disabled: boolean; 1245 | /** 1246 | * A function returning boolean determines whether item should be prepended. 1247 | */ 1248 | shouldAdd: ShouldAddCallback; 1249 | /** 1250 | * A function to call when item is clicked 1251 | */ 1252 | onClick: OnClickCallback; 1253 | /** 1254 | * Item is only available in Context Menu when method "register" is called. 1255 | */ 1256 | register: () => void; 1257 | /** 1258 | * Stop Item to be prepended into Context Menu. 1259 | */ 1260 | deregister: () => void; 1261 | } 1262 | 1263 | /** 1264 | * Create a sub menu to contain `Item`s. 1265 | * `Item`s in `subItems` array shouldn't be registered. 1266 | */ 1267 | class SubMenu { 1268 | constructor(name: string, subItems: Iterable, shouldAdd?: ShouldAddCallback, disabled?: boolean); 1269 | name: string; 1270 | disabled: boolean; 1271 | /** 1272 | * A function returning boolean determines whether item should be prepended. 1273 | */ 1274 | shouldAdd: ShouldAddCallback; 1275 | addItem: (item: Item) => void; 1276 | removeItem: (item: Item) => void; 1277 | /** 1278 | * SubMenu is only available in Context Menu when method "register" is called. 1279 | */ 1280 | register: () => void; 1281 | /** 1282 | * Stop SubMenu to be prepended into Context Menu. 1283 | */ 1284 | deregister: () => void; 1285 | } 1286 | } 1287 | 1288 | /** 1289 | * Popup Modal 1290 | */ 1291 | namespace PopupModal { 1292 | interface Content { 1293 | title: string; 1294 | /** 1295 | * You can specify a string for simple text display 1296 | * or a HTML element for interactive config/setting menu 1297 | */ 1298 | content: string | Element; 1299 | /** 1300 | * Bigger window 1301 | */ 1302 | isLarge?: boolean; 1303 | } 1304 | 1305 | function display(e: Content): void; 1306 | function hide(): void; 1307 | } 1308 | 1309 | /** React instance to create components */ 1310 | const React: any; 1311 | /** React DOM instance to render and mount components */ 1312 | const ReactDOM: any; 1313 | /** React DOM Server instance to render components to string */ 1314 | const ReactDOMServer: any; 1315 | 1316 | /** Stock React components exposed from Spotify library */ 1317 | namespace ReactComponent { 1318 | type ContextMenuProps = { 1319 | /** 1320 | * Decide whether to use the global singleton context menu (rendered in ) 1321 | * or a new inline context menu (rendered in a sibling 1322 | * element to `children`) 1323 | */ 1324 | renderInline?: boolean; 1325 | /** 1326 | * Determins what will trigger the context menu. For example, a click, or a right-click 1327 | */ 1328 | trigger?: "click" | "right-click"; 1329 | /** 1330 | * Determins is the context menu should open or toggle when triggered 1331 | */ 1332 | action?: "toggle" | "open"; 1333 | /** 1334 | * The preferred placement of the context menu when it opens. 1335 | * Relative to trigger element. 1336 | */ 1337 | placement?: 1338 | | "top" 1339 | | "top-start" 1340 | | "top-end" 1341 | | "right" 1342 | | "right-start" 1343 | | "right-end" 1344 | | "bottom" 1345 | | "bottom-start" 1346 | | "bottom-end" 1347 | | "left" 1348 | | "left-start" 1349 | | "left-end"; 1350 | /** 1351 | * The x and y offset distances at which the context menu should open. 1352 | * Relative to trigger element and `position`. 1353 | */ 1354 | offset?: [number, number]; 1355 | /** 1356 | * Will stop the client from scrolling while the context menu is open 1357 | */ 1358 | preventScrollingWhileOpen?: boolean; 1359 | /** 1360 | * The menu UI to render inside of the context menu. 1361 | */ 1362 | menu: 1363 | | typeof Spicetify.ReactComponent.Menu 1364 | | typeof Spicetify.ReactComponent.AlbumMenu 1365 | | typeof Spicetify.ReactComponent.PodcastShowMenu 1366 | | typeof Spicetify.ReactComponent.ArtistMenu 1367 | | typeof Spicetify.ReactComponent.PlaylistMenu; 1368 | /** 1369 | * A child of the context menu. Should be ` 73 | 74 | 87 | 102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
    119 |
    120 |
    121 |
    122 |
    123 |
    124 | **SECONDINJECTION** 125 |
    126 |
    127 |
    128 |
    129 |
    130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 | 149 | 150 |
151 | 152 |
153 | `; 154 | 155 | const customAppHTML = createCustomAppHtml(document.getElementsByClassName("Root__nav-bar")[0]); 156 | const playlistHTML = createPlaylistHtml(document.getElementsByClassName("Root__nav-bar")[0]); 157 | if (customAppHTML === undefined || playlistHTML === undefined) { 158 | console.log("Error"); 159 | return; 160 | } 161 | 162 | oldHTML = oldHTML.replace("**INJECTION**", customAppHTML); 163 | oldHTML = oldHTML.replace("**SECONDINJECTION**", playlistHTML); 164 | 165 | // Replace the contents of of the element with the class "Root__nav-bar" with the old HTML 166 | document.getElementsByClassName("Root__nav-bar")[0].innerHTML = oldHTML; 167 | 168 | // For every "main-navBar-navBarItem" element in the oldHTML, add an event Listener for the click event, when it is clicked, run Spicetify.Platform.History.push() and push the href of the element to the history, also override the default behaviour of the click event 169 | let elements = document.getElementsByClassName("main-navBar-navBarItem"); 170 | for (let i = 0; i < elements.length; i++) { 171 | elements[i].addEventListener("click", e => { 172 | const href = elements[i].getElementsByTagName("a")[0].href; 173 | // Strip "https://xpui.app.spotify.com" from the href 174 | Spicetify.Platform.History.push(href.replace("https://xpui.app.spotify.com", "")); 175 | e.preventDefault(); 176 | }); 177 | } 178 | // For every "main-rootlist-rootlistItem" element in the oldHTML, add an event Listener for the click event, when it is clicked, run Spicetify.Platform.History.push() and push the href of the element to the history, also override the default behaviour of the click event 179 | elements = document.getElementsByClassName("main-rootlist-rootlistItem"); 180 | for (let i = 0; i < elements.length; i++) { 181 | elements[i].addEventListener("click", e => { 182 | const href = elements[i].getElementsByTagName("a")[0].href; 183 | // Strip "https://xpui.app.spotify.com" from the href 184 | Spicetify.Platform.History.push(href.replace("https://xpui.app.spotify.com", "")); 185 | e.preventDefault(); 186 | }); 187 | } 188 | })(); 189 | 190 | function extractCustomAppsFromSidebarHtml(sidebarHtml) { 191 | const customAppsData = []; 192 | const customAppElements = sidebarHtml.querySelectorAll(".main-topBar-navLink"); 193 | for (const customAppElement of customAppElements) { 194 | const href = customAppElement.getAttribute("href"); 195 | const icon = customAppElement.getElementsByClassName("home-icon")[0].innerHTML; 196 | const activeIcon = customAppElement.getElementsByClassName("home-active-icon")[0].innerHTML; 197 | // Name is the href with hypens replaced with spaces, the first / removed, and with the first letter of each word capitalized 198 | const name = href 199 | .replace(/-/g, " ") 200 | .replace("/", "") 201 | .replace(/\b\w/g, l => l.toUpperCase()); 202 | customAppsData.push({ href, icon, activeIcon, name }); 203 | } 204 | return customAppsData; 205 | } 206 | function extractPlaylistsFromHtml(sidebarHtml) { 207 | const playlists = []; 208 | const playlistElements = sidebarHtml.querySelectorAll("li[role='listitem']"); 209 | console.log(playlistElements); 210 | for (const playlistElement of playlistElements) { 211 | const nameElement = playlistElement.querySelector("a[class='yOc9v5MGaitl1r9UpHKg'] span"); 212 | console.log(nameElement); 213 | const hrefElement = playlistElement.querySelector("a[class='yOc9v5MGaitl1r9UpHKg']"); 214 | console.log(hrefElement); 215 | if (!nameElement || !hrefElement) return; 216 | playlists.push({ 217 | name: nameElement.textContent, 218 | href: hrefElement.getAttribute("href"), 219 | }); 220 | } 221 | console.log(playlists); 222 | return playlists; 223 | } 224 | function createCustomAppHtml(newSidebarHtml) { 225 | if (!newSidebarHtml) return; 226 | const customAppsData = extractCustomAppsFromSidebarHtml(newSidebarHtml); 227 | const customAppHtmls = []; 228 | for (const customAppData of customAppsData) { 229 | const customAppHtml = ``; 240 | customAppHtmls.push(customAppHtml); 241 | } 242 | return customAppHtmls.join(""); 243 | } 244 | function createPlaylistHtml(newPlaylistHtml) { 245 | if (!newPlaylistHtml) return; 246 | const playlistData = extractPlaylistsFromHtml(newPlaylistHtml); 247 | const playlistHtmls = []; 248 | for (const data of playlistData) { 249 | const playlistHtml = ``; 251 | playlistHtmls.push(playlistHtml); 252 | } 253 | console.log(playlistHtmls); 254 | return playlistHtmls.join(""); 255 | } 256 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spicetify-extensions", 3 | "author": "ririxi ", 4 | "license": "MIT", 5 | "scripts": { 6 | "build": "tsc" 7 | }, 8 | "packageManager": "pnpm@9.1.3", 9 | "engines": { 10 | "node": ">=18", 11 | "npm": "please-use-pnpm", 12 | "pnpm": ">=9", 13 | "yarn": "please-use-pnpm" 14 | }, 15 | "devDependencies": { 16 | "@types/react": "^18.3.3", 17 | "typescript": "^5.4.5" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /phraseToPlaylist/README.md: -------------------------------------------------------------------------------- 1 | # PhraseToPlaylist 2 | 3 | Create a playlist based on the phrase with `PhraseToPlaylist` for **[spicetify](https://github.com/spicetify/cli)** 4 | 5 | ## Install 6 | 7 | 1. Copy `phraseToPlaylist.js` to extensions folder based on your OS, or install it via **[marketplace](https://github.com/spicetify/spicetify-marketplace)** 8 | 9 | | **Platform** | **Path** | 10 | |----------------|--------------------------------------------------------------------------------------| 11 | | **Windows** | `%appdata%\spicetify\Extensions\` | 12 | | **Linux** | `~/.config/spicetify/Extensions` or `$XDG_CONFIG_HOME/spicetify/Extensions/` | 13 | | **MacOS** | `~/.config/spicetify/Extensions` or `~/.spicetify/Extensions` | 14 | 15 | After putting the extension file into the correct folder, run the following command to install the extension or install through marketplace: 16 | 17 | ```sh 18 | spicetify config extensions phraseToPlaylist.js 19 | spicetify apply 20 | ``` 21 | 22 | Note: Using the `config` command to add the extension will always append the file name to the existing extensions list. It does not replace the whole key's value. 23 | 24 | Or you can manually edit your `config-xpui.ini` file. Add your desired extension filenames in the extensions key, separated them by the | character. 25 | Example: 26 | 27 | ```ini 28 | [AdditionalOptions] 29 | ... 30 | extensions = autoSkipExplicit.js|shuffle+.js|trashbin.js|phraseToPlaylist.js 31 | ``` 32 | 33 | Then run: 34 | 35 | ```sh 36 | spicetify apply 37 | ``` 38 | 39 | ## Usage 40 | 41 | Toggle in the Profile menu. 42 | 43 | ![Screenshot](https://raw.githubusercontent.com/rxri/spicetify-extensions/main/phraseToPlaylist/phraseToPlaylist.png) 44 | 45 | ----- 46 | If you find any bugs, please [create a new issue](https://github.com/rxri/spicetify-extensions/issues/new/choose) on the GitHub repo. 47 | ![https://github.com/rxri/spicetify-extensions/issues](https://img.shields.io/github/issues/rxri/spicetify-extensions?logo=github) 48 | -------------------------------------------------------------------------------- /phraseToPlaylist/phraseToPlaylist.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /** 3 | * @author ririxi 4 | * @author CharlieS1103 5 | * @author MalTeeez 6 | */ 7 | const CONVERT_ICON = ``; 8 | const songMap = { 9 | that: "3mJCHAKdmZDINjCEEYMEkq", 10 | THAT: "6hbfVquDat90Nv09n05ZnN", 11 | That: "5XZTPT1jb4fEfmluLKmm4B", 12 | you: "5Wdl4yFoXOX1xmA53udLyZ", 13 | YOU: "6cVNYlO75XZ3UZnglTF6WI", 14 | You: "6lbme14HiDWYmGiw1I2Dv6", 15 | our: "5JTjuEFoIfQgP90nvOCWEj", 16 | OUR: "5YhTy3qCTc2RELqbHKv94A", 17 | Our: "4WLnE7W9K41HdRz1rHpz5T", 18 | it: "6eG4JMN3f4WLgj1ElfuMUV", 19 | for: "3beItkavCW1qXszPbFbijD", 20 | is: "1epDL4xhczbpzkXIeGXZzb", 21 | are: "0L6WRMANsYoX1mIe25zwbe", 22 | i: "7wdzLe2Gsx1RGqbvYZHASz", 23 | a: "6WH0LHM2vFBLpmU5RFdDh2", 24 | the: "72FW5JjmSwgHNopNSLRocy", 25 | have: "0DX4IC8aMjisNh7LHDyo6J", 26 | he: "1bc28ebMDp7ym6rHfqFfj0", 27 | we: "0BSI1Epu3YeVwXF1bvL8oH", 28 | to: "4n3lfhTDOaFe9a1c4FPPSB", 29 | and: "2YsrYsusAKqYD74ipCRxvz", 30 | 0: "3GzRIROhugr0XHjrOvyDRP", 31 | 1: "76nlq5gomu49Yn5dfmtv0C", 32 | 2: "62CprXvSWsKBvYu3Yba55A", 33 | 3: "6ECxq5Sh1ogq6oHDRUVmV2", 34 | 4: "6XvzSF3NDwOKP6RF0YmXEU", 35 | 5: "15UttZPJXWsb1fSLwNSfov", 36 | 6: "5os4iDInR4chqaCdXi895k", 37 | 7: "7zbFh74zImpQho3btxuANN", 38 | 8: "1lSnBlAErRss6asu9Y5HuA", 39 | 9: "3HGKzDBC6MfnJtcCRi7xB3", 40 | A: "5uYalrRxXbbK7N8vYlXWFO", 41 | B: "4oViUMnlTQhI9gJwEhUgv5", 42 | C: "3Cv7jBCoHsV6ZnajqZk02J", 43 | D: "6E1ejRJAfE8BC4T1Dc8DNo", 44 | E: "1XBGTp6OqwYaYhemH3aMKT", 45 | F: "6gpAgc7TPrcyJcjvjNVLFo", 46 | G: "2ZUTVMR0zjB8ixh1ZhcbvL", 47 | H: "7Eaoln7EVETUXJ1rotxHWo", 48 | I: "0hJZZMFlSVmtQjOYGKnFng", 49 | J: "4czA2rv6Hz8cgsiomaisAO", 50 | K: "0SMxKocTYMTE8C4dktdlRX", 51 | L: "6brTu7TkwXtFMjQgcxkMA4", 52 | M: "1WWWfx7SyPJEbLCJKt2mpa", 53 | N: "6cyYD58m4zLDzEWld7sxHC", 54 | O: "77yuzxCS3csrgTPSW0pvyk", 55 | P: "58xd48kdUNy6GtJ4j0qENM", 56 | Q: "51CXAV2GNNL3deCtcXpCeu", 57 | R: "0xHghnAskte2ZiCqA0YsPV", 58 | S: "336eHf6SexQkX3MZDykFC7", 59 | T: "4ghtfPjftCLrsqEeH83Q0x", 60 | U: "1K8NZfZN8bh0ApIPYJplVB", 61 | V: "4JnBvAHV8obYVyHVehuOiM", 62 | W: "3hx0gIgOea9IsOpVjJejR1", 63 | X: "62ssdaS7RIUmGROgns2TG1", 64 | Y: "5yKZg1KWvtvWBmHIoU9tzs", 65 | Z: "53CSKhZZKCvldFSyV2CMMX", 66 | in: "2vec1SirAf9NVU5YFpYKWo", 67 | as: "4Q72edajhMV46lHlQQ43Tp", 68 | of: "0m8KR8qryLSgMUp88xYfiE", 69 | It: "1HrOS7MY0qh7k7oF9UgvsJ", 70 | by: "3HLSjmkYgx9jSlY6K7ide0", 71 | on: "7HXs149WscOjEmnnfk0NSY", 72 | so: "3lZgcpwEIunsw3xauzu62W", 73 | be: "5GOZS9DCxvijXL2wLIddlH", 74 | us: "0HBs0pHrnSP8DEAPM1JX5M", 75 | no: "0V3K2DEX1fh7nmOLoN8Lla", 76 | or: "0EQeU8sdVB1yGoY4LItykb", 77 | an: "2yxqUsz8BNuhzj8JpdDpdW", 78 | if: "0l8OTE4sFHWrjVUSUmcu2P", 79 | my: "1AToLbGgM9GhCqc5CnmZ3R", 80 | up: "06mEvkWtMBTiZkEzNehpxe", 81 | Go: "6U6UC3Xg5ukTBQIy245bAo", 82 | go: "7EpP2BHNXHPc7OWlpg8mxj", 83 | In: "2vec1SirAf9NVU5YFpYKWo", 84 | As: "13toFl1UwJPsRxDiD9jgtn", 85 | Of: "313l4VILjTvipoamGptl5h", 86 | By: "5C4sp6JprCFTO9ZQcg4qXs", 87 | On: "167c1Blr84k9YpSCHLNh9m", 88 | So: "7GlurUXL0ZsZYq1YMimC5u", 89 | Be: "53OQwlz2w9TQDQXVAI5R0H", 90 | Us: "5f4l3uDDTNNGEtWaXHOIB9", 91 | No: "4HrI0DIPyvRF1cxUUAGQJc", 92 | Or: "3OR1FPc6xWGlO13WP3LbvY", 93 | An: "4jc4q9D2GhKxU8ID2hSVj1", 94 | If: "40W8Mm9t3ZO1iNQlls35lL", 95 | My: "0w5lktecJcEEjI9KBu0Dl9", 96 | Up: "2wzNQYX6veNM0AJncAV75v", 97 | Am: "5SkShE3Vc3iIDM9GOlboRd", 98 | am: "5kYZKCHnBomMkRSu3Xij2V", 99 | at: "23lpXblF7QUq7iRA5s4NRO", 100 | At: "1AhAdO7zzrW4fQXXOoPyOG", 101 | }; 102 | (async function phraseToPlaylist() { 103 | // @ts-expect-error: Events are not defined in types 104 | await new Promise(res => Spicetify.Events.platformLoaded.on(res)); 105 | // @ts-expect-error: Events are not defined in types 106 | await new Promise(res => Spicetify.Events.webpackLoaded.on(res)); 107 | const { Platform, CosmosAsync, React, ReactDOM, ReactComponent } = Spicetify; 108 | const { ButtonPrimary } = ReactComponent; 109 | if (!CosmosAsync) { 110 | setTimeout(phraseToPlaylist, 100); 111 | return; 112 | } 113 | new Spicetify.Topbar.Button("Phrase2Playlist", CONVERT_ICON, displayPhraseInput, false); 114 | const PhraseToPlaylist = ({ onSubmit, loadingIndicator }) => { 115 | const [phrase, setPhrase] = React.useState(""); 116 | const handleSubmit = (event) => { 117 | event.preventDefault(); 118 | onSubmit(phrase); 119 | }; 120 | return (React.createElement(React.Fragment, null, 121 | React.createElement("textarea", { style: { "border-radius": "5px", "background-color": "#D3D3D3", color: "black", "font-size": "14px" }, cols: 50, rows: 4, placeholder: "Input phrase here!", value: phrase, onChange: (e) => setPhrase(e.target.value) }), 122 | React.createElement("div", { style: { display: "flex", "flex-direction": "row", "justify-content": "space-between" } }, 123 | React.createElement("span", { className: "phrase-loading-indicator" }, loadingIndicator), 124 | React.createElement(ButtonPrimary, { size: "sm", onClick: handleSubmit }, "Submit Phrase")))); 125 | }; 126 | function displayPhraseInput() { 127 | const modalContent = document.createElement("div"); 128 | document.body.appendChild(modalContent); 129 | const handleSubmit = async (phrase) => { 130 | generatePlaylist(phrase); 131 | }; 132 | ReactDOM.render(React.createElement(PhraseToPlaylist, { onSubmit: handleSubmit, loadingIndicator: "0/0" }), modalContent); 133 | Spicetify.PopupModal.display({ 134 | title: "Phrase2Playlist", 135 | content: modalContent, 136 | }); 137 | } 138 | const songMapCache = {}; 139 | async function generatePlaylist(phrase) { 140 | const cleanedPhrase = phrase.replace(/[^\w\s\-]|_/g, "").replace(/\s+/g, " "); 141 | const cleanedPhraseSplit = cleanedPhrase.replace(/^\s+|\s+$/g, "").split(" "); 142 | const songArr = []; 143 | let notFoundSongs = 0; 144 | for (let i = 0; i < cleanedPhraseSplit.length; i++) { 145 | updateLoadingIndicator(i, cleanedPhraseSplit.length); 146 | const songName = cleanedPhraseSplit[i]; 147 | if (!songName) 148 | continue; 149 | const normalizedSongName = songName.toUpperCase(); 150 | const songId = songMap[songName] || songMap[normalizedSongName] || songMapCache[songName]; 151 | if (songId) { 152 | songArr[i] = `spotify:track:${songId}`; 153 | } 154 | else { 155 | const songJson = await searchSong(songName); 156 | if (songJson === "notfound") { 157 | notFoundSongs++; 158 | continue; 159 | } 160 | songArr[i] = `spotify:track:${songJson}`; 161 | songMapCache[songName] = songJson; 162 | } 163 | } 164 | createPlaylist(songArr, notFoundSongs); 165 | } 166 | function updateLoadingIndicator(current, total) { 167 | const spanProgress = document.querySelector(".phrase-loading-indicator"); 168 | if (spanProgress) 169 | spanProgress.textContent = `${current}/${total}`; 170 | } 171 | async function searchSong(songName) { 172 | try { 173 | const songJSON = await CosmosAsync.get(`https://api.spotify.com/v1/search?q=${songName}&type=track&limit=15&offset=0`); 174 | for (const item of songJSON.tracks.items) { 175 | if (await isSameSong(songName, item.name)) 176 | return item.id; 177 | } 178 | } 179 | catch { 180 | console.log(`phrase2playlist: API Error while searching for word: ${songName}, using Error Track`); 181 | return "notfound"; 182 | } 183 | return "notfound"; 184 | } 185 | async function isSameSong(word, name) { 186 | const cleanedName = name 187 | .toUpperCase() 188 | .replace(/[^\w\s\-]|_/g, "") 189 | .replace(/\s+/g, " "); 190 | const cleanedWord = word 191 | .toUpperCase() 192 | .replace(/[^\w\s\-]|_/g, "") 193 | .replace(/\s+/g, " "); 194 | return cleanedName === cleanedWord; 195 | } 196 | async function createPlaylist(songArr, notfoundSongs) { 197 | const date = new Date(); 198 | const locale = "en-GB"; 199 | const newPlaylist = await CosmosAsync.post("https://api.spotify.com/v1/me/playlists", { 200 | name: `Phrase2Playlist - ${date.toLocaleString(locale).slice(0, 10)}`, 201 | public: false, 202 | }); 203 | const playlistID = newPlaylist.uri; 204 | await Platform.PlaylistAPI.add(playlistID, songArr.filter(String), { before: 0 }); 205 | Spicetify.showNotification(`Created new Phrase2Playlist playlist! ${notfoundSongs ? `(${notfoundSongs} songs not found)` : ""}`); 206 | } 207 | })(); 208 | -------------------------------------------------------------------------------- /phraseToPlaylist/phraseToPlaylist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxri/spicetify-extensions/5c8bedf61b718e4d47cc9adede4d39be59726a1e/phraseToPlaylist/phraseToPlaylist.png -------------------------------------------------------------------------------- /phraseToPlaylist/phraseToPlaylist.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @author ririxi 3 | * @author CharlieS1103 4 | * @author MalTeeez 5 | */ 6 | 7 | const CONVERT_ICON = ``; 8 | 9 | const songMap: { [key: string]: string } = { 10 | that: "3mJCHAKdmZDINjCEEYMEkq", 11 | THAT: "6hbfVquDat90Nv09n05ZnN", 12 | That: "5XZTPT1jb4fEfmluLKmm4B", 13 | you: "5Wdl4yFoXOX1xmA53udLyZ", 14 | YOU: "6cVNYlO75XZ3UZnglTF6WI", 15 | You: "6lbme14HiDWYmGiw1I2Dv6", 16 | our: "5JTjuEFoIfQgP90nvOCWEj", 17 | OUR: "5YhTy3qCTc2RELqbHKv94A", 18 | Our: "4WLnE7W9K41HdRz1rHpz5T", 19 | it: "6eG4JMN3f4WLgj1ElfuMUV", 20 | for: "3beItkavCW1qXszPbFbijD", 21 | is: "1epDL4xhczbpzkXIeGXZzb", 22 | are: "0L6WRMANsYoX1mIe25zwbe", 23 | i: "7wdzLe2Gsx1RGqbvYZHASz", 24 | a: "6WH0LHM2vFBLpmU5RFdDh2", 25 | the: "72FW5JjmSwgHNopNSLRocy", 26 | have: "0DX4IC8aMjisNh7LHDyo6J", 27 | he: "1bc28ebMDp7ym6rHfqFfj0", 28 | we: "0BSI1Epu3YeVwXF1bvL8oH", 29 | to: "4n3lfhTDOaFe9a1c4FPPSB", 30 | and: "2YsrYsusAKqYD74ipCRxvz", 31 | 0: "3GzRIROhugr0XHjrOvyDRP", 32 | 1: "76nlq5gomu49Yn5dfmtv0C", 33 | 2: "62CprXvSWsKBvYu3Yba55A", 34 | 3: "6ECxq5Sh1ogq6oHDRUVmV2", 35 | 4: "6XvzSF3NDwOKP6RF0YmXEU", 36 | 5: "15UttZPJXWsb1fSLwNSfov", 37 | 6: "5os4iDInR4chqaCdXi895k", 38 | 7: "7zbFh74zImpQho3btxuANN", 39 | 8: "1lSnBlAErRss6asu9Y5HuA", 40 | 9: "3HGKzDBC6MfnJtcCRi7xB3", 41 | A: "5uYalrRxXbbK7N8vYlXWFO", 42 | B: "4oViUMnlTQhI9gJwEhUgv5", 43 | C: "3Cv7jBCoHsV6ZnajqZk02J", 44 | D: "6E1ejRJAfE8BC4T1Dc8DNo", 45 | E: "1XBGTp6OqwYaYhemH3aMKT", 46 | F: "6gpAgc7TPrcyJcjvjNVLFo", 47 | G: "2ZUTVMR0zjB8ixh1ZhcbvL", 48 | H: "7Eaoln7EVETUXJ1rotxHWo", 49 | I: "0hJZZMFlSVmtQjOYGKnFng", 50 | J: "4czA2rv6Hz8cgsiomaisAO", 51 | K: "0SMxKocTYMTE8C4dktdlRX", 52 | L: "6brTu7TkwXtFMjQgcxkMA4", 53 | M: "1WWWfx7SyPJEbLCJKt2mpa", 54 | N: "6cyYD58m4zLDzEWld7sxHC", 55 | O: "77yuzxCS3csrgTPSW0pvyk", 56 | P: "58xd48kdUNy6GtJ4j0qENM", 57 | Q: "51CXAV2GNNL3deCtcXpCeu", 58 | R: "0xHghnAskte2ZiCqA0YsPV", 59 | S: "336eHf6SexQkX3MZDykFC7", 60 | T: "4ghtfPjftCLrsqEeH83Q0x", 61 | U: "1K8NZfZN8bh0ApIPYJplVB", 62 | V: "4JnBvAHV8obYVyHVehuOiM", 63 | W: "3hx0gIgOea9IsOpVjJejR1", 64 | X: "62ssdaS7RIUmGROgns2TG1", 65 | Y: "5yKZg1KWvtvWBmHIoU9tzs", 66 | Z: "53CSKhZZKCvldFSyV2CMMX", 67 | in: "2vec1SirAf9NVU5YFpYKWo", 68 | as: "4Q72edajhMV46lHlQQ43Tp", 69 | of: "0m8KR8qryLSgMUp88xYfiE", 70 | It: "1HrOS7MY0qh7k7oF9UgvsJ", 71 | by: "3HLSjmkYgx9jSlY6K7ide0", 72 | on: "7HXs149WscOjEmnnfk0NSY", 73 | so: "3lZgcpwEIunsw3xauzu62W", 74 | be: "5GOZS9DCxvijXL2wLIddlH", 75 | us: "0HBs0pHrnSP8DEAPM1JX5M", 76 | no: "0V3K2DEX1fh7nmOLoN8Lla", 77 | or: "0EQeU8sdVB1yGoY4LItykb", 78 | an: "2yxqUsz8BNuhzj8JpdDpdW", 79 | if: "0l8OTE4sFHWrjVUSUmcu2P", 80 | my: "1AToLbGgM9GhCqc5CnmZ3R", 81 | up: "06mEvkWtMBTiZkEzNehpxe", 82 | Go: "6U6UC3Xg5ukTBQIy245bAo", 83 | go: "7EpP2BHNXHPc7OWlpg8mxj", 84 | In: "2vec1SirAf9NVU5YFpYKWo", 85 | As: "13toFl1UwJPsRxDiD9jgtn", 86 | Of: "313l4VILjTvipoamGptl5h", 87 | By: "5C4sp6JprCFTO9ZQcg4qXs", 88 | On: "167c1Blr84k9YpSCHLNh9m", 89 | So: "7GlurUXL0ZsZYq1YMimC5u", 90 | Be: "53OQwlz2w9TQDQXVAI5R0H", 91 | Us: "5f4l3uDDTNNGEtWaXHOIB9", 92 | No: "4HrI0DIPyvRF1cxUUAGQJc", 93 | Or: "3OR1FPc6xWGlO13WP3LbvY", 94 | An: "4jc4q9D2GhKxU8ID2hSVj1", 95 | If: "40W8Mm9t3ZO1iNQlls35lL", 96 | My: "0w5lktecJcEEjI9KBu0Dl9", 97 | Up: "2wzNQYX6veNM0AJncAV75v", 98 | Am: "5SkShE3Vc3iIDM9GOlboRd", 99 | am: "5kYZKCHnBomMkRSu3Xij2V", 100 | at: "23lpXblF7QUq7iRA5s4NRO", 101 | At: "1AhAdO7zzrW4fQXXOoPyOG", 102 | }; 103 | 104 | interface PhraseToPlaylistProps { 105 | onSubmit: (phrase: string) => void; 106 | loadingIndicator: string; 107 | } 108 | 109 | (async function phraseToPlaylist() { 110 | // @ts-expect-error: Events are not defined in types 111 | await new Promise(res => Spicetify.Events.platformLoaded.on(res)); 112 | // @ts-expect-error: Events are not defined in types 113 | await new Promise(res => Spicetify.Events.webpackLoaded.on(res)); 114 | const { Platform, CosmosAsync, React, ReactDOM, ReactComponent } = Spicetify; 115 | const { ButtonPrimary } = ReactComponent; 116 | if (!CosmosAsync) { 117 | setTimeout(phraseToPlaylist, 100); 118 | return; 119 | } 120 | 121 | new Spicetify.Topbar.Button("Phrase2Playlist", CONVERT_ICON, displayPhraseInput, false); 122 | 123 | const PhraseToPlaylist: React.FC = ({ onSubmit, loadingIndicator }) => { 124 | const [phrase, setPhrase] = React.useState(""); 125 | 126 | const handleSubmit = (event: React.FormEvent) => { 127 | event.preventDefault(); 128 | onSubmit(phrase); 129 | }; 130 | 131 | return ( 132 | <> 133 | 107 |

108 | 109 | `; 110 | return container; 111 | } 112 | 113 | // Save the notes for the given song URI to local storage 114 | function saveSongNotes(songUri) { 115 | console.log('Save song notes:', songUri); 116 | 117 | const textarea = document.querySelector("#song-notes-textarea") 118 | 119 | if (!textarea) return; 120 | // @ts-ignore 121 | const notes = textarea.value; 122 | 123 | const storedNotes = localStorage.getItem("songNotes"); 124 | const notesMap = storedNotes ? JSON.parse(storedNotes) : {}; 125 | 126 | notesMap[songUri] = notes; 127 | localStorage.setItem("songNotes", JSON.stringify(notesMap)); 128 | 129 | PopupModal.hide(); 130 | } 131 | 132 | // Register the context menu item 133 | const cntxMenu = new Spicetify.ContextMenu.Item( 134 | "Add Note", 135 | openNotesModal, 136 | (uris) => uris.length === 1 137 | ); 138 | 139 | cntxMenu.register(); 140 | 141 | 142 | /* Function taken from https://github.com/L3-N0X/spicetify-dj-info*/ 143 | function getTracklistTrackUri(tracklistElement) { 144 | let values = Object.values(tracklistElement) 145 | if (!values) return null 146 | 147 | return ( 148 | values[0]?.pendingProps?.children[0]?.props?.children?.props?.uri || 149 | values[0]?.pendingProps?.children[0]?.props?.children?.props?.children?.props?.uri || 150 | values[0]?.pendingProps?.children[0]?.props?.children[0]?.props?.uri 151 | ) 152 | } 153 | 154 | function getSongSnippets(uris) { 155 | const storedNotes = localStorage.getItem("songNotes"); 156 | const mappedNotes= storedNotes ? JSON.parse(storedNotes) : {}; 157 | if(!mappedNotes) return {}; 158 | const snippetMap = {}; 159 | 160 | uris.forEach((uri) => { 161 | console.log(uri) 162 | if (mappedNotes[uri]) { 163 | snippetMap[uri] = mappedNotes[uri].substring(0, 55) + "..."; 164 | } 165 | }); 166 | console.log(snippetMap) 167 | return snippetMap; 168 | } 169 | 170 | 171 | function insertTrackNotes(rowSelector) { 172 | 173 | const tracklist = document.getElementsByClassName("main-trackList-trackList main-trackList-indexable")[0]; 174 | tracklist.ariaColCount = "6"; 175 | const rows = document.querySelectorAll(rowSelector); 176 | if (!rows) return; 177 | // Get the uri for each track row using getTracklistTrackUri and store it in an array 178 | const uris = Array.from(rows, (row) => getTracklistTrackUri(row)) 179 | const snippetMap = getSongSnippets(uris); 180 | 181 | const headerRow = document.getElementsByClassName("main-trackList-trackListHeaderRow main-trackList-trackListRowGrid")[0]; 182 | if (!headerRow) return; 183 | const headerColumn = document.createElement("div"); 184 | headerColumn.classList.add("main-trackList-rowSectionVariable"); 185 | headerColumn.ariaColIndex = "6"; 186 | headerColumn.innerHTML = "Notes"; 187 | // Insert it before main-trackList-rowSectionEnd 188 | const endColumn = headerRow.querySelector(".main-trackList-rowSectionEnd"); 189 | if (!endColumn) return; 190 | headerRow.insertBefore(headerColumn, endColumn); 191 | 192 | 193 | 194 | rows.forEach((row) => { 195 | const uri = getTracklistTrackUri(row); 196 | const notes = snippetMap[uri] || ""; 197 | const column = document.createElement("div"); 198 | column.classList.add("track-notes-column"); 199 | column.ariaColIndex = "6"; 200 | column.innerHTML = `${notes}`; 201 | const endColumn = row.querySelector(".main-trackList-rowSectionEnd"); 202 | if (!endColumn) return; 203 | row.insertBefore(column, endColumn); 204 | }); 205 | } 206 | 207 | 208 | 209 | })(); 210 | -------------------------------------------------------------------------------- /writeify/writeify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxri/spicetify-extensions/5c8bedf61b718e4d47cc9adede4d39be59726a1e/writeify/writeify.png --------------------------------------------------------------------------------