├── .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 ``, ``, 1370 | * a custom react component that forwards a ref to a `` or ``, 1371 | * or a function. If a function is passed it will be called with 1372 | * (`isOpen`, `handleContextMenu`, `ref`) as arguments. 1373 | */ 1374 | children: Element | ((isOpen?: boolean, handleContextMenu?: (e: MouseEvent) => void, ref?: (e: Element) => void) => Element); 1375 | }; 1376 | type MenuProps = { 1377 | /** 1378 | * Function that is called when the menu is closed 1379 | */ 1380 | onClose?: () => void; 1381 | /** 1382 | * Function that provides the element that focus should jump to when the menu 1383 | * is opened 1384 | */ 1385 | getInitialFocusElement?: (el: HTMLElement | null) => HTMLElement | undefined | null; 1386 | }; 1387 | type MenuItemProps = { 1388 | /** 1389 | * Function that runs when `MenuItem` is clicked 1390 | */ 1391 | onClick?: React.MouseEventHandler; 1392 | /** 1393 | * Indicates if `MenuItem` is disabled. Disabled items will not cause 1394 | * the `Menu` to close when clicked. 1395 | */ 1396 | disabled?: boolean; 1397 | /** 1398 | * Indicate that a divider line should be added `before` or `after` this `MenuItem` 1399 | */ 1400 | divider?: "before" | "after" | "both"; 1401 | /** 1402 | * React component icon that will be rendered at the end of the `MenuItem` 1403 | * @deprecated Since Spotify `1.2.8`. Use `leadingIcon` or `trailingIcon` instead 1404 | */ 1405 | icon?: React.ReactNode; 1406 | /** 1407 | * React component icon that will be rendered at the start of the `MenuItem` 1408 | * @since Spotify `1.2.8` 1409 | */ 1410 | leadingIcon?: React.ReactNode; 1411 | /** 1412 | * React component icon that will be rendered at the end of the `MenuItem` 1413 | * @since Spotify `1.2.8` 1414 | */ 1415 | trailingIcon?: React.ReactNode; 1416 | }; 1417 | type TooltipProps = { 1418 | /** 1419 | * Label to display in the tooltip 1420 | */ 1421 | label: string; 1422 | /** 1423 | * The child element that the tooltip will be attached to 1424 | * and will display when hovered over 1425 | */ 1426 | children: React.ReactNode; 1427 | /** 1428 | * Decide whether to use the global singleton tooltip (rendered in ``) 1429 | * or a new inline tooltip (rendered in a sibling 1430 | * element to `children`) 1431 | */ 1432 | renderInline?: boolean; 1433 | /** 1434 | * Delay in milliseconds before the tooltip is displayed 1435 | * after the user hovers over the child element 1436 | */ 1437 | showDelay?: number; 1438 | /** 1439 | * Determine whether the tooltip should be displayed 1440 | */ 1441 | disabled?: boolean; 1442 | /** 1443 | * The preferred placement of the context menu when it opens. 1444 | * Relative to trigger element. 1445 | * @default 'top' 1446 | */ 1447 | placement?: 1448 | | "top" 1449 | | "top-start" 1450 | | "top-end" 1451 | | "right" 1452 | | "right-start" 1453 | | "right-end" 1454 | | "bottom" 1455 | | "bottom-start" 1456 | | "bottom-end" 1457 | | "left" 1458 | | "left-start" 1459 | | "left-end"; 1460 | /** 1461 | * Class name to apply to the tooltip 1462 | */ 1463 | labelClassName?: string; 1464 | }; 1465 | type IconComponentProps = { 1466 | /** 1467 | * Icon size 1468 | * @default 24 1469 | */ 1470 | iconSize?: number; 1471 | /** 1472 | * Icon color 1473 | * Might not be used by component 1474 | * @default 'currentColor' 1475 | */ 1476 | color?: string; 1477 | /** 1478 | * Semantic color name 1479 | * Matches color variables used in xpui 1480 | * @default Inherit from parent 1481 | */ 1482 | semanticColor?: SemanticColor; 1483 | /** 1484 | * Icon title 1485 | * @default '' 1486 | */ 1487 | title?: string; 1488 | /** 1489 | * Title ID (internal) 1490 | */ 1491 | titleId?: string; 1492 | /** 1493 | * Icon description 1494 | */ 1495 | desc?: string; 1496 | /** 1497 | * Description ID (internal) 1498 | */ 1499 | descId?: string; 1500 | /** 1501 | * Auto mirror icon 1502 | * @default false 1503 | */ 1504 | autoMirror?: boolean; 1505 | }; 1506 | type TextComponentProps = { 1507 | /** 1508 | * Text color 1509 | * Might not be used by component 1510 | * @default 'currentColor' 1511 | */ 1512 | color?: string; 1513 | /** 1514 | * Semantic color name 1515 | * Matches color variables used in xpui 1516 | * @default Inherit from parent 1517 | */ 1518 | semanticColor?: SemanticColor; 1519 | /** 1520 | * Text style variant 1521 | * @default 'viola' 1522 | */ 1523 | variant?: Variant; 1524 | /** 1525 | * Bottom padding size 1526 | */ 1527 | paddingBottom?: string; 1528 | /** 1529 | * Font weight 1530 | */ 1531 | weight?: "book" | "bold" | "black"; 1532 | }; 1533 | type ConfirmDialogProps = { 1534 | /** 1535 | * Boolean to determine if the dialog should be opened 1536 | * @default true 1537 | */ 1538 | isOpen?: boolean; 1539 | /** 1540 | * Whether to allow inline HTML in component text 1541 | * @default false 1542 | */ 1543 | allowHTML?: boolean; 1544 | /** 1545 | * Dialog title. Can be inline HTML if `allowHTML` is true 1546 | */ 1547 | titleText: string; 1548 | /** 1549 | * Dialog description. Can be inline HTML if `allowHTML` is true 1550 | */ 1551 | descriptionText?: string; 1552 | /** 1553 | * Confirm button text 1554 | */ 1555 | confirmText?: string; 1556 | /** 1557 | * Cancel button text 1558 | */ 1559 | cancelText?: string; 1560 | /** 1561 | * Confirm button aria-label 1562 | */ 1563 | confirmLabel?: string; 1564 | /** 1565 | * Function to run when confirm button is clicked 1566 | * The dialog does not close automatically, a handler must be included. 1567 | * @param {React.MouseEvent} event 1568 | */ 1569 | onConfirm?: (event: React.MouseEvent) => void; 1570 | /** 1571 | * Function to run when cancel button is clicked. 1572 | * The dialog does not close automatically, a handler must be included. 1573 | * @param {React.MouseEvent} event 1574 | */ 1575 | onClose?: (event: React.MouseEvent) => void; 1576 | /** 1577 | * Function to run when dialog is clicked outside of. 1578 | * By default, this will run `onClose`. 1579 | * A handler must be included to close the dialog. 1580 | * @param {React.MouseEvent} event 1581 | */ 1582 | onOutside?: (event: React.MouseEvent) => void; 1583 | }; 1584 | type SliderProps = { 1585 | /** 1586 | * Label for the slider. 1587 | */ 1588 | labelText?: string; 1589 | /** 1590 | * The current value of the slider. 1591 | */ 1592 | value: number; 1593 | /** 1594 | * The minimum value of the slider. 1595 | */ 1596 | min: number; 1597 | /** 1598 | * The maximum value of the slider. 1599 | */ 1600 | max: number; 1601 | /** 1602 | * The step value of the slider. 1603 | */ 1604 | step: number; 1605 | /** 1606 | * Whether or not the slider is disabled/can be interacted with. 1607 | * @default true 1608 | */ 1609 | isInteractive?: boolean; 1610 | /** 1611 | * Whether or not the active style of the slider should be shown. 1612 | * This is equivalent to the slider being focused/hovered. 1613 | * @default false 1614 | */ 1615 | forceActiveStyles?: boolean; 1616 | /** 1617 | * Callback function that is called when the slider starts being dragged. 1618 | * 1619 | * @param {number} value The current value of the slider in percent. 1620 | */ 1621 | onDragStart: (value: number) => void; 1622 | /** 1623 | * Callback function that is called when the slider is being dragged. 1624 | * 1625 | * @param {number} value The current value of the slider in percent. 1626 | */ 1627 | onDragMove: (value: number) => void; 1628 | /** 1629 | * Callback function that is called when the slider stops being dragged. 1630 | * 1631 | * @param {number} value The current value of the slider in percent. 1632 | */ 1633 | onDragEnd: (value: number) => void; 1634 | /** 1635 | * Callback function that is called when the slider incremented a step. 1636 | * 1637 | * @deprecated Use `onDrag` props instead. 1638 | */ 1639 | onStepForward?: () => void; 1640 | /** 1641 | * Callback function that is called when the slider decremented a step. 1642 | * 1643 | * @deprecated Use `onDrag` props instead. 1644 | */ 1645 | onStepBackward?: () => void; 1646 | }; 1647 | type ButtonProps = { 1648 | component: any; 1649 | /** 1650 | * Color set for the button. 1651 | * @default "brightAccent" 1652 | */ 1653 | colorSet?: ColorSet; 1654 | /** 1655 | * Size for the button. 1656 | * @default "md" 1657 | */ 1658 | buttonSize?: "sm" | "md" | "lg"; 1659 | /** 1660 | * Size for the button. 1661 | * @deprecated Use `buttonSize` prop instead, as it will take precedence. 1662 | * @default "medium" 1663 | */ 1664 | size?: "small" | "medium" | "large"; 1665 | /** 1666 | * Unused by Spotify. Usage unknown. 1667 | */ 1668 | fullWidth?: any; 1669 | /** 1670 | * React component to render for an icon placed before children. Component, not element! 1671 | */ 1672 | iconLeading?: (props: any) => any | string; 1673 | /** 1674 | * React component to render for an icon placed after children. Component, not element! 1675 | */ 1676 | iconTrailing?: (props: any) => any | string; 1677 | /** 1678 | * React component to render for an icon used as button body. Component, not element! 1679 | */ 1680 | iconOnly?: (props: any) => any | string; 1681 | /** 1682 | * Additional class name to apply to the button. 1683 | */ 1684 | className?: string; 1685 | /** 1686 | * Label of the element for screen readers. 1687 | */ 1688 | "aria-label"?: string; 1689 | /** 1690 | * ID of an element that describes the button for screen readers. 1691 | */ 1692 | "aria-labelledby"?: string; 1693 | /** 1694 | * Unsafely set the color set for the button. 1695 | * Values from the colorSet will be pasted into the CSS. 1696 | */ 1697 | UNSAFE_colorSet?: ColorSetBody; 1698 | onClick?: (event: MouseEvent) => void; 1699 | onMouseEnter?: (event: MouseEvent) => void; 1700 | onMouseLeave?: (event: MouseEvent) => void; 1701 | onMouseDown?: (event: MouseEvent) => void; 1702 | onMouseUp?: (event: MouseEvent) => void; 1703 | onFocus?: (event: FocusEvent) => void; 1704 | onBlur?: (event: FocusEvent) => void; 1705 | }; 1706 | /** 1707 | * Generic context menu provider 1708 | * 1709 | * Props: 1710 | * @see Spicetify.ReactComponent.ContextMenuProps 1711 | */ 1712 | const ContextMenu: any; 1713 | /** 1714 | * Wrapper of ReactComponent.ContextMenu with props: action = 'toggle' and trigger = 'right-click' 1715 | * 1716 | * Props: 1717 | * @see Spicetify.ReactComponent.ContextMenuProps 1718 | */ 1719 | const RightClickMenu: any; 1720 | /** 1721 | * Outer layer contain ReactComponent.MenuItem(s) 1722 | * 1723 | * Props: 1724 | * @see Spicetify.ReactComponent.MenuProps 1725 | */ 1726 | const Menu: any; 1727 | /** 1728 | * Component to construct menu item 1729 | * Used as ReactComponent.Menu children 1730 | * 1731 | * Props: 1732 | * @see Spicetify.ReactComponent.MenuItemProps 1733 | */ 1734 | const MenuItem: any; 1735 | /** 1736 | * Tailored ReactComponent.Menu for specific type of object 1737 | * 1738 | * Props: { 1739 | * uri: string; 1740 | * onRemoveCallback?: (uri: string) => void; 1741 | * } 1742 | */ 1743 | const AlbumMenu: any; 1744 | const PodcastShowMenu: any; 1745 | const ArtistMenu: any; 1746 | const PlaylistMenu: any; 1747 | const TrackMenu: any; 1748 | /** 1749 | * Component to display tooltip when hovering over element 1750 | * Useful for accessibility 1751 | * 1752 | * Props: 1753 | * @see Spicetify.ReactComponent.TooltipProps 1754 | */ 1755 | const TooltipWrapper: any; 1756 | /** 1757 | * Component to render Spotify-style icon 1758 | * @since Spotify `1.1.95` 1759 | * 1760 | * Props: 1761 | * @see Spicetify.ReactComponent.IconComponentProps 1762 | */ 1763 | const IconComponent: any; 1764 | /** 1765 | * Component to render Spotify-style text 1766 | * @since Spotify `1.1.95` 1767 | * 1768 | * Props: 1769 | * @see Spicetify.ReactComponent.TextComponentProps 1770 | */ 1771 | const TextComponent: any; 1772 | /** 1773 | * Component to render Spotify-style confirm dialog 1774 | * 1775 | * Props: 1776 | * @see Spicetify.ReactComponent.ConfirmDialogProps 1777 | */ 1778 | const ConfirmDialog: any; 1779 | /** 1780 | * Component to render Spotify slider 1781 | * 1782 | * Used in progress bar, volume slider, crossfade settings, etc. 1783 | * 1784 | * Props: 1785 | * @see Spicetify.ReactComponent.SliderProps 1786 | */ 1787 | const Slider: any; 1788 | /** 1789 | * Component to render Spotify primary button 1790 | * 1791 | * Props: 1792 | * @see Spicetify.ReactComponent.ButtonProps 1793 | */ 1794 | const ButtonPrimary: any; 1795 | /** 1796 | * Component to render Spotify secondary button 1797 | * 1798 | * Props: 1799 | * @see Spicetify.ReactComponent.ButtonProps 1800 | */ 1801 | const ButtonSecondary: any; 1802 | /** 1803 | * Component to render Spotify tertiary button 1804 | * 1805 | * Props: 1806 | * @see Spicetify.ReactComponent.ButtonProps 1807 | */ 1808 | const ButtonTertiary: any; 1809 | } 1810 | 1811 | /** 1812 | * Add button in top bar next to navigation buttons 1813 | */ 1814 | namespace Topbar { 1815 | class Button { 1816 | constructor(label: string, icon: Icon | string, onClick: (self: Button) => void, disabled?: boolean); 1817 | label: string; 1818 | icon: string; 1819 | onClick: (self: Button) => void; 1820 | disabled: boolean; 1821 | element: HTMLButtonElement; 1822 | tippy: any; 1823 | } 1824 | } 1825 | 1826 | /** 1827 | * Add button in player controls 1828 | */ 1829 | namespace Playbar { 1830 | /** 1831 | * Create a button on the right side of the playbar 1832 | */ 1833 | class Button { 1834 | constructor( 1835 | label: string, 1836 | icon: Icon | string, 1837 | onClick?: (self: Button) => void, 1838 | disabled?: boolean, 1839 | active?: boolean, 1840 | registerOnCreate?: boolean 1841 | ); 1842 | label: string; 1843 | icon: string; 1844 | onClick: (self: Button) => void; 1845 | disabled: boolean; 1846 | active: boolean; 1847 | element: HTMLButtonElement; 1848 | tippy: any; 1849 | register: () => void; 1850 | deregister: () => void; 1851 | } 1852 | 1853 | /** 1854 | * Create a widget next to track info 1855 | */ 1856 | class Widget { 1857 | constructor( 1858 | label: string, 1859 | icon: Icon | string, 1860 | onClick?: (self: Widget) => void, 1861 | disabled?: boolean, 1862 | active?: boolean, 1863 | registerOnCreate?: boolean 1864 | ); 1865 | label: string; 1866 | icon: string; 1867 | onClick: (self: Widget) => void; 1868 | disabled: boolean; 1869 | active: boolean; 1870 | element: HTMLButtonElement; 1871 | tippy: any; 1872 | register: () => void; 1873 | deregister: () => void; 1874 | } 1875 | } 1876 | 1877 | /** 1878 | * SVG icons 1879 | */ 1880 | const SVGIcons: Record; 1881 | 1882 | /** 1883 | * Return font styling used by Spotify. 1884 | * @param font Name of the font. 1885 | * Can match any of the fonts listed in `Spicetify._fontStyle` or returns a generic style otherwise. 1886 | */ 1887 | function getFontStyle(font: Variant): string; 1888 | 1889 | /** 1890 | * A filtered copy of user's `config-xpui` file. 1891 | */ 1892 | namespace Config { 1893 | const version: string; 1894 | const current_theme: string; 1895 | const color_scheme: string; 1896 | const extensions: string[]; 1897 | const custom_apps: string[]; 1898 | } 1899 | 1900 | /** 1901 | * Tippy.js instance used by Spotify 1902 | */ 1903 | const Tippy: any; 1904 | /** 1905 | * Spicetify's predefined props for Tippy.js 1906 | * Used to mimic Spotify's tooltip behavior 1907 | */ 1908 | const TippyProps: any; 1909 | 1910 | /** 1911 | * Interface for interacting with Spotify client's app title 1912 | */ 1913 | namespace AppTitle { 1914 | /** 1915 | * Set default app title. This has no effect if the player is running. 1916 | * Will override any previous forced title. 1917 | * @param title Title to set 1918 | * @return Promise that resolves to a function to cancel forced title. This doesn't reset the title. 1919 | */ 1920 | function set(title: string): Promise<{ clear: () => void }>; 1921 | /** 1922 | * Reset app title to default 1923 | */ 1924 | function reset(): Promise; 1925 | /** 1926 | * Get current default app title 1927 | * @return Current default app title 1928 | */ 1929 | function get(): Promise; 1930 | /** 1931 | * Subscribe to title changes. 1932 | * This event is not fired when the player changes app title. 1933 | * @param callback Callback to call when title changes 1934 | * @return Object with method to unsubscribe 1935 | */ 1936 | function sub(callback: (title: string) => void): { clear: () => void }; 1937 | } 1938 | 1939 | /** 1940 | * Spicetify's QraphQL wrapper for Spotify's GraphQL API endpoints 1941 | */ 1942 | namespace GraphQL { 1943 | /** 1944 | * Possible types of entities. 1945 | * 1946 | * This list is dynamic and may change in the future. 1947 | */ 1948 | type Query = 1949 | | "decorateItemsForEnhance" 1950 | | "imageURLAndSize" 1951 | | "imageSources" 1952 | | "audioItems" 1953 | | "creator" 1954 | | "extractedColors" 1955 | | "extractedColorsAndImageSources" 1956 | | "fetchExtractedColorAndImageForAlbumEntity" 1957 | | "fetchExtractedColorAndImageForArtistEntity" 1958 | | "fetchExtractedColorAndImageForEpisodeEntity" 1959 | | "fetchExtractedColorAndImageForPlaylistEntity" 1960 | | "fetchExtractedColorAndImageForPodcastEntity" 1961 | | "fetchExtractedColorAndImageForTrackEntity" 1962 | | "fetchExtractedColorForAlbumEntity" 1963 | | "fetchExtractedColorForArtistEntity" 1964 | | "fetchExtractedColorForEpisodeEntity" 1965 | | "fetchExtractedColorForPlaylistEntity" 1966 | | "fetchExtractedColorForPodcastEntity" 1967 | | "fetchExtractedColorForTrackEntity" 1968 | | "getAlbumNameAndTracks" 1969 | | "getEpisodeName" 1970 | | "getTrackName" 1971 | | "queryAlbumTrackUris" 1972 | | "queryTrackArtists" 1973 | | "decorateContextEpisodesOrChapters" 1974 | | "decorateContextTracks" 1975 | | "fetchTracksForRadioStation" 1976 | | "decoratePlaylists" 1977 | | "playlistUser" 1978 | | "FetchPlaylistMetadata" 1979 | | "playlistContentsItemTrackArtist" 1980 | | "playlistContentsItemTrackAlbum" 1981 | | "playlistContentsItemTrack" 1982 | | "playlistContentsItemLocalTrack" 1983 | | "playlistContentsItemEpisodeShow" 1984 | | "playlistContentsItemEpisode" 1985 | | "playlistContentsItemResponse" 1986 | | "playlistContentsItem" 1987 | | "FetchPlaylistContents" 1988 | | "episodeTrailerUri" 1989 | | "podcastEpisode" 1990 | | "podcastMetadataV2" 1991 | | "minimalAudiobook" 1992 | | "audiobookChapter" 1993 | | "audiobookMetadataV2" 1994 | | "fetchExtractedColors" 1995 | | "queryFullscreenMode" 1996 | | "queryNpvEpisode" 1997 | | "queryNpvArtist" 1998 | | "albumTrack" 1999 | | "getAlbum" 2000 | | "queryAlbumTracks" 2001 | | "queryArtistOverview" 2002 | | "queryArtistAppearsOn" 2003 | | "discographyAlbum" 2004 | | "albumMetadataReleases" 2005 | | "albumMetadata" 2006 | | "queryArtistDiscographyAlbums" 2007 | | "queryArtistDiscographySingles" 2008 | | "queryArtistDiscographyCompilations" 2009 | | "queryArtistDiscographyAll" 2010 | | "queryArtistDiscographyOverview" 2011 | | "artistPlaylist" 2012 | | "queryArtistPlaylists" 2013 | | "queryArtistDiscoveredOn" 2014 | | "queryArtistFeaturing" 2015 | | "queryArtistRelated" 2016 | | "queryArtistMinimal" 2017 | | "searchModalResults" 2018 | | "queryWhatsNewFeed" 2019 | | "whatsNewFeedNewItems" 2020 | | "SetItemsStateInWhatsNewFeed" 2021 | | "browseImageURLAndSize" 2022 | | "browseImageSources" 2023 | | "browseAlbum" 2024 | | "browseArtist" 2025 | | "browseEpisode" 2026 | | "browseChapter" 2027 | | "browsePlaylist" 2028 | | "browsePodcast" 2029 | | "browseAudiobook" 2030 | | "browseTrack" 2031 | | "browseUser" 2032 | | "browseMerch" 2033 | | "browseArtistConcerts" 2034 | | "browseContent" 2035 | | "browseSectionContainer" 2036 | | "browseClientFeature" 2037 | | "browseItem" 2038 | | "browseAll" 2039 | | "browsePage"; 2040 | /** 2041 | * Collection of GraphQL definitions. 2042 | */ 2043 | const Definitions: Record; 2044 | /** 2045 | * Sends a GraphQL query to Spotify. 2046 | * @description A preinitialized version of `Spicetify.GraphQL.Handler` using current context. 2047 | * @param query Query to send 2048 | * @param variables Variables to use 2049 | * @param context Context to use 2050 | * @return Promise that resolves to the response 2051 | */ 2052 | function Request(query: (typeof Definitions)[Query | string], variables?: Record, context?: Record): Promise; 2053 | /** 2054 | * Context for GraphQL queries. 2055 | * @description Used to set context for the handler and initialze it. 2056 | */ 2057 | const Context: Record; 2058 | /** 2059 | * Handler for GraphQL queries. 2060 | * @param context Context to use 2061 | * @return Function to handle GraphQL queries 2062 | */ 2063 | function Handler( 2064 | context: Record 2065 | ): (query: (typeof Definitions)[Query | string], variables?: Record, context?: Record) => Promise; 2066 | } 2067 | 2068 | namespace ReactHook { 2069 | /** 2070 | * React Hook to create interactive drag-and-drop element 2071 | * @description Used to create a draggable element that can be dropped into Spotify's components (e.g. Playlist, Folder, Sidebar, Queue) 2072 | * @param uris List of URIs to be dragged 2073 | * @param label Label to be displayed when dragging 2074 | * @param contextUri Context URI of the element from which the drag originated (e.g. Playlist URI) 2075 | * @param sectionIndex Index of the section in which the drag originated 2076 | * @param dropOriginUri URI of the desired drop target. Leave empty to allow drop anywhere 2077 | * @return Function to handle drag event. Should be passed to `onDragStart` prop of the element. All parameters passed onto the hook will be passed onto the handler unless declared otherwise. 2078 | * 2079 | */ 2080 | function DragHandler( 2081 | uris?: string[], 2082 | label?: string, 2083 | contextUri?: string, 2084 | sectionIndex?: number, 2085 | dropOriginUri?: string 2086 | ): (event: React.DragEvent, uris?: string[], label?: string, contextUri?: string, sectionIndex?: number) => void; 2087 | 2088 | /** 2089 | * React Hook to use extracted color from GraphQL 2090 | * 2091 | * @note This is a wrapper of ReactQuery's `useQuery` hook. 2092 | * The component using this hook must be wrapped in a `QueryClientProvider` component. 2093 | * 2094 | * @see https://tanstack.com/query/v3/docs/react/reference/QueryClientProvider 2095 | * 2096 | * @param uri URI of the Spotify image to extract color from. 2097 | * @param fallbackColor Fallback color to use if the image is not available. Defaults to `#535353`. 2098 | * @param variant Variant of the color to use. Defaults to `colorRaw`. 2099 | * 2100 | * @return Extracted color hex code. 2101 | */ 2102 | function useExtractedColor(uri: string, fallbackColor?: string, variant?: "colorRaw" | "colorLight" | "colorDark"): string; 2103 | } 2104 | 2105 | /** 2106 | * react-flip-toolkit 2107 | * @description A lightweight magic-move library for configurable layout transitions. 2108 | * @link https://github.com/aholachek/react-flip-toolkit 2109 | */ 2110 | namespace ReactFlipToolkit { 2111 | const Flipper: any; 2112 | const Flipped: any; 2113 | const spring: any; 2114 | } 2115 | 2116 | /** 2117 | * classnames 2118 | * @description A simple JavaScript utility for conditionally joining classNames together. 2119 | * @link https://github.com/JedWatson/classnames 2120 | */ 2121 | function classnames(...args: any[]): string; 2122 | 2123 | /** 2124 | * React Query v3 2125 | * @description A hook for fetching, caching and updating asynchronous data in React. 2126 | * @link https://github.com/TanStack/query/tree/v3 2127 | */ 2128 | const ReactQuery: any; 2129 | 2130 | /** 2131 | * Analyse and extract color presets from an image. Works for any valid image URL/URI. 2132 | * @param image Spotify URI to an image, or an image URL. 2133 | */ 2134 | function extractColorPreset(image: string | string[]): Promise< 2135 | { 2136 | colorRaw: Color; 2137 | colorLight: Color; 2138 | colorDark: Color; 2139 | isFallback: boolean; 2140 | }[] 2141 | >; 2142 | 2143 | interface hsl { 2144 | h: number; 2145 | s: number; 2146 | l: number; 2147 | } 2148 | interface hsv { 2149 | h: number; 2150 | s: number; 2151 | v: number; 2152 | } 2153 | interface rgb { 2154 | r: number; 2155 | g: number; 2156 | b: number; 2157 | } 2158 | type CSSColors = "HEX" | "HEXA" | "HSL" | "HSLA" | "RGB" | "RGBA"; 2159 | /** 2160 | * Spotify's internal color class 2161 | */ 2162 | class Color { 2163 | constructor(rgb: rgb, hsl: hsl, hsv: hsv, alpha?: number); 2164 | 2165 | static BLACK: Color; 2166 | static WHITE: Color; 2167 | static CSSFormat: Record & Record; 2168 | 2169 | a: number; 2170 | hsl: hsl; 2171 | hsv: hsv; 2172 | rgb: rgb; 2173 | 2174 | /** 2175 | * Convert CSS representation to Color 2176 | * @param cssColor CSS representation of the color. Must not contain spaces. 2177 | * @param alpha Alpha value of the color. Defaults to 1. 2178 | * @return Color object 2179 | * @throws {Error} If the CSS color is invalid or unsupported 2180 | */ 2181 | static fromCSS(cssColor: string, alpha?: number): Color; 2182 | static fromHSL(hsl: hsl, alpha?: number): Color; 2183 | static fromHSV(hsv: hsv, alpha?: number): Color; 2184 | static fromRGB(rgb: rgb, alpha?: number): Color; 2185 | static fromHex(hex: string, alpha?: number): Color; 2186 | 2187 | /** 2188 | * Change the contrast of the color against another so that 2189 | * the contrast between them is at least `strength`. 2190 | */ 2191 | contrastAdjust(against: Color, strength?: number): Color; 2192 | 2193 | /** 2194 | * Stringify JSON result 2195 | */ 2196 | stringify(): string; 2197 | 2198 | /** 2199 | * Convert to CSS representation 2200 | * @param colorFormat CSS color format to convert to 2201 | * @return CSS representation of the color 2202 | */ 2203 | toCSS(colorFormat: number): string; 2204 | 2205 | /** 2206 | * Return RGBA representation of the color 2207 | */ 2208 | toString(): string; 2209 | } 2210 | 2211 | /** 2212 | * Spotify internal library for localization 2213 | */ 2214 | namespace Locale { 2215 | /** 2216 | * Relative time format 2217 | */ 2218 | const _relativeTimeFormat: Intl.RelativeTimeFormat | null; 2219 | /** 2220 | * Registered date time formats in the current session 2221 | */ 2222 | const _dateTimeFormats: Record; 2223 | /** 2224 | * Current locale of the client 2225 | */ 2226 | const _locale: string; 2227 | const _urlLocale: string; 2228 | /** 2229 | * Collection of supported locales 2230 | */ 2231 | const _supportedLocales: Record; 2232 | /** 2233 | * Dictionary of localized strings 2234 | */ 2235 | const _dictionary: Record; 2236 | 2237 | /** 2238 | * Format date into locale string 2239 | * 2240 | * @param date Date to format 2241 | * @param options Options to use 2242 | * @return Localized string 2243 | * @throws {RangeError} If the date is invalid 2244 | */ 2245 | function formatDate(date: number | Date | undefined, options?: Intl.DateTimeFormatOptions): string; 2246 | /** 2247 | * Format time into relative locale string 2248 | * 2249 | * @param date Date to format 2250 | * @param options Options to use 2251 | * @return Localized string 2252 | * @throws {RangeError} If the date is invalid 2253 | */ 2254 | function formatRelativeTime(date: number | Date | undefined, options?: Intl.DateTimeFormatOptions): string; 2255 | /** 2256 | * Format number into locale string 2257 | * 2258 | * @param number Number to format 2259 | * @param options Options to use 2260 | * @return Localized string 2261 | */ 2262 | function formatNumber(number: number, options?: Intl.NumberFormatOptions): string; 2263 | /** 2264 | * Format number into compact locale string 2265 | * 2266 | * @param number Number to format 2267 | * @return Localized string 2268 | */ 2269 | function formatNumberCompact(number: number): string; 2270 | /** 2271 | * Get localized string 2272 | * 2273 | * @param key Key of the string 2274 | * @param children React children to pass the string into 2275 | * @return Localized string or React Fragment of the children 2276 | */ 2277 | function get(key: string, ...children: React.ReactNode[]): string | React.ReactNode; 2278 | /** 2279 | * Get date time format of the passed options. 2280 | * 2281 | * Function calls here will register to the `_dateTimeFormats` dictionary. 2282 | * 2283 | * @param options Options to use 2284 | * @return Date time format 2285 | */ 2286 | function getDateTimeFormat(options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat; 2287 | /** 2288 | * Get the current locale dictionary 2289 | * 2290 | * @return Current locale dictionary 2291 | */ 2292 | function getDictionary(): Record; 2293 | /** 2294 | * Get the current locale 2295 | * 2296 | * @return Current locale 2297 | */ 2298 | function getLocale(): string; 2299 | /** 2300 | * Get the current locale code for Smartling 2301 | * 2302 | * @return Current locale code for Smartling 2303 | */ 2304 | function getSmartlingLocale(): string; 2305 | /** 2306 | * Get the current locale code for URL 2307 | * 2308 | * @return Current locale code for URL 2309 | */ 2310 | function getUrlLocale(): string; 2311 | /** 2312 | * Get the current relative time format 2313 | * 2314 | * @return Current relative time format 2315 | */ 2316 | function getRelativeTimeFormat(): Intl.RelativeTimeFormat; 2317 | /** 2318 | * Get the separator for the current locale 2319 | * 2320 | * @return Separator for the current locale 2321 | */ 2322 | function getSeparator(): string; 2323 | /** 2324 | * Set the current locale 2325 | * 2326 | * This will clear all previously set relative time formats and key-value pairs. 2327 | * 2328 | * @param locale Locale to set 2329 | */ 2330 | function setLocale(locale: string): void; 2331 | /** 2332 | * Set the current locale code for URL 2333 | * 2334 | * @param locale Locale code for URL to set 2335 | */ 2336 | function setUrlLocale(locale: string): void; 2337 | /** 2338 | * Set the dictionary for the current locale 2339 | * 2340 | * @param dictionary Dictionary to set 2341 | */ 2342 | function setDictionary(dictionary: Record): void; 2343 | /** 2344 | * Transform text into locale lowercase 2345 | * 2346 | * @param text Text to transform 2347 | * @return Locale lowercase text 2348 | */ 2349 | function toLocaleLowerCase(text: string): string; 2350 | /** 2351 | * Transform text into locale uppercase 2352 | * 2353 | * @param text Text to transform 2354 | * @return Locale uppercase text 2355 | */ 2356 | function toLocaleUpperCase(text: string): string; 2357 | } 2358 | } 2359 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2023 CharlieS1103 4 | Copyright (c) 2024 ririxi 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spicetify extensions 2 | 3 | ## [adblockify](./adblock/README.md) 4 | 5 | Stream Spotify music without interruptions - Block ads instantly, with ease. 6 | 7 | > [!NOTE] 8 | > Premium features include: 9 | > - No forced shuffle 10 | > - No forced repeat 11 | > - Modifiable queue 12 | > - No ads between songs and in the UI 13 | > - No upsell popups about premium or popup ads 14 | 15 | > [!CAUTION] 16 | > This extension **won't**: 17 | > - Unlock native lyrics page 18 | > - Let you download songs 19 | > - Allow to change song quality to `Very High` 20 | > - Allow you to listen with friends in Jams 21 | 22 | 23 | ## [Song-Stats](./songstats/README.md) 24 | 25 | Display the audio features of a song 26 | 27 | #### To use: 28 | 29 | * Right click any song and click *View Song Stats* in the context menu 30 | 31 | ## [Feature Shuffle](./featureshuffle/README.md) 32 | 33 | Create a randomized playlist based on the average audio features of the clicked playlist 34 | 35 | #### To use: 36 | 37 | * Right click a playlist and click *Create Feature Based Playlist*, note that it may take up to ~3 minutes to generate a playlist. 38 | 39 | ## [Wikify](./wikify/README.md) 40 | 41 | Display an Artists Wikipedia page and learn more about them! 42 | 43 | #### To use: 44 | 45 | * Right click a playlist and click *View Wiki* 46 | 47 | ## [FixEnhance](./old-sidebar/README.md) 48 | 49 | Patch spotify enhance playlist feature (Spotify patched it themselves, however if you prefer an old version this should fix it for you) 50 | 51 | #### To use: 52 | 53 | * In order to work, you must have experimental features enabled on Spicetify: 54 | ``spicetify config experimental_features 1`` 55 | ``spicetify apply``. 56 | You must also enable the "Enable Enhance Playlist UI and functionality" feature in the experimental features menu. 57 | 58 | ## [FormatColors](./formatColors/README.md) 59 | 60 | Convert the current colors defined in root to color.ini format, made for Theme Developers 61 | 62 | #### To use: 63 | 64 | * Click the button on the topbar, right click the text and click copy. 65 | 66 | ## [PhraseToPlaylist](./phraseToPlaylist/README.md) 67 | 68 | Convert a phrase into a playlist with songs arraged to make that phrase 69 | 70 | #### To use: 71 | 72 | * Click the button on the topbar, enter phrase, click submit. 73 | 74 | ----- 75 | Most of these extensions are made by [CharlieS1103](https://github.com/CharlieS1103) except [adblockify](./adblock/README.md). -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | Nothing to do atm... -------------------------------------------------------------------------------- /adblock/README.md: -------------------------------------------------------------------------------- 1 | # adblockify 2 | 3 | Stream Spotify music without interruptions - Block ads instantly, with ease. 4 | 5 | > [!NOTE] 6 | > Premium features include: 7 | > - No forced shuffle 8 | > - No forced repeat 9 | > - Modifiable queue 10 | > - No ads between songs and in the UI 11 | > - No upsell popups about premium or popup ads 12 | 13 | > [!CAUTION] 14 | > This extension **won't**: 15 | > - Unlock native lyrics page 16 | > - Let you download songs 17 | > - Allow to change song quality to `Very High` 18 | > - Allow you to listen with friends in Jams 19 | 20 | ## Install 21 | 22 | 1. Copy `adblock.js` to extensions folder based on your OS or install it via **[marketplace](https://github.com/spicetify/spicetify-marketplace)** 23 | 24 | | **Platform** | **Path** | 25 | |----------------|--------------------------------------------------------------------------------------| 26 | | **Windows** | `%appdata%\spicetify\Extensions\` | 27 | | **Linux** | `~/.config/spicetify/Extensions` or `$XDG_CONFIG_HOME/spicetify/Extensions/` | 28 | | **MacOS** | `~/.config/spicetify/Extensions` or `~/.spicetify/Extensions` | 29 | 30 | After putting the extension file into the correct folder, run the following command to install the extension or install through marketplace: 31 | 32 | ```sh 33 | spicetify config extensions adblock.js 34 | spicetify apply 35 | ``` 36 | 37 | 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. 38 | 39 | Or you can manually edit your `config-xpui.ini` file. Add your desired extension filenames in the extensions key, separated them by the | character. 40 | Example: 41 | 42 | ```ini 43 | [AdditionalOptions] 44 | ... 45 | extensions = autoSkipExplicit.js|shuffle+.js|trashbin.js|adblock.js 46 | ``` 47 | 48 | Then run: 49 | 50 | ```sh 51 | spicetify apply 52 | ``` 53 | 54 | ----- 55 | If you find any bugs, please [create a new issue](https://github.com/rxri/spicetify-extensions/issues/new/choose) on the GitHub repo. 56 |  57 | -------------------------------------------------------------------------------- /adblock/adblock.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /** 3 | * @author ririxi 4 | */ 5 | const loadWebpack = () => { 6 | try { 7 | const require = window.webpackChunkclient_web.push([[Symbol()], {}, (re) => re]); 8 | const cache = Object.keys(require.m).map(id => require(id)); 9 | const modules = cache 10 | .filter(module => typeof module === "object") 11 | .flatMap(module => { 12 | try { 13 | return Object.values(module); 14 | } 15 | catch { } 16 | }); 17 | const functionModules = modules.filter(module => typeof module === "function"); 18 | return { cache, functionModules }; 19 | } 20 | catch (error) { 21 | console.error("adblockify: Failed to load webpack", error); 22 | return { cache: [], functionModules: [] }; 23 | } 24 | }; 25 | const getSettingsClient = (cache, functionModules = [], transport = {}) => { 26 | try { 27 | const settingsClient = cache.find((m) => m?.settingsClient)?.settingsClient; 28 | if (!settingsClient) { 29 | const settings = functionModules.find(m => m?.SERVICE_ID === "spotify.ads.esperanto.settings.proto.Settings" || m?.SERVICE_ID === "spotify.ads.esperanto.proto.Settings"); 30 | return new settings(transport); 31 | } 32 | return settingsClient; 33 | } 34 | catch (error) { 35 | console.error("adblockify: Failed to get ads settings client", error); 36 | return null; 37 | } 38 | }; 39 | const getSlotsClient = (functionModules, transport) => { 40 | try { 41 | const slots = functionModules.find(m => m.SERVICE_ID === "spotify.ads.esperanto.slots.proto.Slots" || m.SERVICE_ID === "spotify.ads.esperanto.proto.Slots"); 42 | return new slots(transport); 43 | } 44 | catch (error) { 45 | console.error("adblockify: Failed to get slots client", error); 46 | return null; 47 | } 48 | }; 49 | const getTestingClient = (functionModules, transport) => { 50 | try { 51 | const testing = functionModules.find(m => m.SERVICE_ID === "spotify.ads.esperanto.testing.proto.Testing" || m.SERVICE_ID === "spotify.ads.esperanto.proto.Testing"); 52 | return new testing(transport); 53 | } 54 | catch (error) { 55 | console.error("adblockify: Failed to get testing client", error); 56 | return null; 57 | } 58 | }; 59 | const map = new Map(); 60 | const retryCounter = (slotId, action) => { 61 | if (!map.has(slotId)) 62 | map.set(slotId, { count: 0 }); 63 | if (action === "increment") 64 | map.get(slotId).count++; 65 | else if (action === "clear") 66 | map.delete(slotId); 67 | else if (action === "get") 68 | return map.get(slotId)?.count; 69 | }; 70 | (async function adblockify() { 71 | // @ts-expect-error: Events are not defined in types 72 | await new Promise(res => Spicetify.Events.platformLoaded.on(res)); 73 | // @ts-expect-error: Events are not defined in types 74 | await new Promise(res => Spicetify.Events.webpackLoaded.on(res)); 75 | const webpackCache = loadWebpack(); 76 | const { Platform, Locale } = Spicetify; 77 | const { AdManagers } = Platform; 78 | if (!AdManagers?.audio || Object.keys(AdManagers).length === 0) { 79 | setTimeout(adblockify, 100); 80 | return; 81 | } 82 | const { audio } = AdManagers; 83 | const { UserAPI } = Platform; 84 | const productState = UserAPI._product_state || UserAPI._product_state_service || Platform?.ProductStateAPI?.productStateApi; 85 | if (!Spicetify?.CosmosAsync) { 86 | setTimeout(adblockify, 100); 87 | return; 88 | } 89 | const { CosmosAsync } = Spicetify; 90 | let slots = []; 91 | const slotsClient = getSlotsClient(webpackCache.functionModules, productState.transport); 92 | if (slotsClient) 93 | slots = (await slotsClient.getSlots()).adSlots; 94 | else 95 | slots = await CosmosAsync.get("sp://ads/v1/slots"); 96 | const hideAdLikeElements = () => { 97 | const css = document.createElement("style"); 98 | const upgradeText = Locale.get("upgrade.tooltip.title"); 99 | css.className = "adblockify"; 100 | css.innerHTML = `.nHCJskDZVlmDhNNS9Ixv, .utUDWsORU96S7boXm2Aq, .cpBP3znf6dhHLA2dywjy, .G7JYBeU1c2QawLyFs5VK, .vYl1kgf1_R18FCmHgdw2, .vZkc6VwrFz0EjVBuHGmx, .iVAZDcTm1XGjxwKlQisz, ._I_1HMbDnNlNAaViEnbp, .xXj7eFQ8SoDKYXy6L3E1, .F68SsPm8lZFktQ1lWsQz, .MnW5SczTcbdFHxLZ_Z8j, .WiPggcPDzbwGxoxwLWFf, .ReyA3uE3K7oEz7PTTnAn, .x8e0kqJPS0bM4dVK7ESH, .gZ2Nla3mdRREDCwybK6X, .SChMe0Tert7lmc5jqH01, .AwF4EfqLOIJ2xO7CjHoX, .UlkNeRDFoia4UDWtrOr4, .k_RKSQxa2u5_6KmcOoSw, ._mWmycP_WIvMNQdKoAFb, .O3UuqEx6ibrxyOJIdpdg, .akCwgJVf4B4ep6KYwrk5, .bIA4qeTh_LSwQJuVxDzl, .ajr9pah2nj_5cXrAofU_, .gvn0k6QI7Yl_A0u46hKn, .obTnuSx7ZKIIY1_fwJhe, .IiLMLyxs074DwmEH4x5b, .RJjM91y1EBycwhT_wH59, .mxn5B5ceO2ksvMlI1bYz, .l8wtkGVi89_AsA3nXDSR, .Th1XPPdXMnxNCDrYsnwb, .SJMBltbXfqUiByDAkUN_, .Nayn_JfAUsSO0EFapLuY, .YqlFpeC9yMVhGmd84Gdo, .HksuyUyj1n3aTnB4nHLd, .DT8FJnRKoRVWo77CPQbQ, ._Cq69xKZBtHaaeMZXIdk, .main-leaderboardComponent-container, .sponsor-container, a.link-subtle.main-navBar-navBarLink.GKnnhbExo0U9l7Jz2rdc, button[title="${upgradeText}"], button[aria-label="${upgradeText}"], .main-topBar-UpgradeButton, .main-contextMenu-menuItem a[href^="https://www.spotify.com/premium/"], div[data-testid*="hpto"] {display: none !important;}`; 101 | document.head.appendChild(css); 102 | }; 103 | const disableAds = async () => { 104 | try { 105 | await productState.putOverridesValues({ pairs: { ads: "0", catalogue: "premium", product: "premium", type: "premium" } }); 106 | } 107 | catch (error) { 108 | console.error("adblockify: Failed inside `disableAds` function\n", error); 109 | } 110 | }; 111 | const configureAdManagers = async () => { 112 | try { 113 | const { billboard, leaderboard, sponsoredPlaylist } = AdManagers; 114 | const testingClient = getTestingClient(webpackCache.functionModules, productState.transport); 115 | if (testingClient) 116 | testingClient.addPlaytime({ seconds: -100000000000 }); 117 | else 118 | await CosmosAsync.post("sp://ads/v1/testing/playtime", { value: -100000000000 }); 119 | await audio.disable(); 120 | audio.isNewAdsNpvEnabled = false; 121 | await billboard.disable(); 122 | await leaderboard.disableLeaderboard(); 123 | await sponsoredPlaylist.disable(); 124 | if (AdManagers?.inStreamApi) { 125 | const { inStreamApi } = AdManagers; 126 | await inStreamApi.disable(); 127 | } 128 | if (AdManagers?.vto) { 129 | const { vto } = AdManagers; 130 | await vto.manager.disable(); 131 | vto.isNewAdsNpvEnabled = false; 132 | } 133 | setTimeout(disableAds, 100); 134 | } 135 | catch (error) { 136 | console.error("adblockify: Failed inside `configureAdManagers` function\n", error); 137 | } 138 | }; 139 | const bindToSlots = async () => { 140 | for (const slot of slots) { 141 | subToSlot(slot.slotId || slot.slot_id); 142 | setTimeout(() => handleAdSlot({ adSlotEvent: { slotId: slot.slotId || slot.slot_id } }), 50); 143 | } 144 | }; 145 | const handleAdSlot = (data) => { 146 | const slotId = data?.adSlotEvent?.slotId; 147 | try { 148 | const adsCoreConnector = audio?.inStreamApi?.adsCoreConnector; 149 | if (typeof adsCoreConnector?.clearSlot === "function") 150 | adsCoreConnector.clearSlot(slotId); 151 | const slotsClient = getSlotsClient(webpackCache.functionModules, productState.transport); 152 | if (slotsClient) 153 | slotsClient.clearAllAds({ slotId }); 154 | updateSlotSettings(slotId); 155 | } 156 | catch (error) { 157 | console.error("adblockify: Failed inside `handleAdSlot` function. Retrying in 1 second...\n", error); 158 | retryCounter(slotId, "increment"); 159 | if (retryCounter(slotId, "get") > 5) { 160 | console.error(`adblockify: Failed inside \`handleAdSlot\` function for 5th time. Giving up...\nSlot id: ${slotId}.`); 161 | retryCounter(slotId, "clear"); 162 | return; 163 | } 164 | setTimeout(handleAdSlot, 1 * 1000, data); 165 | } 166 | configureAdManagers(); 167 | }; 168 | const updateSlotSettings = async (slotId) => { 169 | try { 170 | const settingsClient = getSettingsClient(webpackCache.cache, webpackCache.functionModules, productState.transport); 171 | if (!settingsClient) 172 | return; 173 | await settingsClient.updateAdServerEndpoint({ slotIds: [slotId], url: "http://localhost/no/thanks" }); 174 | await settingsClient.updateStreamTimeInterval({ slotId, timeInterval: "0" }); 175 | await settingsClient.updateSlotEnabled({ slotId, enabled: false }); 176 | await settingsClient.updateDisplayTimeInterval({ slotId, timeInterval: "0" }); 177 | } 178 | catch (error) { 179 | console.error("adblockify: Failed inside `updateSlotSettings` function\n", error); 180 | } 181 | }; 182 | const intervalUpdateSlotSettings = async () => { 183 | for (const slot of slots) { 184 | updateSlotSettings(slot.slotId || slot.slot_id); 185 | } 186 | }; 187 | const subToSlot = (slot) => { 188 | try { 189 | audio.inStreamApi.adsCoreConnector.subscribeToSlot(slot, handleAdSlot); 190 | } 191 | catch (error) { 192 | console.error("adblockify: Failed inside `subToSlot` function\n", error); 193 | } 194 | }; 195 | const runObserver = () => { 196 | const nodeList = Array.from(document.querySelectorAll(".ReactModalPortal")); 197 | const observer = new MutationObserver(mutations => { 198 | for (const mutation of mutations) { 199 | if (mutation.addedNodes.length) { 200 | const node = mutation.addedNodes[0]; 201 | const InAppModal = node.classList.contains("GenericModal__overlay"); 202 | if (!InAppModal) 203 | continue; 204 | const iframe = node.querySelector("iframe"); 205 | if (!iframe) 206 | continue; 207 | const iframeBody = iframe?.contentWindow?.document.body; 208 | if (!iframeBody) 209 | continue; 210 | const promotional = iframeBody.querySelector("[data-click-to-action-url*='/premium-promotional-offer-terms']"); 211 | if (!promotional) 212 | continue; 213 | node.remove(); 214 | } 215 | } 216 | }); 217 | for (const node of nodeList) { 218 | observer.observe(node, { childList: true }); 219 | } 220 | }; 221 | const enableExperimentalFeatures = async () => { 222 | try { 223 | const expFeatures = JSON.parse(localStorage.getItem("spicetify-exp-features") || "{}"); 224 | if (typeof expFeatures?.enableEsperantoMigration?.value !== "undefined") 225 | expFeatures.enableEsperantoMigration.value = true; 226 | if (typeof expFeatures?.enableInAppMessaging?.value !== "undefined") 227 | expFeatures.enableInAppMessaging.value = false; 228 | if (typeof expFeatures?.hideUpgradeCTA?.value !== "undefined") 229 | expFeatures.hideUpgradeCTA.value = true; 230 | if (typeof expFeatures?.enablePremiumUserForMiniPlayer?.value !== "undefined") 231 | expFeatures.enablePremiumUserForMiniPlayer.value = true; 232 | localStorage.setItem("spicetify-exp-features", JSON.stringify(expFeatures)); 233 | const overrides = { 234 | enableEsperantoMigration: true, 235 | enableInAppMessaging: false, 236 | hideUpgradeCTA: true, 237 | enablePremiumUserForMiniPlayer: true, 238 | }; 239 | // @ts-expect-error: RemoteConfigResolver is not defined in types 240 | if (Spicetify?.RemoteConfigResolver) { 241 | // @ts-expect-error: createInternalMap is not defined in types 242 | const map = Spicetify.createInternalMap(overrides); 243 | // @ts-expect-error: RemoteConfigResolver is not defined in types 244 | Spicetify.RemoteConfigResolver.value.setOverrides(map); 245 | } 246 | else if (Spicetify.Platform?.RemoteConfigDebugAPI) { 247 | const RemoteConfigDebugAPI = Spicetify.Platform.RemoteConfigDebugAPI; 248 | for (const [key, value] of Object.entries(overrides)) { 249 | await RemoteConfigDebugAPI.setOverride({ source: "web", type: "boolean", name: key }, value); 250 | } 251 | } 252 | } 253 | catch (error) { 254 | console.error("adblockify: Failed inside `enableExperimentalFeatures` function\n", error); 255 | } 256 | }; 257 | // const checkSpotifyVersion = (): boolean => { 258 | // const version = Spicetify.Platform.version.split(".").map((i: string) => Number.parseInt(i)); 259 | // if (version[0] === 1 && version[1] >= 2 && version[2] >= 56) { 260 | // console.error("adblockify: Unsupported version of spotify. Not launching further"); 261 | // // @ts-expect-error: Snackbar is not defined in types 262 | // Spicetify.Snackbar.enqueueSnackbar( 263 | // "adblockify: Spotify version `1.2.56` and higher are NOT supported at this moment. Spicetify does not support these at this moment either. Please downgrade to `1.2.55` to use adblockify and block Spotify updates", 264 | // { 265 | // variant: "error", 266 | // autoHideDuration: 10000, 267 | // } 268 | // ); 269 | // return true; 270 | // } 271 | // return false; 272 | // }; 273 | // if (checkSpotifyVersion()) return; 274 | bindToSlots(); 275 | hideAdLikeElements(); 276 | // to enable one day if disabling `enableInAppMessages` exp feature doesn't work 277 | //runObserver(); 278 | productState.subValues({ keys: ["ads", "catalogue", "product", "type"] }, () => configureAdManagers()); 279 | enableExperimentalFeatures(); 280 | setTimeout(enableExperimentalFeatures, 3 * 1000); 281 | // Update slot settings after 5 seconds... idk why, don't ask me why, it just works 282 | setTimeout(intervalUpdateSlotSettings, 5 * 1000); 283 | })(); 284 | -------------------------------------------------------------------------------- /adblock/adblock.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author ririxi 3 | */ 4 | 5 | interface ProductStateAPI { 6 | putOverridesValues(params: { pairs: { [key: string]: string } }): Promise; 7 | subValues(params: { keys: string[] }, callback: () => void): Promise; 8 | transport: any; 9 | } 10 | 11 | interface AdManagers { 12 | audio: { 13 | disable(): Promise; 14 | inStreamApi: { 15 | adsCoreConnector: { 16 | clearSlot(slotId: string): void; 17 | subscribeToSlot(slotId: string, callback: (data: { adSlotEvent: { slotId: string } }) => void): void; 18 | }; 19 | }; 20 | isNewAdsNpvEnabled: boolean; 21 | }; 22 | billboard: { 23 | disable(): Promise; 24 | }; 25 | leaderboard: { 26 | disableLeaderboard(): Promise; 27 | }; 28 | inStreamApi: { 29 | disable(): Promise; 30 | }; 31 | sponsoredPlaylist: { 32 | disable(): Promise; 33 | }; 34 | vto?: { 35 | manager: { 36 | disable(): Promise; 37 | }; 38 | isNewAdsNpvEnabled: boolean; 39 | }; 40 | } 41 | 42 | interface Window { 43 | webpackChunkclient_web: any; 44 | } 45 | 46 | interface SettingsClient { 47 | updateAdServerEndpoint(params: { slotIds: string[]; url: string }): Promise; 48 | updateDisplayTimeInterval(params: { slotId: string; timeInterval: string }): Promise; 49 | updateSlotEnabled(params: { slotId: string; enabled: boolean }): Promise; 50 | updateStreamTimeInterval(params: { slotId: string; timeInterval: string }): Promise; 51 | } 52 | 53 | interface SlotsClient { 54 | clearAllAds(params: { slotId: string }): Promise; 55 | getSlots(): Promise<{ adSlots: { slotId: string; slot_id: string }[] }>; 56 | } 57 | 58 | const loadWebpack = () => { 59 | try { 60 | const require = window.webpackChunkclient_web.push([[Symbol()], {}, (re: any) => re]); 61 | const cache = Object.keys(require.m).map(id => require(id)); 62 | const modules = cache 63 | .filter(module => typeof module === "object") 64 | .flatMap(module => { 65 | try { 66 | return Object.values(module); 67 | } catch {} 68 | }); 69 | const functionModules = modules.filter(module => typeof module === "function"); 70 | 71 | return { cache, functionModules }; 72 | } catch (error) { 73 | console.error("adblockify: Failed to load webpack", error); 74 | return { cache: [], functionModules: [] }; 75 | } 76 | }; 77 | 78 | const getSettingsClient = (cache: any[], functionModules: any[] = [], transport: any = {}): SettingsClient | null => { 79 | try { 80 | const settingsClient = cache.find((m: any) => m?.settingsClient)?.settingsClient; 81 | if (!settingsClient) { 82 | const settings = functionModules.find( 83 | m => m?.SERVICE_ID === "spotify.ads.esperanto.settings.proto.Settings" || m?.SERVICE_ID === "spotify.ads.esperanto.proto.Settings" 84 | ); 85 | return new settings(transport); 86 | } 87 | return settingsClient; 88 | } catch (error) { 89 | console.error("adblockify: Failed to get ads settings client", error); 90 | return null; 91 | } 92 | }; 93 | 94 | const getSlotsClient = (functionModules: any[], transport: any): SlotsClient | null => { 95 | try { 96 | const slots = functionModules.find( 97 | m => m.SERVICE_ID === "spotify.ads.esperanto.slots.proto.Slots" || m.SERVICE_ID === "spotify.ads.esperanto.proto.Slots" 98 | ); 99 | return new slots(transport); 100 | } catch (error) { 101 | console.error("adblockify: Failed to get slots client", error); 102 | return null; 103 | } 104 | }; 105 | 106 | const getTestingClient = (functionModules: any[], transport: any) => { 107 | try { 108 | const testing = functionModules.find( 109 | m => m.SERVICE_ID === "spotify.ads.esperanto.testing.proto.Testing" || m.SERVICE_ID === "spotify.ads.esperanto.proto.Testing" 110 | ); 111 | return new testing(transport); 112 | } catch (error) { 113 | console.error("adblockify: Failed to get testing client", error); 114 | return null; 115 | } 116 | }; 117 | 118 | const map = new Map(); 119 | 120 | const retryCounter = (slotId: string, action: "increment" | "clear" | "get") => { 121 | if (!map.has(slotId)) map.set(slotId, { count: 0 }); 122 | if (action === "increment") map.get(slotId).count++; 123 | else if (action === "clear") map.delete(slotId); 124 | else if (action === "get") return map.get(slotId)?.count; 125 | }; 126 | 127 | (async function adblockify() { 128 | // @ts-expect-error: Events are not defined in types 129 | await new Promise(res => Spicetify.Events.platformLoaded.on(res)); 130 | // @ts-expect-error: Events are not defined in types 131 | await new Promise(res => Spicetify.Events.webpackLoaded.on(res)); 132 | const webpackCache = loadWebpack(); 133 | 134 | const { Platform, Locale } = Spicetify; 135 | const { AdManagers } = Platform; 136 | if (!AdManagers?.audio || Object.keys(AdManagers).length === 0) { 137 | setTimeout(adblockify, 100); 138 | return; 139 | } 140 | const { audio }: AdManagers = AdManagers; 141 | const { UserAPI } = Platform; 142 | const productState: ProductStateAPI = UserAPI._product_state || UserAPI._product_state_service || Platform?.ProductStateAPI?.productStateApi; 143 | if (!Spicetify?.CosmosAsync) { 144 | setTimeout(adblockify, 100); 145 | return; 146 | } 147 | const { CosmosAsync } = Spicetify; 148 | 149 | let slots = []; 150 | const slotsClient = getSlotsClient(webpackCache.functionModules, productState.transport); 151 | if (slotsClient) slots = (await slotsClient.getSlots()).adSlots; 152 | else slots = await CosmosAsync.get("sp://ads/v1/slots"); 153 | 154 | const hideAdLikeElements = () => { 155 | const css = document.createElement("style"); 156 | const upgradeText = Locale.get("upgrade.tooltip.title"); 157 | 158 | css.className = "adblockify"; 159 | css.innerHTML = `.nHCJskDZVlmDhNNS9Ixv, .utUDWsORU96S7boXm2Aq, .cpBP3znf6dhHLA2dywjy, .G7JYBeU1c2QawLyFs5VK, .vYl1kgf1_R18FCmHgdw2, .vZkc6VwrFz0EjVBuHGmx, .iVAZDcTm1XGjxwKlQisz, ._I_1HMbDnNlNAaViEnbp, .xXj7eFQ8SoDKYXy6L3E1, .F68SsPm8lZFktQ1lWsQz, .MnW5SczTcbdFHxLZ_Z8j, .WiPggcPDzbwGxoxwLWFf, .ReyA3uE3K7oEz7PTTnAn, .x8e0kqJPS0bM4dVK7ESH, .gZ2Nla3mdRREDCwybK6X, .SChMe0Tert7lmc5jqH01, .AwF4EfqLOIJ2xO7CjHoX, .UlkNeRDFoia4UDWtrOr4, .k_RKSQxa2u5_6KmcOoSw, ._mWmycP_WIvMNQdKoAFb, .O3UuqEx6ibrxyOJIdpdg, .akCwgJVf4B4ep6KYwrk5, .bIA4qeTh_LSwQJuVxDzl, .ajr9pah2nj_5cXrAofU_, .gvn0k6QI7Yl_A0u46hKn, .obTnuSx7ZKIIY1_fwJhe, .IiLMLyxs074DwmEH4x5b, .RJjM91y1EBycwhT_wH59, .mxn5B5ceO2ksvMlI1bYz, .l8wtkGVi89_AsA3nXDSR, .Th1XPPdXMnxNCDrYsnwb, .SJMBltbXfqUiByDAkUN_, .Nayn_JfAUsSO0EFapLuY, .YqlFpeC9yMVhGmd84Gdo, .HksuyUyj1n3aTnB4nHLd, .DT8FJnRKoRVWo77CPQbQ, ._Cq69xKZBtHaaeMZXIdk, .main-leaderboardComponent-container, .sponsor-container, a.link-subtle.main-navBar-navBarLink.GKnnhbExo0U9l7Jz2rdc, button[title="${upgradeText}"], button[aria-label="${upgradeText}"], .main-topBar-UpgradeButton, .main-contextMenu-menuItem a[href^="https://www.spotify.com/premium/"], div[data-testid*="hpto"] {display: none !important;}`; 160 | document.head.appendChild(css); 161 | }; 162 | 163 | const disableAds = async () => { 164 | try { 165 | await productState.putOverridesValues({ pairs: { ads: "0", catalogue: "premium", product: "premium", type: "premium" } }); 166 | } catch (error: unknown) { 167 | console.error("adblockify: Failed inside `disableAds` function\n", error); 168 | } 169 | }; 170 | 171 | const configureAdManagers = async () => { 172 | try { 173 | const { billboard, leaderboard, sponsoredPlaylist }: AdManagers = AdManagers; 174 | const testingClient = getTestingClient(webpackCache.functionModules, productState.transport); 175 | 176 | if (testingClient) testingClient.addPlaytime({ seconds: -100000000000 }); 177 | else await CosmosAsync.post("sp://ads/v1/testing/playtime", { value: -100000000000 }); 178 | 179 | await audio.disable(); 180 | audio.isNewAdsNpvEnabled = false; 181 | await billboard.disable(); 182 | await leaderboard.disableLeaderboard(); 183 | await sponsoredPlaylist.disable(); 184 | if (AdManagers?.inStreamApi) { 185 | const { inStreamApi }: AdManagers = AdManagers; 186 | await inStreamApi.disable(); 187 | } 188 | if (AdManagers?.vto) { 189 | const { vto }: AdManagers = AdManagers; 190 | await vto.manager.disable(); 191 | vto.isNewAdsNpvEnabled = false; 192 | } 193 | setTimeout(disableAds, 100); 194 | } catch (error: unknown) { 195 | console.error("adblockify: Failed inside `configureAdManagers` function\n", error); 196 | } 197 | }; 198 | 199 | const bindToSlots = async () => { 200 | for (const slot of slots) { 201 | subToSlot(slot.slotId || slot.slot_id); 202 | setTimeout(() => handleAdSlot({ adSlotEvent: { slotId: slot.slotId || slot.slot_id } }), 50); 203 | } 204 | }; 205 | 206 | const handleAdSlot = (data: { adSlotEvent: { slotId: string } }) => { 207 | const slotId = data?.adSlotEvent?.slotId; 208 | 209 | try { 210 | const adsCoreConnector = audio?.inStreamApi?.adsCoreConnector; 211 | if (typeof adsCoreConnector?.clearSlot === "function") adsCoreConnector.clearSlot(slotId); 212 | const slotsClient = getSlotsClient(webpackCache.functionModules, productState.transport); 213 | if (slotsClient) slotsClient.clearAllAds({ slotId }); 214 | updateSlotSettings(slotId); 215 | } catch (error: unknown) { 216 | console.error("adblockify: Failed inside `handleAdSlot` function. Retrying in 1 second...\n", error); 217 | retryCounter(slotId, "increment"); 218 | if (retryCounter(slotId, "get") > 5) { 219 | console.error(`adblockify: Failed inside \`handleAdSlot\` function for 5th time. Giving up...\nSlot id: ${slotId}.`); 220 | retryCounter(slotId, "clear"); 221 | return; 222 | } 223 | setTimeout(handleAdSlot, 1 * 1000, data); 224 | } 225 | configureAdManagers(); 226 | }; 227 | 228 | const updateSlotSettings = async (slotId: string) => { 229 | try { 230 | const settingsClient = getSettingsClient(webpackCache.cache, webpackCache.functionModules, productState.transport); 231 | if (!settingsClient) return; 232 | await settingsClient.updateAdServerEndpoint({ slotIds: [slotId], url: "http://localhost/no/thanks" }); 233 | await settingsClient.updateStreamTimeInterval({ slotId, timeInterval: "0" }); 234 | await settingsClient.updateSlotEnabled({ slotId, enabled: false }); 235 | await settingsClient.updateDisplayTimeInterval({ slotId, timeInterval: "0" }); 236 | } catch (error: unknown) { 237 | console.error("adblockify: Failed inside `updateSlotSettings` function\n", error); 238 | } 239 | }; 240 | 241 | const intervalUpdateSlotSettings = async () => { 242 | for (const slot of slots) { 243 | updateSlotSettings(slot.slotId || slot.slot_id); 244 | } 245 | }; 246 | 247 | const subToSlot = (slot: string) => { 248 | try { 249 | audio.inStreamApi.adsCoreConnector.subscribeToSlot(slot, handleAdSlot); 250 | } catch (error: unknown) { 251 | console.error("adblockify: Failed inside `subToSlot` function\n", error); 252 | } 253 | }; 254 | 255 | const runObserver = () => { 256 | const nodeList = Array.from(document.querySelectorAll(".ReactModalPortal")); 257 | 258 | const observer = new MutationObserver(mutations => { 259 | for (const mutation of mutations) { 260 | if (mutation.addedNodes.length) { 261 | const node = mutation.addedNodes[0] as HTMLElement; 262 | const InAppModal = node.classList.contains("GenericModal__overlay"); 263 | if (!InAppModal) continue; 264 | const iframe = node.querySelector("iframe") as HTMLIFrameElement; 265 | if (!iframe) continue; 266 | const iframeBody = iframe?.contentWindow?.document.body; 267 | if (!iframeBody) continue; 268 | const promotional = iframeBody.querySelector("[data-click-to-action-url*='/premium-promotional-offer-terms']"); 269 | if (!promotional) continue; 270 | node.remove(); 271 | } 272 | } 273 | }); 274 | 275 | for (const node of nodeList) { 276 | observer.observe(node, { childList: true }); 277 | } 278 | }; 279 | 280 | const enableExperimentalFeatures = async () => { 281 | try { 282 | const expFeatures = JSON.parse(localStorage.getItem("spicetify-exp-features") || "{}"); 283 | if (typeof expFeatures?.enableEsperantoMigration?.value !== "undefined") expFeatures.enableEsperantoMigration.value = true; 284 | if (typeof expFeatures?.enableInAppMessaging?.value !== "undefined") expFeatures.enableInAppMessaging.value = false; 285 | if (typeof expFeatures?.hideUpgradeCTA?.value !== "undefined") expFeatures.hideUpgradeCTA.value = true; 286 | if (typeof expFeatures?.enablePremiumUserForMiniPlayer?.value !== "undefined") expFeatures.enablePremiumUserForMiniPlayer.value = true; 287 | localStorage.setItem("spicetify-exp-features", JSON.stringify(expFeatures)); 288 | const overrides = { 289 | enableEsperantoMigration: true, 290 | enableInAppMessaging: false, 291 | hideUpgradeCTA: true, 292 | enablePremiumUserForMiniPlayer: true, 293 | }; 294 | 295 | // @ts-expect-error: RemoteConfigResolver is not defined in types 296 | if (Spicetify?.RemoteConfigResolver) { 297 | // @ts-expect-error: createInternalMap is not defined in types 298 | const map = Spicetify.createInternalMap(overrides); 299 | // @ts-expect-error: RemoteConfigResolver is not defined in types 300 | Spicetify.RemoteConfigResolver.value.setOverrides(map); 301 | } else if (Spicetify.Platform?.RemoteConfigDebugAPI) { 302 | const RemoteConfigDebugAPI = Spicetify.Platform.RemoteConfigDebugAPI; 303 | 304 | for (const [key, value] of Object.entries(overrides)) { 305 | await RemoteConfigDebugAPI.setOverride({ source: "web", type: "boolean", name: key }, value); 306 | } 307 | } 308 | } catch (error: unknown) { 309 | console.error("adblockify: Failed inside `enableExperimentalFeatures` function\n", error); 310 | } 311 | }; 312 | 313 | // const checkSpotifyVersion = (): boolean => { 314 | // const version = Spicetify.Platform.version.split(".").map((i: string) => Number.parseInt(i)); 315 | // if (version[0] === 1 && version[1] >= 2 && version[2] >= 56) { 316 | // console.error("adblockify: Unsupported version of spotify. Not launching further"); 317 | // // @ts-expect-error: Snackbar is not defined in types 318 | // Spicetify.Snackbar.enqueueSnackbar( 319 | // "adblockify: Spotify version `1.2.56` and higher are NOT supported at this moment. Spicetify does not support these at this moment either. Please downgrade to `1.2.55` to use adblockify and block Spotify updates", 320 | // { 321 | // variant: "error", 322 | // autoHideDuration: 10000, 323 | // } 324 | // ); 325 | 326 | // return true; 327 | // } 328 | 329 | // return false; 330 | // }; 331 | 332 | // if (checkSpotifyVersion()) return; 333 | bindToSlots(); 334 | hideAdLikeElements(); 335 | // to enable one day if disabling `enableInAppMessages` exp feature doesn't work 336 | //runObserver(); 337 | productState.subValues({ keys: ["ads", "catalogue", "product", "type"] }, () => configureAdManagers()); 338 | enableExperimentalFeatures(); 339 | setTimeout(enableExperimentalFeatures, 3 * 1000); 340 | 341 | // Update slot settings after 5 seconds... idk why, don't ask me why, it just works 342 | setTimeout(intervalUpdateSlotSettings, 5 * 1000); 343 | })(); 344 | -------------------------------------------------------------------------------- /adblock/assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxri/spicetify-extensions/5c8bedf61b718e4d47cc9adede4d39be59726a1e/adblock/assets/preview.png -------------------------------------------------------------------------------- /adblock/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["adblock.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "style": { 11 | "useSingleVarDeclarator": "off" 12 | }, 13 | "suspicious": { 14 | "noExplicitAny": "off" 15 | } 16 | } 17 | }, 18 | "formatter": { 19 | "enabled": true, 20 | "formatWithErrors": true, 21 | "indentStyle": "tab", 22 | "indentWidth": 2, 23 | "lineWidth": 150 24 | }, 25 | "javascript": { 26 | "formatter": { 27 | "trailingCommas": "es5", 28 | "arrowParentheses": "asNeeded" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /featureshuffle/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxri/spicetify-extensions/5c8bedf61b718e4d47cc9adede4d39be59726a1e/featureshuffle/.DS_Store -------------------------------------------------------------------------------- /featureshuffle/README.md: -------------------------------------------------------------------------------- 1 | # FeatureShuffle 2 | 3 | Create a randomized playlist based on the audio features of a playlist with `FeatureShuffle` for **[spicetify](https://github.com/spicetify/cli)** 4 | 5 | * Right click a playlist and click "Create Feature Based Playlist" (This process may take up to 3 minutes.) 6 | 7 | ## Install 8 | 9 | 1. Copy `featureshuffle.js` to extensions folder based on your OS, or install it via **[marketplace](https://github.com/spicetify/spicetify-marketplace)** 10 | 11 | | **Platform** | **Path** | 12 | |----------------|--------------------------------------------------------------------------------------| 13 | | **Windows** | `%appdata%\spicetify\Extensions\` | 14 | | **Linux** | `~/.config/spicetify/Extensions` or `$XDG_CONFIG_HOME/spicetify/Extensions/` | 15 | | **MacOS** | `~/.config/spicetify/Extensions` or `~/.spicetify/Extensions` | 16 | 17 | After putting the extension file into the correct folder, run the following command to install the extension or install through marketplace: 18 | 19 | ```sh 20 | spicetify config extensions FeatureShuffle.js 21 | spicetify apply 22 | ``` 23 | 24 | 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. 25 | 26 | Or you can manually edit your `config-xpui.ini` file. Add your desired extension filenames in the extensions key, separated them by the | character. 27 | Example: 28 | 29 | ```ini 30 | [AdditionalOptions] 31 | ... 32 | extensions = autoSkipExplicit.js|shuffle+.js|trashbin.js|FeatureShuffle.js 33 | ``` 34 | 35 | Then run: 36 | 37 | ```sh 38 | spicetify apply 39 | ``` 40 | 41 | ## Usage 42 | 43 | Toggle in the Profile menu. 44 | 45 | [](https://raw.githubusercontent.com/rxri/spicetify-extensions/main/FeatureShuffle/FeatureShuffle.png) 46 | 47 | ----- 48 | If you find any bugs, please [create a new issue](https://github.com/rxri/spicetify-extensions/issues/new/choose) on the GitHub repo. 49 |  50 | -------------------------------------------------------------------------------- /featureshuffle/featureshuffle.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @author CharlieS1103 5 | */ 6 | 7 | (function songstats() { 8 | const { CosmosAsync, URI } = Spicetify; 9 | if (!(CosmosAsync && URI)) { 10 | setTimeout(songstats, 300); 11 | return; 12 | } 13 | 14 | const buttontxt = "Create Feature Based Playlist"; 15 | const average = array => array.reduce((a, b) => a + b) / array.length; 16 | 17 | async function makePlaylist(uris) { 18 | const uri = uris[0]; 19 | const uriFinal = uri.split(":")[2]; 20 | 21 | const user = await CosmosAsync.get("https://api.spotify.com/v1/me"); 22 | 23 | const playlistitems = (await CosmosAsync.get(`https://api.spotify.com/v1/playlists/${uriFinal}/tracks`)).items.map(i => i.track.href); 24 | 25 | const avrDanceability = [], 26 | avrTempo = [], 27 | avrEnergy = [], 28 | avrInstrumentalness = [], 29 | avrSpeechiness = [], 30 | avrLiveness = []; 31 | 32 | for (i = 0; i < playlistitems.length; i++) { 33 | const songuri = playlistitems[i].split("/")[5]; 34 | let res; 35 | try { 36 | res = await CosmosAsync.get(`https://api.spotify.com/v1/audio-features/${songuri}`); 37 | } catch {} 38 | 39 | avrDanceability.push(Math.round(100 * res.danceability) / 100); 40 | avrEnergy.push(Math.round(100 * res.energy) / 100); 41 | avrInstrumentalness.push(Math.round(100 * res.instrumentalness) / 100); 42 | avrSpeechiness.push(Math.round(100 * res.speechiness) / 100); 43 | avrTempo.push(Math.round(100 * res.tempo) / 100); 44 | avrLiveness.push(Math.round(100 * res.liveness) / 100); 45 | } 46 | 47 | const avr2Dance = average(avrDanceability); 48 | const avr2Tempo = average(avrTempo); 49 | const avr2Energy = average(avrEnergy); 50 | const avr2Intrumentalness = average(avrInstrumentalness); 51 | const avr2Liveness = average(avrLiveness); 52 | 53 | function randAlph(rndInt) { 54 | const alphabet = "abcdefghijklmnopqrstuvwxyz"; 55 | const letters = []; 56 | 57 | for (let i = 0; i < rndInt; i++) { 58 | const randomletter = alphabet[Math.floor(Math.random() * alphabet.length)]; 59 | letters.push(randomletter); 60 | } 61 | const string = letters.join(""); 62 | return string; 63 | } 64 | 65 | const randomSongrequest = []; 66 | 67 | for (let i = 0; i < 21; i++) { 68 | const getRandomSongsArray = ["%25-%25", "-%25", "%25-%25", "-%25", "%25-%25", "-%25", "%25-%25", "-%25"]; 69 | const rndInt = Math.floor(Math.random() * 3) + 1; 70 | 71 | const ranSong = getRandomSongsArray[Math.floor(Math.random() * getRandomSongsArray.length)]; 72 | const ranString = randAlph(rndInt); 73 | const getRandomSongs = ranSong.replace("-", ranString); 74 | const getRandomOffset = Math.floor(Math.random() * (500 - 1 + 1) + 1); 75 | const url = `https://api.spotify.com/v1/search?q=${getRandomSongs}&offset=${getRandomOffset}&type=track&limit=1&market=US`; 76 | const randomSongrequestToAppend = (await CosmosAsync.get(url)).tracks.items.map(track => track.uri); 77 | console.log(randomSongrequestToAppend); 78 | 79 | let res2; 80 | function validateSong(res2) { 81 | if (Math.round(100 * res2.liveness) / 100 < avr2Liveness - 2 || Math.round(100 * res2.liveness) / 100 > avr2Liveness + 2) return false; 82 | if (res2.tempo < avr2Tempo - 5 || res2.tempo > avr2Tempo + 5) return false; 83 | if ( 84 | Math.round(100 * res2.instrumentalness) / 100 < avr2Intrumentalness - 2 || 85 | Math.round(100 * res2.instrumentalness) / 100 > avr2Intrumentalness + 2 86 | ) 87 | return false; 88 | if (Math.round(100 * res2.energy) / 100 < avr2Energy - 2 || Math.round(100 * res2.energy) / 100 > avr2Energy + 2) return false; 89 | if (Math.round(100 * res2.danceability) / 100 < avr2Dance - 2 || Math.round(100 * res2.danceability) / 100 > avr2Dance + 2) return false; 90 | return true; 91 | } 92 | 93 | if (randomSongrequestToAppend[0] !== undefined) { 94 | try { 95 | res2 = await CosmosAsync.get(`https://api.spotify.com/v1/audio-features/${randomSongrequestToAppend[0].split(":")[2]}`); 96 | if (validateSong(res2)) { 97 | randomSongrequest.push(randomSongrequestToAppend[0]); 98 | console.log("Song passed"); 99 | } else i--; 100 | } catch (error) { 101 | console.warn(error); 102 | } 103 | } 104 | } 105 | const newplaylist = await CosmosAsync.post(`https://api.spotify.com/v1/users/${user.id}/playlists`, { 106 | name: "New Playlist", 107 | }); 108 | 109 | const playlisturi = newplaylist.uri.split(":")[2]; 110 | 111 | CosmosAsync.post(`https://api.spotify.com/v1/playlists/${playlisturi}/tracks`, { 112 | uris: randomSongrequest, 113 | }); 114 | } 115 | 116 | function shouldDisplayContextMenu(uris) { 117 | if (uris.length > 1) return false; 118 | const uri = uris[0]; 119 | const uriObj = Spicetify.URI.fromString(uri); 120 | 121 | if (uriObj.type === Spicetify.URI.Type.PLAYLIST_V2) return true; 122 | return false; 123 | } 124 | 125 | const cntxMenu = new Spicetify.ContextMenu.Item(buttontxt, makePlaylist, shouldDisplayContextMenu); 126 | cntxMenu.register(); 127 | })(); 128 | -------------------------------------------------------------------------------- /featureshuffle/featureshuffle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxri/spicetify-extensions/5c8bedf61b718e4d47cc9adede4d39be59726a1e/featureshuffle/featureshuffle.png -------------------------------------------------------------------------------- /formatColors/README.md: -------------------------------------------------------------------------------- 1 | # FormatColors 2 | 3 | Convert colors defined in root to `color.ini` format with `FormatColors` for **[spicetify](https://github.com/spicetify/cli)** 4 | 5 | * For use by theme developers, click the button and it will open a popup model. Click on any of the text to select the full text, then right click and hit copy. 6 | 7 | ## Install 8 | 9 | 1. Copy `formatColors.js` to extensions folder based on your OS, or install it via **[marketplace](https://github.com/spicetify/spicetify-marketplace)** 10 | 11 | | **Platform** | **Path** | 12 | |----------------|--------------------------------------------------------------------------------------| 13 | | **Windows** | `%appdata%\spicetify\Extensions\` | 14 | | **Linux** | `~/.config/spicetify/Extensions` or `$XDG_CONFIG_HOME/spicetify/Extensions/` | 15 | | **MacOS** | `~/.config/spicetify/Extensions` or `~/.spicetify/Extensions` | 16 | 17 | After putting the extension file into the correct folder, run the following command to install the extension or install through marketplace: 18 | 19 | ```sh 20 | spicetify config extensions formatColors.js 21 | spicetify apply 22 | ``` 23 | 24 | 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. 25 | 26 | Or you can manually edit your `config-xpui.ini` file. Add your desired extension filenames in the extensions key, separated them by the | character. 27 | Example: 28 | 29 | ```ini 30 | [AdditionalOptions] 31 | ... 32 | extensions = autoSkipExplicit.js|shuffle+.js|trashbin.js|formatColors.js 33 | ``` 34 | 35 | Then run: 36 | 37 | ```sh 38 | spicetify apply 39 | ``` 40 | 41 | ## Usage 42 | 43 | Toggle in the Profile menu. 44 | 45 |  46 | 47 | ----- 48 | If you find any bugs, please [create a new issue](https://github.com/rxri/spicetify-extensions/issues/new/choose) on the GitHub repo. 49 |  50 | -------------------------------------------------------------------------------- /formatColors/formatColors.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @author CharlieS1103 5 | */ 6 | 7 | (function formatColors() { 8 | const { Platform } = Spicetify; 9 | if (!Platform) { 10 | setTimeout(formatColors, 300); 11 | return; 12 | } 13 | 14 | const buttontxt = "Format Colors"; 15 | const BUTTON_ICON = ` 16 | 17 | 18 | 20 | 21 | 22 | 23 | 27 | 29 | 31 | 32 | 33 | 34 | 35 | `; 36 | 37 | new Spicetify.Topbar.Button("Format Colors", BUTTON_ICON, convertColorSheet, false); 38 | })(); 39 | 40 | function convertColorSheet() { 41 | for (const sheet of document.styleSheets) { 42 | // TODO: Fix the wall of ignores 43 | // @ts-ignore 44 | if (sheet.href === "https://xpui.app.spotify.com/colors.css" || sheet.ownerNode.classList[1] === "marketplaceScheme") { 45 | let cssText = sheet.rules[0].cssText; 46 | cssText = cssText.replaceAll(":root {", ""); 47 | cssText = cssText.replaceAll("{", ""); 48 | cssText = cssText.replaceAll(":", " ="); 49 | cssText = cssText.replaceAll("--spice-", ""); 50 | cssText = cssText.replaceAll("#", ""); 51 | cssText = cssText.replaceAll("}", ""); 52 | cssText = cssText.replaceAll("!important", ""); 53 | Spicetify.Platform.ClipboardAPI.copy(cssText); 54 | cssText = cssText.replaceAll(";", "\\n"); 55 | const regex = /\\n|\\r\\n|\\n\\r|\\r/g; 56 | const reg = /rgb.*?\\n/gm; 57 | cssText = cssText.replace(reg, ""); 58 | 59 | const htmlElement = `${cssText.replace(regex, "")}`; 60 | Spicetify.PopupModal.display({ 61 | title: "Formatted Colors", 62 | content: htmlElement, 63 | }); 64 | 65 | Spicetify.showNotification("Copied to clipboard"); 66 | break; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /formatColors/formatColors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxri/spicetify-extensions/5c8bedf61b718e4d47cc9adede4d39be59726a1e/formatColors/formatColors.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Feature Shuffle", 4 | "description": "Spicetify extension to make a playlist based on another playlist's audio features", 5 | "preview": "featureshuffle/featureshuffle.png", 6 | "main": "featureshuffle/featureshuffle.js", 7 | "readme": "featureshuffle/README.md", 8 | "authors": [{ "name": "CharlieS1103", "url": "https://github.com/CharlieS1103" }] 9 | }, 10 | { 11 | "name": "Song Stats", 12 | "description": "Spicetify extension to display a song's audio features", 13 | "preview": "songstats/songstats.png", 14 | "main": "songstats/songstats.js", 15 | "readme": "songstats/README.md", 16 | "authors": [{ "name": "CharlieS1103", "url": "https://github.com/CharlieS1103" }] 17 | }, 18 | { 19 | "name": "WikiFy", 20 | "description": "Shows an Artists wikipedia page to learn more about them", 21 | "preview": "wikify/wikify.png", 22 | "main": "wikify/wikify.js", 23 | "readme": "wikify/README.md", 24 | "authors": [{ "name": "CharlieS1103", "url": "https://github.com/CharlieS1103" }] 25 | }, 26 | { 27 | "name": "adblockify", 28 | "description": "Stream Spotify music without interruptions - Block ads instantly, with ease.", 29 | "preview": "adblock/assets/preview.png", 30 | "main": "adblock/adblock.js", 31 | "readme": "adblock/README.md", 32 | "authors": [{ "name": "ririxi", "url": "https://github.com/rxri" }], 33 | "tags": ["ads", "block", "remove", "disable"] 34 | }, 35 | { 36 | "name": "Format colors", 37 | "description": "Convert the current colors defined in root to color.ini format, for theme developers", 38 | "preview": "formatColors/formatColors.png", 39 | "main": "formatColors/formatColors.js", 40 | "readme": "formatColors/README.md", 41 | "authors": [{ "name": "CharlieS1103", "url": "https://github.com/CharlieS1103" }] 42 | }, 43 | { 44 | "name": "Phrase to Playlist", 45 | "description": "Convert a phrase into a playlist with songs arraged to make that phrase", 46 | "preview": "phraseToPlaylist/phraseToPlaylist.png", 47 | "main": "phraseToPlaylist/phraseToPlaylist.js", 48 | "readme": "phraseToPlaylist/README.md", 49 | "authors": [ 50 | { "name": "ririxi", "url": "https://github.com/rxri" }, 51 | { "name": "CharlieS1103", "url": "https://github.com/CharlieS1103" }, 52 | { "name": "MalTeeez", "url": "https://github.com/MalTeeez" } 53 | ] 54 | }, 55 | { 56 | "name": "Writeify", 57 | "description": "Take notes on songs, albums, and artists", 58 | "preview": "writeify/writeify.png", 59 | "main": "writeify/writeify.js", 60 | "readme": "writeify/README.md", 61 | "authors": [{ "name": "CharlieS1103", "url": "https://github.com/CharlieS1103" }] 62 | } 63 | ] 64 | -------------------------------------------------------------------------------- /old-sidebar/README.md: -------------------------------------------------------------------------------- 1 | # OldSidebar 2 | 3 | Go back to the old sidebar with `OldSidebar` for **[spicetify](https://github.com/spicetify/cli)** 4 | 5 | ## Install 6 | 7 | 1. Copy `oldSidebar.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 oldSidebar.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|oldSidebar.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 |  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 |  48 | -------------------------------------------------------------------------------- /old-sidebar/oldSidebar.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @author CharlieS1103 5 | */ 6 | 7 | (async function oldSidebar() { 8 | const { Platform } = Spicetify; 9 | // Also wait till the sidebar is loaded 10 | if (!Platform || !document.querySelector(".Root__nav-bar") || document.querySelector(".main-rootlist-topSentinel")) { 11 | setTimeout(oldSidebar, 10); 12 | console.log("Waiting for sidebar to load"); 13 | return; 14 | } 15 | // Sleep using await and a promise 16 | const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); 17 | // Wait for the sidebar to load a little more 18 | await sleep(3000); 19 | let oldHTML = ` 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | Home 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | Search 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | Your Library 55 | 56 | 57 | 58 | **INJECTION** 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | Create Playlist 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | Liked Songs 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | Your Episodes 99 | 100 | 101 | 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 | Resize main navigation 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 = ` 230 | 231 | 232 | ${customAppData.icon} 233 | 234 | 235 | ${customAppData.activeIcon} 236 | 237 | ${customAppData.name} 238 | 239 | `; 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 = ` 250 | ${data.name} `; 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 |  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 |  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 | setPhrase(e.target.value)} 140 | /> 141 | 142 | {loadingIndicator} 143 | 144 | Submit Phrase 145 | 146 | 147 | > 148 | ); 149 | }; 150 | 151 | function displayPhraseInput() { 152 | const modalContent = document.createElement("div"); 153 | document.body.appendChild(modalContent); 154 | 155 | const handleSubmit = async (phrase: string) => { 156 | generatePlaylist(phrase); 157 | }; 158 | 159 | ReactDOM.render(, modalContent); 160 | 161 | Spicetify.PopupModal.display({ 162 | title: "Phrase2Playlist", 163 | content: modalContent, 164 | }); 165 | } 166 | 167 | const songMapCache: { [key: string]: string } = {}; 168 | 169 | async function generatePlaylist(phrase: string) { 170 | const cleanedPhrase = phrase.replace(/[^\w\s\-]|_/g, "").replace(/\s+/g, " "); 171 | const cleanedPhraseSplit = cleanedPhrase.replace(/^\s+|\s+$/g, "").split(" "); 172 | const songArr: string[] = []; 173 | let notFoundSongs = 0; 174 | 175 | for (let i = 0; i < cleanedPhraseSplit.length; i++) { 176 | updateLoadingIndicator(i, cleanedPhraseSplit.length); 177 | const songName = cleanedPhraseSplit[i]; 178 | 179 | if (!songName) continue; 180 | 181 | const normalizedSongName = songName.toUpperCase(); 182 | const songId = songMap[songName] || songMap[normalizedSongName] || songMapCache[songName]; 183 | 184 | if (songId) { 185 | songArr[i] = `spotify:track:${songId}`; 186 | } else { 187 | const songJson = await searchSong(songName); 188 | if (songJson === "notfound") { 189 | notFoundSongs++; 190 | continue; 191 | } 192 | 193 | songArr[i] = `spotify:track:${songJson}`; 194 | songMapCache[songName] = songJson; 195 | } 196 | } 197 | 198 | createPlaylist(songArr, notFoundSongs); 199 | } 200 | 201 | function updateLoadingIndicator(current: number, total: number) { 202 | const spanProgress = document.querySelector(".phrase-loading-indicator"); 203 | if (spanProgress) spanProgress.textContent = `${current}/${total}`; 204 | } 205 | 206 | async function searchSong(songName: string): Promise { 207 | try { 208 | const songJSON = await CosmosAsync.get(`https://api.spotify.com/v1/search?q=${songName}&type=track&limit=15&offset=0`); 209 | for (const item of songJSON.tracks.items) { 210 | if (await isSameSong(songName, item.name)) return item.id; 211 | } 212 | } catch { 213 | console.log(`phrase2playlist: API Error while searching for word: ${songName}, using Error Track`); 214 | return "notfound"; 215 | } 216 | return "notfound"; 217 | } 218 | 219 | async function isSameSong(word: string, name: string): Promise { 220 | const cleanedName = name 221 | .toUpperCase() 222 | .replace(/[^\w\s\-]|_/g, "") 223 | .replace(/\s+/g, " "); 224 | const cleanedWord = word 225 | .toUpperCase() 226 | .replace(/[^\w\s\-]|_/g, "") 227 | .replace(/\s+/g, " "); 228 | return cleanedName === cleanedWord; 229 | } 230 | 231 | async function createPlaylist(songArr: string[], notfoundSongs: number) { 232 | const date = new Date(); 233 | const locale = "en-GB"; 234 | const newPlaylist = await CosmosAsync.post("https://api.spotify.com/v1/me/playlists", { 235 | name: `Phrase2Playlist - ${date.toLocaleString(locale).slice(0, 10)}`, 236 | public: false, 237 | }); 238 | 239 | const playlistID = newPlaylist.uri; 240 | await Platform.PlaylistAPI.add(playlistID, songArr.filter(String), { before: 0 }); 241 | 242 | Spicetify.showNotification(`Created new Phrase2Playlist playlist! ${notfoundSongs ? `(${notfoundSongs} songs not found)` : ""}`); 243 | } 244 | })(); 245 | -------------------------------------------------------------------------------- /phraseToPlaylist/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["phraseToPlaylist.tsx"] 4 | } 5 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | devDependencies: 11 | '@types/react': 12 | specifier: ^18.3.3 13 | version: 18.3.3 14 | typescript: 15 | specifier: ^5.4.5 16 | version: 5.4.5 17 | 18 | packages: 19 | 20 | '@types/prop-types@15.7.12': 21 | resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} 22 | 23 | '@types/react@18.3.3': 24 | resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==} 25 | 26 | csstype@3.1.3: 27 | resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} 28 | 29 | typescript@5.4.5: 30 | resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} 31 | engines: {node: '>=14.17'} 32 | hasBin: true 33 | 34 | snapshots: 35 | 36 | '@types/prop-types@15.7.12': {} 37 | 38 | '@types/react@18.3.3': 39 | dependencies: 40 | '@types/prop-types': 15.7.12 41 | csstype: 3.1.3 42 | 43 | csstype@3.1.3: {} 44 | 45 | typescript@5.4.5: {} 46 | -------------------------------------------------------------------------------- /songstats/README.md: -------------------------------------------------------------------------------- 1 | # songstats 2 | 3 | See audio features of the music you love with `Songstats` for **[spicetify](https://github.com/spicetify/cli)** 4 | 5 | * Right click a song and click "View Song Stats" to see it's audio features. 6 | 7 | ## Install 8 | 9 | 1. Copy `songstats.js` to extensions folder based on your OS, or install it via **[marketplace](https://github.com/spicetify/spicetify-marketplace)** 10 | 11 | | **Platform** | **Path** | 12 | |----------------|--------------------------------------------------------------------------------------| 13 | | **Windows** | `%appdata%\spicetify\Extensions\` | 14 | | **Linux** | `~/.config/spicetify/Extensions` or `$XDG_CONFIG_HOME/spicetify/Extensions/` | 15 | | **MacOS** | `~/.config/spicetify/Extensions` or `~/.spicetify/Extensions` | 16 | 17 | After putting the extension file into the correct folder, run the following command to install the extension or install through marketplace: 18 | 19 | ```sh 20 | spicetify config extensions songstats.js 21 | spicetify apply 22 | ``` 23 | 24 | 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. 25 | 26 | Or you can manually edit your `config-xpui.ini` file. Add your desired extension filenames in the extensions key, separated them by the | character. 27 | Example: 28 | 29 | ```ini 30 | [AdditionalOptions] 31 | ... 32 | extensions = autoSkipExplicit.js|shuffle+.js|trashbin.js|songstats.js 33 | ``` 34 | 35 | Then run: 36 | 37 | ```sh 38 | spicetify apply 39 | ``` 40 | 41 | ## Usage 42 | 43 | Toggle in the Profile menu. 44 | 45 |  46 | 47 | ----- 48 | If you find any bugs, please [create a new issue](https://github.com/rxri/spicetify-extensions/issues/new/choose) on the GitHub repo. 49 |  50 | -------------------------------------------------------------------------------- /songstats/songstats.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @author CharlieS1103 5 | */ 6 | 7 | (function songstats() { 8 | const { CosmosAsync, ContextMenu, URI } = Spicetify; 9 | if (!(CosmosAsync && URI)) { 10 | setTimeout(songstats, 300); 11 | return; 12 | } 13 | let local_language = Spicetify.Locale._locale; 14 | const translation = { 15 | en: { 16 | titletxt: "Song Stats", 17 | buttontxt: "View Song Stats", 18 | danceability: "Danceability", 19 | energy: "Energy", 20 | key: "Key", 21 | loudness: "Loudness", 22 | speechiness: "Speechiness", 23 | acousticness: "Acousticness", 24 | instrumentalness: "Instrumentalness", 25 | liveness: "Liveness", 26 | valence: "Valence", 27 | tempo: "Tempo", 28 | popularity: "Popularity", 29 | releaseDate: "Release Date", 30 | }, 31 | fr: { 32 | titletxt: "Statistique de la musique", 33 | buttontxt: "Voir les statistique de la musique", 34 | danceability: "Capacité à danser", 35 | energy: "Énergie", 36 | key: "Tonalité", 37 | loudness: "Intensité sonore", 38 | speechiness: "Élocution", 39 | acousticness: "Acoustique", 40 | instrumentalness: "instrumentalité", 41 | liveness: "vivacité", 42 | valence: "Mood", 43 | tempo: "Tempo", 44 | popularity: "Popularité", 45 | releaseDate: "Date de sortie", 46 | }, 47 | "fr-CA": { 48 | titletxt: "Statistique de la musique", 49 | buttontxt: "Voir les statistique de la musique", 50 | danceability: "Capacité à danser", 51 | energy: "Énergie", 52 | key: "Tonalité", 53 | loudness: "Intensité sonore", 54 | speechiness: "Élocution", 55 | acousticness: "Acoustique", 56 | instrumentalness: "instrumentalité", 57 | liveness: "vivacité", 58 | valence: "Mood", 59 | tempo: "Tempo", 60 | popularity: "Popularité", 61 | releaseDate: "Date de sortie", 62 | }, 63 | cs: { 64 | titletxt: "Statistiky písně", 65 | buttontxt: "Zobrazit statistiky písně", 66 | danceability: "Tančitelnost", 67 | energy: "Energie", 68 | key: "Tónina", 69 | loudness: "Hlasitost", 70 | speechiness: "Mluvenost", 71 | acousticness: "Akustičnost", 72 | instrumentalness: "Nástrojovost", 73 | liveness: "Živost", 74 | valence: "Emoční náboj", 75 | tempo: "Tempo", 76 | popularity: "Popularita", 77 | releaseDate: "Datum vydání", 78 | }, 79 | de: { 80 | titletxt: "Songstatistiken", 81 | buttontxt: "Songstatistiken anzeigen", 82 | danceability: "Tanzbarkeit", 83 | energy: "Energie", 84 | key: "Tonart", 85 | loudness: "Lautstärke", 86 | speechiness: "Sprechanteil", 87 | acousticness: "Akustik", 88 | instrumentalness: "Instrumentalität", 89 | liveness: "Lebendigkeit", 90 | valence: "Stimmung", 91 | tempo: "Tempo", 92 | popularity: "Beliebtheit", 93 | releaseDate: "Veröffentlichungsdatum", 94 | }, 95 | es: { 96 | titletxt: "Estadísticas de la canción", 97 | buttontxt: "Ver estadísticas de la canción", 98 | danceability: "Bailable", 99 | energy: "Energía", 100 | key: "Tono", 101 | loudness: "Volumen", 102 | speechiness: "Habla", 103 | acousticness: "Acústica", 104 | instrumentalness: "Instrumental", 105 | liveness: "Vivacidad", 106 | valence: "Estado de ánimo", 107 | tempo: "Tempo", 108 | popularity: "Popularidad", 109 | releaseDate: "Fecha de lanzamiento", 110 | }, 111 | }; 112 | 113 | try { 114 | translation[local_language].buttontxt; 115 | } catch { 116 | local_language = "en"; 117 | } 118 | 119 | const titletxt = translation[local_language].titletxt; 120 | const buttontxt = translation[local_language].buttontxt; 121 | const danceability = translation[local_language].danceability; 122 | const energy = translation[local_language].energy; 123 | const key = translation[local_language].key; 124 | const loudness = translation[local_language].loudness; 125 | const speechiness = translation[local_language].speechiness; 126 | const acousticness = translation[local_language].acousticness; 127 | const instrumentalness = translation[local_language].instrumentalness; 128 | const liveness = translation[local_language].liveness; 129 | const valence = translation[local_language].valence; 130 | const tempo = translation[local_language].tempo; 131 | const popularity = translation[local_language].popularity; 132 | const releaseDate = translation[local_language].releaseDate; 133 | 134 | //Watch for when the song is changed 135 | 136 | async function getSongStats(uris) { 137 | const uri = uris[0]; 138 | const uriFinal = uri.split(":")[2]; 139 | const res = await CosmosAsync.get(`https://api.spotify.com/v1/audio-features/${uriFinal}`); 140 | const resTrack = await CosmosAsync.get(`https://api.spotify.com/v1/tracks/${uriFinal}`); 141 | 142 | const pitchClasses = ["C", "C♯/D♭", "D", "D♯/E♭", "E", "F", "F♯/G♭", "G", "G♯/A♭", "A", "A♯/B♭", "B"]; 143 | 144 | let keyText = res.key; 145 | if (res.key === -1) { 146 | keyText = "Undefined"; 147 | } else { 148 | const pitchClassIndex = res.key; 149 | keyText = pitchClasses[pitchClassIndex]; 150 | } 151 | 152 | Spicetify.PopupModal.display({ 153 | title: `${titletxt}`, 154 | content: ` 179 | 180 | 181 | ${danceability}: 182 | ${Math.round(100 * res.danceability)} % 183 | 184 | 185 | ${energy}: 186 | ${Math.round(100 * res.energy)} % 187 | 188 | 189 | ${key}: 190 | ${keyText} 191 | 192 | 193 | ${loudness}: 194 | ${res.loudness} dB 195 | 196 | 197 | ${speechiness}: 198 | ${Math.round(100 * res.speechiness)} % 199 | 200 | 201 | 202 | ${acousticness}: 203 | ${Math.round(100 * res.acousticness)} % 204 | 205 | 206 | ${instrumentalness}: 207 | ${Math.round(100 * res.instrumentalness)} % 208 | 209 | 210 | ${liveness}: 211 | ${Math.round(100 * res.liveness)} % 212 | 213 | 214 | ${valence}: 215 | ${Math.round(100 * res.valence)} % 216 | 217 | 218 | ${tempo}: 219 | ${res.tempo} BPM 220 | 221 | 222 | ${popularity}: 223 | ${resTrack.popularity} % 224 | 225 | 226 | ${releaseDate}: 227 | ${resTrack.album.release_date} 228 | 229 | `, 230 | }); 231 | } 232 | 233 | function shouldDisplayContextMenu(uris) { 234 | if (uris.length > 1) return false; 235 | const uri = uris[0]; 236 | const uriObj = Spicetify.URI.fromString(uri); 237 | if (uriObj.type === Spicetify.URI.Type.TRACK) return true; 238 | return false; 239 | } 240 | 241 | const cntxMenu = new ContextMenu.Item(buttontxt, getSongStats, shouldDisplayContextMenu); 242 | cntxMenu.register(); 243 | })(); 244 | -------------------------------------------------------------------------------- /songstats/songstats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxri/spicetify-extensions/5c8bedf61b718e4d47cc9adede4d39be59726a1e/songstats/songstats.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "skipLibCheck": true, 6 | "target": "ES2021", 7 | "verbatimModuleSyntax": true, 8 | "allowJs": true, 9 | "jsx": "react", 10 | "strict": true, 11 | "noUncheckedIndexedAccess": true, 12 | "lib": ["ES2021", "DOM"] 13 | }, 14 | "exclude": ["featureshuffle/*", "songstats/*", "wikify/*", "writeify/*", "old-sidebar/*", "formatColors/*"] 15 | } 16 | -------------------------------------------------------------------------------- /wikify/README.md: -------------------------------------------------------------------------------- 1 | # Wikify 2 | 3 | Learn more about an artist with `Wikify` for **[spicetify](https://github.com/spicetify/cli)** 4 | 5 | * Right click an artist on spotify and click "View Wiki", it will display a Wikipedia page for the artist in your spotify client! 6 | 7 | ## Install 8 | 9 | 1. Copy `wikify.js` to extensions folder based on your OS, or install it via **[marketplace](https://github.com/spicetify/spicetify-marketplace)** 10 | 11 | | **Platform** | **Path** | 12 | |----------------|--------------------------------------------------------------------------------------| 13 | | **Windows** | `%appdata%\spicetify\Extensions\` | 14 | | **Linux** | `~/.config/spicetify/Extensions` or `$XDG_CONFIG_HOME/spicetify/Extensions/` | 15 | | **MacOS** | `~/.config/spicetify/Extensions` or `~/.spicetify/Extensions` | 16 | 17 | After putting the extension file into the correct folder, run the following command to install the extension or install through marketplace: 18 | 19 | ```sh 20 | spicetify config extensions wikify.js 21 | spicetify apply 22 | ``` 23 | 24 | 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. 25 | 26 | Or you can manually edit your `config-xpui.ini` file. Add your desired extension filenames in the extensions key, separated them by the | character. 27 | Example: 28 | 29 | ```ini 30 | [AdditionalOptions] 31 | ... 32 | extensions = autoSkipExplicit.js|shuffle+.js|trashbin.js|wikify.js 33 | ``` 34 | 35 | Then run: 36 | 37 | ```sh 38 | spicetify apply 39 | ``` 40 | 41 | ## Usage 42 | 43 | Toggle in the Profile menu. 44 | 45 |  46 | 47 | ----- 48 | If you find any bugs, please [create a new issue](https://github.com/rxri/spicetify-extensions/issues/new/choose) on the GitHub repo. 49 |  50 | -------------------------------------------------------------------------------- /wikify/wikify.js: -------------------------------------------------------------------------------- 1 | /// 2 | (function WikiFy() { 3 | if (!document.body.classList.contains("wikify-injected")) { 4 | const styleSheet = document.createElement("style"); 5 | 6 | styleSheet.innerHTML = "body > generic-modal > div > div { background-color: beige !important; color: black !important; }"; 7 | document.body.appendChild(styleSheet); 8 | document.body.classList.add("wikify-injected"); 9 | } 10 | 11 | const { CosmosAsync, URI, Locale, PopupModal } = Spicetify; 12 | if (!(CosmosAsync && URI && Locale && PopupModal)) { 13 | setTimeout(WikiFy, 10); 14 | return; 15 | } 16 | 17 | const lang = Locale.getLocale(); 18 | const buttontxt = "View Wiki"; 19 | 20 | function error() { 21 | PopupModal.display({ 22 | title: "Error", 23 | content: "Selected artist does not have a WikiPedia page, sorry.", 24 | }); 25 | } 26 | 27 | function extractPageText(wikiInfo) { 28 | const wikiInfoArr = wikiInfo.query.pages; 29 | const page = Object.values(wikiInfoArr)[0]; 30 | if (page != null || page !== undefined) return page.extract.replace(//g, ""); 31 | } 32 | 33 | async function getWikiText(uris) { 34 | const rawUri = uris[0]; 35 | const uriSplit = rawUri.split(":"); 36 | const uriType = uriSplit[1]; 37 | const uri = uriSplit[2]; 38 | let artistName = undefined; 39 | let trackName = undefined; 40 | 41 | try { 42 | //This assumes that the `View Wiki` option is only available for artists and tracks 43 | if (uriType === "artist") { 44 | const artistObject = await CosmosAsync.get(`https://api.spotify.com/v1/artists/${uri}`); 45 | artistName = artistObject.name; 46 | } else if (uriType === "track") { 47 | const trackObject = await CosmosAsync.get(`https://api.spotify.com/v1/tracks/${uri}`); 48 | artistName = trackObject.artists[0].name; 49 | trackName = trackObject.name; 50 | } 51 | 52 | if (trackName != null) { 53 | const trackNameTrimmed = trackName.replace(/\s/g, "%20"); 54 | 55 | const wikiTrackInfo = await CosmosAsync.get( 56 | `https://${lang}.wikipedia.org/w/api.php?action=query&format=json&prop=extracts%7Cdescription&titles=${trackNameTrimmed}` 57 | ); 58 | const wikiTrackSongInfo = await CosmosAsync.get( 59 | `https://${lang}.wikipedia.org/w/api.php?action=query&format=json&prop=extracts%7Cdescription&titles=${trackNameTrimmed}%20(song)` 60 | ); 61 | //TODO: option to choose local language or english / english fallback? / subcontextmenu to choose? 62 | //https://en.wikipedia.org/w/api.php?action=query&format=json&uselang=en&list=search&srsearch=${trackNameTrimmed} 63 | 64 | const trackSongPageText = extractPageText(wikiTrackSongInfo); 65 | const trackPageText = extractPageText(wikiTrackInfo); 66 | 67 | if (trackSongPageText !== "\n") { 68 | PopupModal.display({ 69 | title: "WikiFy for Track", 70 | content: trackSongPageText, 71 | }); 72 | return; 73 | } 74 | 75 | if (trackPageText !== "\n") { 76 | PopupModal.display({ 77 | title: "WikiFy for Track", 78 | content: trackPageText, 79 | }); 80 | return; 81 | } 82 | } 83 | 84 | if (artistName != null) { 85 | const artistNameTrimmed = artistName.replace(/\s/g, "%20"); 86 | 87 | const wikiArtistInfo = await CosmosAsync.get( 88 | `https://${lang}.wikipedia.org/w/api.php?action=query&format=json&prop=extracts%7Cdescription&titles=${artistNameTrimmed}` 89 | ); 90 | //TODO: option to choose local language or english / english fallback? / subcontextmenu to choose? 91 | //https://en.wikipedia.org/w/api.php?action=query&format=json&uselang=en&list=search&srsearch=${artistNameTrimmed} 92 | 93 | const artistPageText = extractPageText(wikiArtistInfo); 94 | 95 | if (artistPageText !== "/n") { 96 | PopupModal.display({ 97 | title: "WikiFy for Artist", 98 | content: artistPageText, 99 | }); 100 | return; 101 | } 102 | error(); 103 | } 104 | } catch { 105 | PopupModal.display({ 106 | title: "Requests Failed", 107 | content: "Error while fetching infromation from Wikipedia and Spotify", 108 | }); 109 | } 110 | } 111 | 112 | function shouldDisplayContextMenu(uris) { 113 | if (uris.length > 1) return false; 114 | const uri = uris[0]; 115 | const uriObj = Spicetify.URI.fromString(uri); 116 | if (uriObj.type === Spicetify.URI.Type.TRACK || uriObj.type === Spicetify.URI.Type.ARTIST) return true; 117 | return false; 118 | } 119 | 120 | const cntxMenu = new Spicetify.ContextMenu.Item(buttontxt, getWikiText, shouldDisplayContextMenu); 121 | 122 | cntxMenu.register(); 123 | })(); 124 | -------------------------------------------------------------------------------- /wikify/wikify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxri/spicetify-extensions/5c8bedf61b718e4d47cc9adede4d39be59726a1e/wikify/wikify.png -------------------------------------------------------------------------------- /writeify/README.md: -------------------------------------------------------------------------------- 1 | # Writeify 2 | 3 | Take notes on songs with `Writeify` for **[spicetify](https://github.com/spicetify/cli)** 4 | 5 | * Right click any song or click it's note in the note column of the albums to write a note, remember to hit save! 6 | 7 | ## Install 8 | 9 | 1. Copy `writeify.js` to extensions folder based on your OS, or install it via **[marketplace](https://github.com/spicetify/spicetify-marketplace)** 10 | 11 | | **Platform** | **Path** | 12 | |----------------|--------------------------------------------------------------------------------------| 13 | | **Windows** | `%appdata%\spicetify\Extensions\` | 14 | | **Linux** | `~/.config/spicetify/Extensions` or `$XDG_CONFIG_HOME/spicetify/Extensions/` | 15 | | **MacOS** | `~/.config/spicetify/Extensions` or `~/.spicetify/Extensions` | 16 | 17 | After putting the extension file into the correct folder, run the following command to install the extension or install through marketplace: 18 | 19 | ```sh 20 | spicetify config extensions writeify.js 21 | spicetify apply 22 | ``` 23 | 24 | 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. 25 | 26 | Or you can manually edit your `config-xpui.ini` file. Add your desired extension filenames in the extensions key, separated them by the | character. 27 | Example: 28 | 29 | ```ini 30 | [AdditionalOptions] 31 | ... 32 | extensions = autoSkipExplicit.js|shuffle+.js|trashbin.js|writeify.js 33 | ``` 34 | 35 | Then run: 36 | 37 | ```sh 38 | spicetify apply 39 | ``` 40 | 41 | ## Usage 42 | 43 | Toggle in the Profile menu. 44 | 45 |  46 | 47 | ----- 48 | If you find any bugs, please [create a new issue](https://github.com/rxri/spicetify-extensions/issues/new/choose) on the GitHub repo. 49 |  50 | -------------------------------------------------------------------------------- /writeify/writeify.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | // NAME: Song Notes 4 | // AUTHOR: CharlieS1103 5 | // DESCRIPTION: Create and edit notes for specific songs 6 | 7 | /// 8 | 9 | (async function SongNotes() { 10 | console.log('Song Notes extension loaded'); 11 | 12 | const { CosmosAsync, Menu, PopupModal, Platform } = Spicetify; 13 | if (!(CosmosAsync && Menu && PopupModal)) { 14 | setTimeout(SongNotes, 10); 15 | console.log('Waiting for Spicetify objects to be available'); 16 | return; 17 | } 18 | 19 | // Make a sleep function 20 | 21 | async function sleep(ms) { 22 | return new Promise(resolve => setTimeout(resolve, ms)); 23 | } 24 | /* TODO: Fix this part 25 | Platform.History.listen(async ({ pathname }) => { 26 | if (pathname.includes("/playlist")) { 27 | await sleep(2500) 28 | insertTrackNotes(".main-trackList-trackListRow"); 29 | } 30 | }); 31 | */ 32 | 33 | 34 | 35 | 36 | // Inject CSS 37 | const style = document.createElement("style"); 38 | style.innerHTML = ` 39 | 40 | .song-notes-textarea { 41 | width: 100%; 42 | height: 200px; 43 | padding: 10px; 44 | font-size: 14px; 45 | background-color: var(--spice-main); 46 | resize: none; 47 | border: 1px var(--spice-misc); 48 | box-shadow: 0px 0px 20px 0px var(--spice-misc); 49 | color: var(--spice-subtext); 50 | } 51 | .song-notes-save { 52 | background-color: var(--spice-button); 53 | color: var(--spice-text); 54 | border: none; 55 | padding: 10px 20px; 56 | font-size: 16px; 57 | cursor: pointer; 58 | border-radius: 5px; 59 | } 60 | .song-notes-save:hover { 61 | background-color: var(--spice-button-active); 62 | } 63 | 64 | .track-notes-column { 65 | -webkit-box-align: center; 66 | -ms-flex-align: center; 67 | align-items: center; 68 | display: -webkit-box; 69 | display: -ms-flexbox; 70 | display: flex; 71 | grid-column: 4; 72 | font-size: 0.750rem; 73 | justify-self: end; 74 | width: 175%; 75 | } 76 | `; 77 | document.head.appendChild(style); 78 | 79 | 80 | // Open the popup modal with the song notes 81 | function openNotesModal(uris) { 82 | console.log('Open notes modal:', uris); 83 | const songUri = uris[0]; 84 | const storedNotes = localStorage.getItem("songNotes"); 85 | let notes = ""; 86 | 87 | if (songUri && storedNotes) { 88 | const notesMap = JSON.parse(storedNotes); 89 | notes = notesMap[songUri] || ""; 90 | } 91 | 92 | PopupModal.display({ 93 | title: "Song Notes", 94 | content: createNotesModal(notes), 95 | }); 96 | 97 | const saveButton = document.querySelector("#song-notes-save"); 98 | if (!saveButton) return; 99 | saveButton.addEventListener("click", () => saveSongNotes(songUri), false); 100 | } 101 | 102 | // Create the popup modal to display the notes 103 | function createNotesModal(notes) { 104 | const container = document.createElement("div"); 105 | container.innerHTML = ` 106 | ${notes} 107 | 108 | Save 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 --------------------------------------------------------------------------------