├── .gitattributes ├── projects ├── library │ ├── previews │ │ ├── albums.png │ │ ├── artists.png │ │ ├── playlists.png │ │ └── collections.png │ ├── src │ │ ├── settings.json │ │ ├── components │ │ │ ├── local_album_menu.tsx │ │ │ ├── leading_icon.tsx │ │ │ ├── pin_icon.tsx │ │ │ ├── collapse_button.tsx │ │ │ ├── folder_fallback.tsx │ │ │ ├── artist_menu_item.tsx │ │ │ ├── load_more_card.tsx │ │ │ ├── toggle_filters.tsx │ │ │ ├── expand_button.tsx │ │ │ ├── back_button.tsx │ │ │ ├── text_input_dialog.tsx │ │ │ ├── add_button.tsx │ │ │ ├── nav_context.tsx │ │ │ ├── searchbar.tsx │ │ │ ├── folder_menu.tsx │ │ │ ├── custom_card.tsx │ │ │ ├── collection_menu.tsx │ │ │ └── album_menu_item.tsx │ │ ├── styles │ │ │ ├── icon_filled.svg │ │ │ ├── icon_unfilled.svg │ │ │ ├── app.scss │ │ │ └── external.scss │ │ ├── types │ │ │ ├── library_types.ts │ │ │ └── platform.ts │ │ ├── extensions │ │ │ ├── folder_image_wrapper.ts │ │ │ └── extension.tsx │ │ ├── utils │ │ │ └── collection_sort.ts │ │ ├── app.tsx │ │ └── pages │ │ │ ├── artists.tsx │ │ │ ├── shows.tsx │ │ │ ├── albums.tsx │ │ │ ├── collections.tsx │ │ │ └── playlists.tsx │ ├── package.json │ └── README.md ├── stats │ ├── previews │ │ ├── top_albums.png │ │ ├── top_artists.png │ │ ├── top_charts.png │ │ ├── top_genres.png │ │ ├── top_tracks.png │ │ ├── library_analysis.png │ │ └── playlist_analysis.png │ ├── src │ │ ├── styles │ │ │ ├── placeholder.png │ │ │ ├── icon_filled.svg │ │ │ ├── icon_unfilled.svg │ │ │ └── app.scss │ │ ├── settings.json │ │ ├── components │ │ │ ├── cards │ │ │ │ ├── stat_card.tsx │ │ │ │ └── chart_card.tsx │ │ │ ├── shelf.tsx │ │ │ ├── buttons │ │ │ │ ├── refresh_button.tsx │ │ │ │ └── create_playlist_button.tsx │ │ │ ├── inline_grid.tsx │ │ │ └── tracklist.tsx │ │ ├── api │ │ │ ├── platform.ts │ │ │ ├── lastfm.ts │ │ │ └── spotify.ts │ │ ├── extensions │ │ │ ├── cache.ts │ │ │ └── extension.tsx │ │ ├── types │ │ │ ├── stats_types.ts │ │ │ ├── lastfm.ts │ │ │ ├── graph_ql.ts │ │ │ └── spotify.ts │ │ ├── pages │ │ │ ├── top_albums.tsx │ │ │ ├── top_tracks.tsx │ │ │ ├── top_artists.tsx │ │ │ ├── playlist.tsx │ │ │ ├── top_genres.tsx │ │ │ ├── library.tsx │ │ │ └── charts.tsx │ │ ├── app.tsx │ │ └── utils │ │ │ ├── converter.ts │ │ │ └── track_helper.ts │ ├── package.json │ ├── install.sh │ ├── install.ps1 │ └── README.md └── shared │ └── src │ ├── placeholders │ ├── def_placeholder.png │ └── folder_placeholder.png │ ├── status │ ├── useStatus.tsx │ └── status.tsx │ ├── types │ ├── react_query.ts │ └── platform.ts │ ├── config │ ├── config_types.ts │ ├── config_modal.scss │ ├── config_wrapper.tsx │ └── config_modal.tsx │ ├── components │ ├── page_container.tsx │ ├── navigation │ │ └── navigation_bar.tsx │ ├── settings_button.tsx │ └── spotify_card.tsx │ ├── dropdown │ ├── useDropdownMenu.tsx │ ├── useSortDropdownMenu.tsx │ ├── sort_dropdown.tsx │ └── dropdown.tsx │ ├── icons │ └── arrows.tsx │ └── shared.scss ├── package.json ├── tsconfig.json ├── manifest.json ├── README.md └── .gitignore /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/* linguist-vendored -------------------------------------------------------------------------------- /projects/library/previews/albums.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/library/previews/albums.png -------------------------------------------------------------------------------- /projects/library/previews/artists.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/library/previews/artists.png -------------------------------------------------------------------------------- /projects/library/previews/playlists.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/library/previews/playlists.png -------------------------------------------------------------------------------- /projects/stats/previews/top_albums.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/top_albums.png -------------------------------------------------------------------------------- /projects/stats/previews/top_artists.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/top_artists.png -------------------------------------------------------------------------------- /projects/stats/previews/top_charts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/top_charts.png -------------------------------------------------------------------------------- /projects/stats/previews/top_genres.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/top_genres.png -------------------------------------------------------------------------------- /projects/stats/previews/top_tracks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/top_tracks.png -------------------------------------------------------------------------------- /projects/library/previews/collections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/library/previews/collections.png -------------------------------------------------------------------------------- /projects/stats/src/styles/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/src/styles/placeholder.png -------------------------------------------------------------------------------- /projects/stats/previews/library_analysis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/library_analysis.png -------------------------------------------------------------------------------- /projects/stats/previews/playlist_analysis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/playlist_analysis.png -------------------------------------------------------------------------------- /projects/shared/src/placeholders/def_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/shared/src/placeholders/def_placeholder.png -------------------------------------------------------------------------------- /projects/shared/src/placeholders/folder_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/shared/src/placeholders/folder_placeholder.png -------------------------------------------------------------------------------- /projects/stats/src/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "displayName": "Statistics", 3 | "nameId": "stats", 4 | "icon": "styles/icon_unfilled.svg", 5 | "activeIcon": "styles/icon_filled.svg" 6 | } -------------------------------------------------------------------------------- /projects/library/src/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "displayName": "Your Library", 3 | "nameId": "library", 4 | "icon": "styles/icon_unfilled.svg", 5 | "activeIcon": "styles/icon_filled.svg" 6 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spicetify-apps", 3 | "private": true, 4 | "version": "1.0.0", 5 | "author": "harbassan", 6 | "license": "MIT", 7 | "workspaces": [ 8 | "stats", 9 | "library" 10 | ] 11 | } -------------------------------------------------------------------------------- /projects/library/src/components/local_album_menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const LocalAlbumMenu = ({ id }: { id: string }) => { 4 | const { Menu, MenuItem } = Spicetify.ReactComponent; 5 | 6 | return ; 7 | }; 8 | 9 | export default LocalAlbumMenu; 10 | -------------------------------------------------------------------------------- /projects/library/src/styles/icon_filled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /projects/library/src/styles/icon_unfilled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /projects/library/src/components/leading_icon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const LeadingIcon = ({ path }: { path: string }) => { 4 | return ( 5 | ${path}`, 9 | }} 10 | iconSize={16} 11 | /> 12 | ); 13 | }; 14 | 15 | export default LeadingIcon; 16 | -------------------------------------------------------------------------------- /projects/library/src/components/pin_icon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const PinIcon = () => ( 4 | 5 | 6 | 7 | ); 8 | 9 | export default PinIcon; 10 | -------------------------------------------------------------------------------- /projects/library/src/components/collapse_button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { LeftArrow } from "../../../shared/src/icons/arrows"; 3 | 4 | const collapseLibrary = () => { 5 | Spicetify.Platform.LocalStorageAPI.setItem("left-sidebar-state", 1); 6 | }; 7 | 8 | const CollapseButton = () => { 9 | const { ButtonTertiary } = Spicetify.ReactComponent; 10 | 11 | return ; 12 | }; 13 | 14 | export default CollapseButton; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "jsx": "react", 5 | "module": "ES2022", 6 | "moduleResolution": "bundler", 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "lib": [ 12 | "ES2022", 13 | "DOM" 14 | ], 15 | "baseUrl": ".", 16 | "paths": { 17 | "@shared/*": ["./projects/shared/src/*"] 18 | }, 19 | }, 20 | "include": [ 21 | "./projects/*/src/**/*"] 22 | } 23 | -------------------------------------------------------------------------------- /projects/shared/src/status/useStatus.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Status from "./status"; 3 | 4 | const useStatus = (status: "success" | "error" | "pending", error: Error | null) => { 5 | if (status === "pending") { 6 | return ; 7 | } 8 | 9 | if (status === "error") { 10 | return ; 11 | } 12 | 13 | return null; 14 | }; 15 | 16 | export default useStatus; 17 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Statistics", 4 | "description": "See your top artists, tracks, genres and an analysis of your music library.", 5 | "preview": "stats/previews/top_genres.png", 6 | "readme": "stats/README.md", 7 | "tags": ["stats"] 8 | }, 9 | { 10 | "name": "Library", 11 | "description": "An extended music library with additional features.", 12 | "preview": "library/previews/albums.png", 13 | "readme": "library/README.md", 14 | "tags": ["library"] 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /projects/library/src/components/folder_fallback.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const FolderSVG = (e: any): React.ReactElement => { 4 | return ( 5 | ', 12 | }} 13 | {...e} 14 | /> 15 | ); 16 | }; 17 | 18 | export default FolderSVG; 19 | -------------------------------------------------------------------------------- /projects/stats/src/components/cards/stat_card.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface StatCardProps { 4 | label: string; 5 | value: number | string; 6 | } 7 | 8 | function StatCard({ label, value }: StatCardProps) { 9 | const { TextComponent } = Spicetify.ReactComponent; 10 | 11 | return ( 12 |
13 | 14 | {value} 15 | 16 | 17 | {label} 18 | 19 |
20 | ); 21 | } 22 | 23 | export default StatCard; 24 | -------------------------------------------------------------------------------- /projects/library/src/components/artist_menu_item.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import LeadingIcon from "./leading_icon"; 3 | 4 | const ArtistMenuItem = () => { 5 | const { MenuItem } = Spicetify.ReactComponent; 6 | const { SVGIcons } = Spicetify; 7 | 8 | const context = React.useContext(Spicetify.ContextMenuV2._context); 9 | const uri = context?.props?.uri; 10 | 11 | return ( 12 | } 15 | onClick={() => CollectionsWrapper.createCollectionFromDiscog(uri)} 16 | > 17 | Create Discog Collection 18 | 19 | ); 20 | }; 21 | 22 | export default ArtistMenuItem; 23 | -------------------------------------------------------------------------------- /projects/library/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spicetify-library", 3 | "version": "1.1.1", 4 | "private": true, 5 | "scripts": { 6 | "build": "spicetify-creator", 7 | "build-local": "spicetify-creator --out=dist --minify", 8 | "watch": "spicetify-creator --watch" 9 | }, 10 | "license": "MIT", 11 | "devDependencies": { 12 | "@types/react": "^18.0.33", 13 | "@types/react-dom": "^18.0.11", 14 | "spicetify-creator": "^1.0.17" 15 | }, 16 | "dependencies": { 17 | "uuid": "^9.0.1" 18 | }, 19 | "description": "---", 20 | "main": "index.js", 21 | "keywords": [], 22 | "author": "" 23 | } 24 | -------------------------------------------------------------------------------- /projects/shared/src/types/react_query.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | QueryClientProvider as QueryClientProviderT, 3 | QueryClient as QueryClientT, 4 | useQuery as useQueryT, 5 | useInfiniteQuery as useInfiniteQueryT, 6 | } from "@tanstack/react-query"; 7 | 8 | 9 | export const ReactQuery = Spicetify.ReactQuery; 10 | 11 | export const useQuery: typeof useQueryT = (...args) => ReactQuery.useQuery(...args); 12 | export const useInfiniteQuery: typeof useInfiniteQueryT = (...args) => ReactQuery.useInfiniteQuery(...args); 13 | export const getQueryClient = () => ReactQuery.QueryClient as QueryClientT; 14 | export const QueryClientProvider: typeof QueryClientProviderT = (...args) => ReactQuery.QueryClientProvider(...args); 15 | -------------------------------------------------------------------------------- /projects/stats/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spicetify-stats", 3 | "version": "1.1.3", 4 | "private": true, 5 | "scripts": { 6 | "build": "spicetify-creator", 7 | "build-local": "spicetify-creator --out=dist --minify", 8 | "watch": "spicetify-creator --watch" 9 | }, 10 | "license": "MIT", 11 | "devDependencies": { 12 | "@tanstack/react-query": "^5.49.0", 13 | "@types/react": "^18.0.33", 14 | "@types/react-dom": "^18.0.11", 15 | "spicetify-creator": "^1.0.17" 16 | }, 17 | "dependencies": { 18 | "lodash": "^4.17.21" 19 | }, 20 | "description": "---", 21 | "main": "index.js", 22 | "keywords": [], 23 | "author": "" 24 | } 25 | -------------------------------------------------------------------------------- /projects/library/src/components/load_more_card.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const LoadMoreCard = (props: any) => { 4 | const { callback } = props; 5 | return ( 6 |
7 |
8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | Load More 16 | 17 |
18 | ); 19 | }; 20 | 21 | export default LoadMoreCard; 22 | -------------------------------------------------------------------------------- /projects/library/src/types/library_types.ts: -------------------------------------------------------------------------------- 1 | import type CollectionsWrapper from "../extensions/collections_wrapper"; 2 | import type FolderImageWrapper from "../extensions/folder_image_wrapper"; 3 | 4 | declare global { 5 | var SpicetifyLibrary: any; 6 | var CollectionsWrapper: CollectionsWrapper; 7 | var FolderImageWrapper: FolderImageWrapper; 8 | } 9 | 10 | export interface Config { 11 | "card-size": number; 12 | "extended-search": boolean; 13 | localAlbums: boolean; 14 | includeLikedSongs: boolean; 15 | includeLocalFiles: boolean; 16 | "show-artists": boolean; 17 | "show-albums": boolean; 18 | "show-playlists": boolean; 19 | "show-shows": boolean; 20 | "show-collections": boolean; 21 | } 22 | 23 | export interface ConfigWrapper { 24 | config: Config; 25 | launchModal: () => void; 26 | } 27 | -------------------------------------------------------------------------------- /projects/stats/src/components/shelf.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface ShelfProps { 4 | title: string; 5 | children: React.ReactElement | React.ReactElement[]; 6 | } 7 | 8 | function Shelf(props: ShelfProps): React.ReactElement { 9 | const { TextComponent } = Spicetify.ReactComponent; 10 | const { title, children } = props; 11 | 12 | return ( 13 |
14 |
15 |
16 |
17 | 18 |
19 |
20 |
21 |
{children}
22 |
23 | ); 24 | } 25 | 26 | export default React.memo(Shelf); 27 | -------------------------------------------------------------------------------- /projects/stats/src/styles/icon_filled.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /projects/stats/src/styles/icon_unfilled.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /projects/shared/src/config/config_types.ts: -------------------------------------------------------------------------------- 1 | export type ConfigProps = Record; 2 | 3 | export interface ConfigWrapperProps { 4 | config: ConfigProps; 5 | launchModal: (callback: () => void) => void; 6 | } 7 | 8 | export type ModalStructureProps = ModalStructureRowProps[]; 9 | 10 | type SharedProps = { 11 | name: string; 12 | key: string; 13 | desc?: string; 14 | def: any; 15 | sectionHeader?: string; 16 | callback?: (value: any) => void; 17 | }; 18 | 19 | type ModalStructureRowProps = SharedProps & 20 | ( 21 | | { type: "toggle" /* other props for toggle */ } 22 | | { type: "text"; placeholder?: string /* other props for text */ } 23 | | { type: "dropdown"; options: string[] /* other props for dropdown */ } 24 | | { type: "slider"; min: number; max: number; step: number /* other props for slider */ } 25 | ); 26 | -------------------------------------------------------------------------------- /projects/library/src/components/toggle_filters.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DownArrow, UpArrow } from "../../../shared/src/icons/arrows"; 3 | 4 | const ToggleFiltersButton = () => { 5 | const [direction, setDirection] = React.useState( 6 | document.body.classList.contains("show-ylx-filters") ? "up" : "down", 7 | ); 8 | const { ButtonTertiary } = Spicetify.ReactComponent; 9 | 10 | const toggleDirection = () => { 11 | if (direction === "down") { 12 | document.body.classList.add("show-ylx-filters"); 13 | setDirection("up"); 14 | } else { 15 | setDirection("down"); 16 | document.body.classList.remove("show-ylx-filters"); 17 | } 18 | }; 19 | 20 | const Icon = direction === "down" ? DownArrow : UpArrow; 21 | 22 | return ; 23 | }; 24 | 25 | export default ToggleFiltersButton; 26 | -------------------------------------------------------------------------------- /projects/library/src/components/expand_button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const expandLibrary = () => { 4 | Spicetify.Platform.LocalStorageAPI.setItem("ylx-sidebar-state", 0); 5 | }; 6 | 7 | const ExpandIcon = () => { 8 | const { IconComponent } = Spicetify.ReactComponent; 9 | 10 | return ( 11 | ', 16 | }} 17 | iconSize={16} 18 | /> 19 | ); 20 | }; 21 | 22 | const ExpandButton = () => { 23 | const { ButtonTertiary } = Spicetify.ReactComponent; 24 | 25 | return ; 26 | }; 27 | 28 | export default ExpandButton; 29 | -------------------------------------------------------------------------------- /projects/shared/src/components/page_container.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface PageContainerProps { 4 | lhs: React.ReactNode[]; 5 | rhs?: React.ReactNode[]; 6 | children: React.ReactElement | React.ReactElement[]; 7 | } 8 | 9 | const PageContainer = (props: PageContainerProps) => { 10 | const { rhs, lhs, children } = props; 11 | const { TextComponent } = Spicetify.ReactComponent; 12 | 13 | function parseNodes(nodes: React.ReactNode[]) { 14 | return nodes.map(node => typeof node === "string" 15 | ? 16 | : node 17 | ); 18 | } 19 | return ( 20 |
21 |
22 |
{parseNodes(lhs)}
23 |
{rhs}
24 |
25 |
{children}
26 |
27 | ); 28 | }; 29 | 30 | export default PageContainer; 31 | -------------------------------------------------------------------------------- /projects/library/src/components/back_button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function BackIcon(): React.ReactElement { 4 | return ( 5 | ', 10 | }} 11 | iconSize={16} 12 | /> 13 | ); 14 | } 15 | 16 | function BackButton({ url }: { url: string }) { 17 | const { TooltipWrapper, ButtonTertiary } = Spicetify.ReactComponent; 18 | 19 | function navigate() { 20 | Spicetify.Platform.History.replace(`/library/${url}`); 21 | Spicetify.LocalStorage.set("library:active-link", url); 22 | } 23 | 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | export default BackButton; 34 | 35 | -------------------------------------------------------------------------------- /projects/stats/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Define variables 4 | CUSTOM_APPS_DIR="$HOME/.config/spicetify/CustomApps" 5 | STATS_APP_DIR="$CUSTOM_APPS_DIR/stats" 6 | REPO="harbassan/spicetify-apps" 7 | ZIP_FILE="/tmp/spicetify-stats.zip" 8 | TEMP_DIR="/tmp/spicetify-stats" 9 | 10 | # Create CustomApps directory if it doesn't exist 11 | mkdir -p "$CUSTOM_APPS_DIR" 12 | 13 | # Get the latest release download URL 14 | LATEST_RELEASE_URL=$(curl -s "https://api.github.com/repos/$REPO/releases" | grep -B10 "spicetify-stats.release.zip" | grep "browser_download_url" | head -n1 | cut -d '"' -f 4) 15 | 16 | # Download the zip file 17 | curl -L -o "$ZIP_FILE" "$LATEST_RELEASE_URL" 18 | 19 | # Unzip the file 20 | unzip "$ZIP_FILE" -d "$TEMP_DIR" 21 | 22 | # Move the unzipped folder to the correct location 23 | rm -rf "$STATS_APP_DIR/stats" # Ensure the target is empty 24 | cp -r "$TEMP_DIR/stats" "$STATS_APP_DIR/" 25 | 26 | # Apply Spicetify configuration 27 | spicetify config custom_apps stats 28 | spicetify apply 29 | 30 | # Clean up 31 | rm -rf "$ZIP_FILE" "$TEMP_DIR" 32 | 33 | echo "Installation complete. Enjoy your new stats app!" 34 | -------------------------------------------------------------------------------- /projects/library/src/components/text_input_dialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { type FormEvent } from "react"; 2 | 3 | const TextInputDialog = (props: { def?: string; placeholder: string; onSave: (value: string) => void }) => { 4 | const { def, placeholder, onSave } = props; 5 | 6 | const [value, setValue] = React.useState(def || ""); 7 | 8 | const onSubmit = (e: FormEvent) => { 9 | e.preventDefault(); 10 | Spicetify.PopupModal.hide(); 11 | onSave(value); 12 | }; 13 | 14 | return ( 15 | <> 16 |
17 | 26 | 29 |
30 | 31 | ); 32 | }; 33 | 34 | export default TextInputDialog; 35 | -------------------------------------------------------------------------------- /projects/shared/src/components/navigation/navigation_bar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | function NavigationBar({ links, selected, storekey }: { links: string[], selected: string, storekey: string }) { 5 | // @ts-ignore 6 | const { Chip } = Spicetify.ReactComponent; 7 | 8 | function navigate(page: string) { 9 | Spicetify.Platform.History.push(`/${storekey.split(":")[0]}/${page}`); 10 | Spicetify.LocalStorage.set(storekey, page); 11 | } 12 | 13 | return ReactDOM.createPortal( 14 |
15 |
16 |
17 | {links.map(link => 18 | navigate(link)}>{link} 19 | )} 20 |
21 |
22 |
, 23 | document.querySelector(".main-topBar-topbarContentWrapper")! 24 | ); 25 | }; 26 | 27 | export default NavigationBar; 28 | -------------------------------------------------------------------------------- /projects/library/src/extensions/folder_image_wrapper.ts: -------------------------------------------------------------------------------- 1 | class FolderImageWrapper extends EventTarget { 2 | _folderImages: Record; 3 | 4 | constructor() { 5 | super(); 6 | this._folderImages = JSON.parse(localStorage.getItem("library:folderImages") || "{}"); 7 | } 8 | 9 | static INSTANCE = new FolderImageWrapper(); 10 | 11 | getFolderImage(uri: string) { 12 | return this._folderImages[uri]; 13 | } 14 | 15 | getFolderImages() { 16 | return this._folderImages; 17 | } 18 | 19 | setFolderImage({ uri, url }: { uri: string; url: string }) { 20 | this._folderImages[uri] = url; 21 | 22 | this.saveFolderImages(); 23 | Spicetify.showNotification("Folder image updated"); 24 | } 25 | 26 | removeFolderImage(uri: string) { 27 | delete this._folderImages[uri]; 28 | 29 | this.saveFolderImages(); 30 | Spicetify.showNotification("Folder image removed"); 31 | } 32 | 33 | saveFolderImages() { 34 | this.dispatchEvent(new CustomEvent("update", { detail: this._folderImages })); 35 | localStorage.setItem("library:folderImages", JSON.stringify(this._folderImages)); 36 | } 37 | } 38 | 39 | window.FolderImageWrapper = FolderImageWrapper.INSTANCE; 40 | 41 | export default FolderImageWrapper; 42 | -------------------------------------------------------------------------------- /projects/stats/src/api/platform.ts: -------------------------------------------------------------------------------- 1 | import type { getAlbumResponse } from "../types/graph_ql"; 2 | import type { PlaylistResponse, RootlistResponse } from "../../../shared/types/platform"; 3 | 4 | export const getFullPlaylist = async (uri: string) => { 5 | const playlist = (await Spicetify.Platform.PlaylistAPI.getPlaylist(uri)) as PlaylistResponse; 6 | const tracks = playlist.contents.items; 7 | return tracks; 8 | }; 9 | 10 | export const getRootlist = async () => { 11 | const rootlist = (await Spicetify.Platform.RootlistAPI.getContents({ flatten: true })) as RootlistResponse; 12 | return rootlist.items; 13 | }; 14 | 15 | export const getAlbumMeta = (uri: string) => { 16 | return ( 17 | Spicetify.GraphQL.Request(Spicetify.GraphQL.Definitions.getAlbum, { 18 | uri, 19 | offset: 0, 20 | limit: 1, 21 | locale: Spicetify.Locale.getLocale(), 22 | }) as Promise 23 | ).then((res) => res?.data?.albumUnion); 24 | }; 25 | 26 | export const getAlbumMetas = (uris: string[]) => { 27 | return Promise.all(uris.map((uri) => getAlbumMeta(uri))); 28 | }; 29 | 30 | export const queryInLibrary = async (uris: string[]) => { 31 | return Spicetify.Platform.LibraryAPI.contains(...uris) as Promise; 32 | }; 33 | -------------------------------------------------------------------------------- /projects/stats/src/extensions/cache.ts: -------------------------------------------------------------------------------- 1 | const cache: Record = {}; 2 | 3 | export const set = (key: string, value: T) => { 4 | cache[key] = value; 5 | }; 6 | 7 | const invalidate = (key: string) => { 8 | delete cache[key]; 9 | }; 10 | 11 | // cache a specific function 12 | export const cacher = (cb: () => Promise) => { 13 | return async ({ queryKey }: { queryKey: string[] }) => { 14 | const key = queryKey.join("-"); 15 | if (cache[key]) return cache[key] as T; 16 | const result = await cb(); 17 | set(key, result); 18 | return result; 19 | }; 20 | }; 21 | 22 | // cache a batch function 23 | export const batchCacher = (prefix: string, cb: (ids: string[]) => Promise) => { 24 | return async (ids: string[]) => { 25 | const cached = ids.map((id) => cache[`${prefix}-${id}`] as T); 26 | const uncached = ids.filter((_, index) => !cached[index]); 27 | const results = await cb(uncached); 28 | results.forEach((result, index) => set(`${prefix}-${uncached[index]}`, result)); 29 | return [...cached.filter(Boolean), ...results]; 30 | }; 31 | }; 32 | 33 | export const invalidator = (queryKey: string[], refetch: () => Promise) => { 34 | invalidate(queryKey.join("-")); 35 | refetch(); 36 | }; 37 | -------------------------------------------------------------------------------- /projects/library/src/utils/collection_sort.ts: -------------------------------------------------------------------------------- 1 | import { CollectionChild } from "../extensions/collections_wrapper"; 2 | 3 | function collectionSort(order: string, reverse: boolean) { 4 | const sortBy = (a: CollectionChild, b: CollectionChild) => { 5 | if (a.pinned || b.pinned) return 0; 6 | switch (order) { 7 | case "0": 8 | return a.name.replace(/^the\s+/i, '').localeCompare(b.name.replace(/^the\s+/i, '')); 9 | case "1": 10 | return new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime(); 11 | case "2": 12 | if (a.type === "collection") return -1; 13 | if (b.type === "collection") return 1; 14 | return a.artists[0].name.replace(/^the\s+/i, '').localeCompare(b.artists[0].name.replace(/^the\s+/i, '')); 15 | case "6": 16 | // @ts-ignore Date contructor does accept null as a parameter 17 | return new Date(b.lastPlayedAt).getTime() - new Date(a.lastPlayedAt).getTime(); 18 | default: 19 | return 0; 20 | } 21 | }; 22 | 23 | return reverse ? (a: any, b: any) => sortBy(b, a) : sortBy; 24 | } 25 | 26 | export default collectionSort; -------------------------------------------------------------------------------- /projects/library/src/components/add_button.tsx: -------------------------------------------------------------------------------- 1 | // biome-ignore lint: 2 | import React from "react"; 3 | 4 | interface AddButtonProps { 5 | Menu: typeof Spicetify.ReactComponent.Menu; 6 | } 7 | 8 | function AddIcon(): React.ReactElement { 9 | return ( 10 | ', 15 | }} 16 | iconSize={16} 17 | /> 18 | ); 19 | } 20 | 21 | function AddButton(props: AddButtonProps): React.ReactElement { 22 | const { ReactComponent } = Spicetify; 23 | const { TooltipWrapper, ButtonTertiary, ContextMenu } = ReactComponent; 24 | const { Menu } = props; 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | 37 | export default AddButton; 38 | -------------------------------------------------------------------------------- /projects/stats/src/components/buttons/refresh_button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface RefreshButtonProps { 4 | callback: () => void; 5 | } 6 | 7 | function RefreshIcon(): React.ReactElement { 8 | return ( 9 | ', 15 | }} 16 | /> 17 | ); 18 | } 19 | 20 | function RefreshButton(props: RefreshButtonProps): React.ReactElement { 21 | const { ButtonTertiary, TooltipWrapper } = Spicetify.ReactComponent; 22 | const { callback } = props; 23 | 24 | return ( 25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | export default RefreshButton; 32 | -------------------------------------------------------------------------------- /projects/stats/src/components/cards/chart_card.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const ChartLine = (name: string, value: number, limit: number, total: number) => { 4 | return ( 5 |
6 |
12 | {name} 13 |
14 | {`${Math.round((value / total) * 100)}%`} 15 |
16 | ); 17 | }; 18 | 19 | const ChartCard = ({ data }: { data: Record }) => { 20 | const [extended, setExtended] = React.useState(false); 21 | 22 | const keys = Object.keys(data) 23 | .sort((a, b) => data[b] - data[a]) 24 | .slice(0, extended ? 50 : 10); 25 | 26 | const total = Object.values(data).reduce((acc, curr) => acc + curr, 0); 27 | 28 | return ( 29 |
30 | {keys.map((key) => ChartLine(key, data[key], data[keys[0]], total))} 31 | 40 |
41 | ); 42 | }; 43 | 44 | export default ChartCard; 45 | -------------------------------------------------------------------------------- /projects/shared/src/dropdown/useDropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import DropdownMenu from "./dropdown"; 3 | 4 | interface OptionProps { 5 | id: string; 6 | name: string; 7 | } 8 | 9 | // don't know why type inference doesn't work when consuming this hook 10 | type ReturnType = [ 11 | dropdown: React.JSX.Element, 12 | activeOption: OptionProps, 13 | setActiveOption: React.Dispatch>, 14 | setAvailableOptions: React.Dispatch> 15 | ]; 16 | 17 | const useDropdownMenu = (options: OptionProps[], storageVariable?: string) => { 18 | const initialOptionID = storageVariable && Spicetify.LocalStorage.get(`${storageVariable}:active-option`); 19 | const initialOption = initialOptionID && options.find((e) => e.id === initialOptionID); 20 | const [activeOption, setActiveOption] = useState(initialOption || options[0]); 21 | const [availableOptions, setAvailableOptions] = useState(options); 22 | const dropdown = ( 23 | { 27 | setActiveOption(option); 28 | if (storageVariable) Spicetify.LocalStorage.set(`${storageVariable}:active-option`, option.id); 29 | }} 30 | /> 31 | ); 32 | 33 | return [dropdown, activeOption, setActiveOption, setAvailableOptions] as ReturnType; 34 | }; 35 | 36 | export default useDropdownMenu; 37 | -------------------------------------------------------------------------------- /projects/library/src/components/nav_context.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface Navigation { 4 | current: string; 5 | navigate: (route: string) => void; 6 | goBack: () => void; 7 | getParam: () => string | undefined; 8 | }; 9 | 10 | export const NavigationContext = React.createContext(null); 11 | 12 | export const NavigationProvider = ({children }: { children: React.ReactNode }) => { 13 | const [current, setCurrent] = React.useState(undefined!); 14 | const [history, setHistory] = React.useState([]); 15 | 16 | const navigate = React.useCallback((newRoute: string) => { 17 | setHistory((prev) => (newRoute !== current ? [...prev, current] : prev)); 18 | if (newRoute !== current) setCurrent(newRoute); 19 | }, [current]); 20 | 21 | const goBack = React.useCallback(() => { 22 | setHistory((prev) => { 23 | if (prev.length === 0) return prev; 24 | const newHistory = [...prev]; 25 | const last = newHistory.pop()!; 26 | setCurrent(last); 27 | return newHistory; 28 | }); 29 | }, []); 30 | 31 | const getParam = React.useCallback(() => { 32 | const [, param] = current.split("/") || []; 33 | return param; 34 | }, [current]); 35 | 36 | return ( 37 | 38 | {children} 39 | 40 | ); 41 | }; 42 | 43 | export const useNavigation = () => { 44 | const ctx = React.useContext(NavigationContext); 45 | return ctx as Navigation; 46 | }; -------------------------------------------------------------------------------- /projects/stats/src/components/buttons/create_playlist_button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { InfoToCreatePlaylist } from "../../types/stats_types"; 4 | 5 | interface CreatePlaylistButtonProps { 6 | infoToCreatePlaylist: InfoToCreatePlaylist; 7 | } 8 | 9 | async function createPlaylistAsync(infoToCreatePlaylist: InfoToCreatePlaylist): Promise { 10 | const { Platform, showNotification } = Spicetify; 11 | const { RootlistAPI, PlaylistAPI } = Platform; 12 | 13 | try { 14 | const { playlistName, itemsUris } = infoToCreatePlaylist; 15 | const playlistUri = await RootlistAPI.createPlaylist(playlistName, { before: "start" }); 16 | await PlaylistAPI.add(playlistUri, itemsUris, { before: "start" }); 17 | } catch (error) { 18 | console.error(error); 19 | showNotification("Failed to create playlist", true, 1000); 20 | } 21 | } 22 | 23 | function CreatePlaylistButton(props: CreatePlaylistButtonProps): React.ReactElement { 24 | const { TooltipWrapper, ButtonSecondary } = Spicetify.ReactComponent; 25 | const { infoToCreatePlaylist } = props; 26 | 27 | return ( 28 | 29 | createPlaylistAsync(infoToCreatePlaylist)} 35 | className="stats-make-playlist-button" 36 | /> 37 | 38 | ); 39 | } 40 | 41 | export default CreatePlaylistButton; 42 | -------------------------------------------------------------------------------- /projects/shared/src/icons/arrows.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const LeftArrow = () => { 4 | const { IconComponent } = Spicetify.ReactComponent; 5 | 6 | return ( 7 | ', 12 | }} 13 | iconSize={16} 14 | /> 15 | ); 16 | }; 17 | 18 | export const UpArrow = () => { 19 | const { IconComponent } = Spicetify.ReactComponent; 20 | 21 | return ( 22 | ', 27 | }} 28 | iconSize={16} 29 | /> 30 | ); 31 | }; 32 | 33 | export const DownArrow = () => { 34 | const { IconComponent } = Spicetify.ReactComponent; 35 | 36 | return ( 37 | ', 42 | }} 43 | iconSize={16} 44 | /> 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /projects/shared/src/dropdown/useSortDropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import SortDropdownMenu from "./sort_dropdown"; 3 | 4 | interface OptionProps { 5 | id: string; 6 | name: string; 7 | } 8 | 9 | // don't know why type inference doesn't work when consuming this hook 10 | type ReturnType = [ 11 | dropdown: React.JSX.Element, 12 | activeOption: OptionProps, 13 | isReversed: boolean, 14 | setActiveOption: React.Dispatch>, 15 | setAvailableOptions: React.Dispatch>, 16 | ]; 17 | 18 | const useSortDropdownMenu = (options: OptionProps[], storageVariable?: string) => { 19 | const initialOptionID = storageVariable && Spicetify.LocalStorage.get(`${storageVariable}:active-option`); 20 | const initialOption = initialOptionID && options.find((e) => e.id === initialOptionID); 21 | const [activeOption, setActiveOption] = useState(initialOption || options[0]); 22 | const [isReversed, setIsReversed] = useState(false); 23 | const [availableOptions, setAvailableOptions] = useState(options); 24 | const dropdown = ( 25 | { 30 | setIsReversed((prev) => option.id === activeOption.id ? !prev : prev); 31 | setActiveOption(option); 32 | if (storageVariable) Spicetify.LocalStorage.set(`${storageVariable}:active-option`, option.id); 33 | }} 34 | /> 35 | ); 36 | 37 | return [dropdown, activeOption, isReversed, setActiveOption, setAvailableOptions] as ReturnType; 38 | }; 39 | 40 | export default useSortDropdownMenu; -------------------------------------------------------------------------------- /projects/library/src/components/searchbar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface SearchBarProps { 4 | setSearch: (value: string) => void; 5 | placeholder?: string; 6 | } 7 | 8 | const searchIconPath = ``; 9 | 10 | const SearchBar = (props: SearchBarProps) => { 11 | const { setSearch, placeholder } = props; 12 | const { IconComponent } = Spicetify.ReactComponent; 13 | 14 | const handleChange = (e: React.ChangeEvent) => { 15 | setSearch(e.target.value); 16 | }; 17 | 18 | const searchIcon = 22 | 23 | return ( 24 |
25 | 37 |
38 | 39 | {searchIcon} 40 | 41 |
42 | 45 |
46 | ); 47 | }; 48 | 49 | export default SearchBar; 50 | -------------------------------------------------------------------------------- /projects/stats/install.ps1: -------------------------------------------------------------------------------- 1 | # Define variables 2 | $customAppsDir = "$env:APPDATA\spicetify\CustomApps" 3 | $statsAppDir = "$customAppsDir\stats" 4 | $repo = "harbassan/spicetify-apps" 5 | $zipFile = "$env:TEMP\spicetify-stats.zip" 6 | $tempDir = "$env:TEMP\spicetify-stats" 7 | 8 | # Create CustomApps directory if it doesn't exist 9 | If (!(Test-Path -Path $customAppsDir)) { 10 | New-Item -ItemType Directory -Path $customAppsDir 11 | } 12 | 13 | # Get the latest STATS release download URL 14 | $latestRelease = (Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases") | Where-Object { 15 | $_.tag_name -match "stats-v[0-9]+\.[0-9]+\.[0-9]+" 16 | } | Select-Object -First 1; 17 | $latestReleaseDownloadUrl = (Invoke-RestMethod -Uri $latestRelease.url).assets[0].browser_download_url 18 | 19 | # Download the zip file 20 | Invoke-WebRequest -Uri $latestReleaseDownloadUrl -OutFile $zipFile 21 | 22 | # Unzip the file 23 | Expand-Archive -Path $zipFile -DestinationPath $tempDir -Force 24 | 25 | # Move the unzipped folder to the correct location 26 | if (Test-Path -Path "$statsAppDir\*") { 27 | Remove-Item -Path "$statsAppDir\*" -Recurse -Force 28 | Write-Host "warning " -ForegroundColor DarkYellow -NoNewline 29 | Write-Host "`"$statsAppDir`" Pre-existing file/s were found and deleted." 30 | } 31 | 32 | Move-Item -Path "$tempDir\*" -Destination $statsAppDir -Force 33 | 34 | # Apply Spicetify configuration 35 | spicetify config custom_apps stats 36 | spicetify apply 37 | 38 | # Clean up 39 | Remove-Item -Path $zipFile, $tempDir -Recurse -Force 40 | 41 | Write-Host "success " -ForegroundColor DarkGreen -NoNewline 42 | Write-Host "Installation complete. Enjoy your new stats app!" 43 | -------------------------------------------------------------------------------- /projects/library/README.md: -------------------------------------------------------------------------------- 1 | # Spicetify Library 2 | 3 | ### A custom app that makes managing your library a better experience. 4 | 5 | --- 6 | 7 | ### Album Collections 8 | 9 | ![preview](previews/albums.png) 10 | 11 | --- 12 | 13 | ### Playlist Folder Images 14 | 15 | ![preview](previews/playlists.png) 16 | 17 | --- 18 | 19 | ### Manual Installation 20 | 21 | Download the zip file in the [latest release](https://github.com/harbassan/spicetify-apps/releases?q=library&expanded=true), rename the unzipped folder to `library`, then place that folder into your `CustomApps` folder in the `spicetify` directory and you're all done. If everything's correct, the structure should be similar to this: 22 | 23 | ``` 24 | 📦spicetify\CustomApps 25 | ┣ 📂marketplace 26 | ┣ etc... 27 | ┗ 📂library 28 | ┃ ┣ 📜extension.js 29 | ┃ ┣ 📜index.js 30 | ┃ ┣ 📜manifest.json 31 | ┃ ┗ 📜style.css 32 | ``` 33 | 34 | Finally, run these commands to apply: 35 | 36 | ```powershell 37 | spicetify config custom_apps library 38 | spicetify apply 39 | ``` 40 | 41 | That's it. Enjoy. 42 | 43 | For more help on installing visit the [Spicetify Docs](https://spicetify.app/docs/advanced-usage/custom-apps#installing). 44 | 45 | ### Uninstallation 46 | 47 | To uninstall the app, run these commands: 48 | 49 | ```powershell 50 | spicetify config custom_apps library- 51 | spicetify apply 52 | ``` 53 | 54 | If you want to remove the app completely, just delete the `library` folder after running the above commands. 55 | 56 | --- 57 | 58 | If you have any questions or issues regarding the app, open an issue on this repo. While doing so, please specify your spicetify version and installation method. 59 | 60 | If you like the app, I'd be really grateful if you liked the repo ❤️. 61 | -------------------------------------------------------------------------------- /projects/library/src/components/folder_menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import LeadingIcon from "./leading_icon"; 3 | import TextInputDialog from "./text_input_dialog"; 4 | 5 | const editIconPath = 6 | ''; 7 | 8 | const deleteIconPath = 9 | ''; 10 | 11 | const FolderMenu = ({ uri }: { uri: string }) => { 12 | const { MenuItem, Menu } = Spicetify.ReactComponent; 13 | 14 | const image = FolderImageWrapper.getFolderImage(uri); 15 | 16 | const setImage = () => { 17 | const setNewImage = (newUrl: string) => { 18 | FolderImageWrapper.setFolderImage({ uri, url: newUrl }); 19 | }; 20 | 21 | Spicetify.PopupModal.display({ 22 | title: "Set Folder Image", 23 | content: , 24 | }); 25 | }; 26 | 27 | const removeImage = () => { 28 | FolderImageWrapper.removeFolderImage(uri); 29 | }; 30 | 31 | return ( 32 | 33 | } onClick={setImage}> 34 | Set Folder Image 35 | 36 | {image && ( 37 | } onClick={removeImage}> 38 | Remove Folder Image 39 | 40 | )} 41 | 42 | ); 43 | }; 44 | 45 | export default FolderMenu; 46 | -------------------------------------------------------------------------------- /projects/stats/src/types/stats_types.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | var SpicetifyStats: any; 3 | } 4 | 5 | export interface Config { 6 | "api-key": string | null; 7 | "lastfm-user": string | null; 8 | "use-lastfm": boolean; 9 | "show-artists": boolean; 10 | "show-tracks": boolean; 11 | "show-genres": boolean; 12 | "show-library": boolean; 13 | "show-charts": boolean; 14 | } 15 | 16 | export interface ConfigWrapper { 17 | config: Config; 18 | launchModal: () => void; 19 | } 20 | 21 | export interface InfoToCreatePlaylist { 22 | playlistName: string; 23 | itemsUris: string[]; 24 | } 25 | 26 | export interface LastFMMinifiedArtist { 27 | name: string; 28 | playcount: number; 29 | uri: string; 30 | image: undefined; 31 | type: "lastfm"; 32 | } 33 | 34 | export interface SpotifyMinifiedArtist { 35 | name: string; 36 | uri: string; 37 | id: string; 38 | image?: string; 39 | genres: string[]; 40 | playcount?: number; 41 | type: "spotify"; 42 | } 43 | 44 | export interface LastFMMinifiedAlbum extends LastFMMinifiedArtist {} 45 | export interface SpotifyMinifiedAlbum extends Omit {} 46 | 47 | export interface SpotifyMinifiedTrack { 48 | id: string; 49 | uri: string; 50 | name: string; 51 | duration_ms: number; 52 | popularity: number; 53 | playcount?: number; 54 | explicit: boolean; 55 | image?: string; 56 | artists: { 57 | name: string; 58 | uri: string; 59 | }[]; 60 | album: { 61 | name: string; 62 | uri: string; 63 | release_date: string; 64 | }; 65 | type: "spotify"; 66 | } 67 | 68 | export interface LastFMMinifiedTrack { 69 | name: string; 70 | uri: string; 71 | playcount: number; 72 | duration_ms: number; 73 | artists: { 74 | name: string; 75 | uri: string; 76 | }[]; 77 | type: "lastfm"; 78 | } 79 | -------------------------------------------------------------------------------- /projects/stats/src/components/inline_grid.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface InlineGridProps { 4 | special?: boolean; 5 | children: React.ReactElement | React.ReactElement[]; 6 | } 7 | 8 | function scrollGrid(event: React.MouseEvent): void { 9 | const { target } = event; 10 | if (!(target instanceof HTMLElement)) return; 11 | 12 | const grid = target.parentNode?.querySelector("div"); 13 | if (!grid) return; 14 | grid.scrollLeft += grid.clientWidth; 15 | 16 | if (grid.scrollWidth - grid.clientWidth - grid.scrollLeft <= grid.clientWidth) { 17 | grid.setAttribute("data-scroll", "end"); 18 | } else { 19 | grid.setAttribute("data-scroll", "both"); 20 | } 21 | } 22 | 23 | function scrollGridLeft(event: React.MouseEvent): void { 24 | const { target } = event; 25 | if (!(target instanceof HTMLElement)) return; 26 | 27 | const grid = target.parentNode?.querySelector("div"); 28 | if (!grid) return; 29 | grid.scrollLeft -= grid.clientWidth; 30 | 31 | if (grid.scrollLeft <= grid.clientWidth) { 32 | grid.setAttribute("data-scroll", "start"); 33 | } else { 34 | grid.setAttribute("data-scroll", "both"); 35 | } 36 | } 37 | 38 | function InlineGrid(props: InlineGridProps): React.ReactElement { 39 | const { children, special } = props; 40 | return ( 41 |
42 | 45 | 48 |
52 | {children} 53 |
54 |
55 | ); 56 | } 57 | 58 | export default React.memo(InlineGrid); 59 | -------------------------------------------------------------------------------- /projects/shared/src/status/status.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const ErrorIcon = () => { 4 | return ( 5 | 9 | ); 10 | }; 11 | 12 | const LibraryIcon = () => { 13 | return ( 14 | 25 | ); 26 | }; 27 | 28 | const Status = (props: { icon: "error" | "library"; heading: string; subheading: string }) => { 29 | const [isVisible, setIsVisible] = React.useState(false); 30 | 31 | React.useEffect(() => { 32 | const to = setTimeout(() => { 33 | setIsVisible(true); 34 | }, 500); 35 | return () => clearTimeout(to); 36 | }, []); 37 | 38 | return isVisible ? ( 39 | <> 40 |
41 | {props.icon === "error" ? : } 42 |

{props.heading}

43 |

{props.subheading}

44 |
45 | 46 | ) : ( 47 | <> 48 | ); 49 | }; 50 | 51 | export default Status; 52 | -------------------------------------------------------------------------------- /projects/stats/src/types/lastfm.ts: -------------------------------------------------------------------------------- 1 | export interface TopTracksResponse { 2 | toptracks: TopTracks; 3 | } 4 | 5 | export interface TopArtistsResponse { 6 | topartists: TopArtists; 7 | } 8 | 9 | export interface TopAlbumsResponse { 10 | topalbums: TopAlbums; 11 | } 12 | 13 | export interface ArtistChartResponse { 14 | artists: TopArtists; 15 | } 16 | 17 | export interface TrackChartResponse { 18 | tracks: TopTracks; 19 | } 20 | 21 | interface ResponseAttr { 22 | user?: string; 23 | totalPages: string; 24 | page: string; 25 | perPage: string; 26 | total: string; 27 | } 28 | 29 | interface TopTracks { 30 | track: Track[]; 31 | "@attr": ResponseAttr; 32 | } 33 | 34 | interface TopArtists { 35 | artist: Artist[]; 36 | "@attr": ResponseAttr; 37 | } 38 | 39 | interface TopAlbums { 40 | album: Album[]; 41 | "@attr": ResponseAttr; 42 | } 43 | 44 | export interface Track { 45 | streamable: Streamable; 46 | mbid: string; 47 | name: string; 48 | image: Image[]; 49 | artist: ArtistSimplified; 50 | url: string; 51 | duration: string; 52 | "@attr": ItemAttr; 53 | playcount: string; 54 | } 55 | 56 | interface ArtistSimplified { 57 | url: string; 58 | name: string; 59 | mbid: string; 60 | } 61 | 62 | export interface Artist extends ArtistSimplified { 63 | streamable: string; 64 | image: Image[]; 65 | playcount: string; 66 | "@attr": ItemAttr; 67 | } 68 | 69 | export interface Album { 70 | artist: ArtistSimplified; 71 | image: Image[]; 72 | mbid: string; 73 | url: string; 74 | playcount: string; 75 | "@attr": ItemAttr; 76 | name: string; 77 | } 78 | 79 | interface ItemAttr { 80 | rank: string; 81 | } 82 | 83 | interface Image { 84 | size: Size; 85 | "#text": string; 86 | } 87 | 88 | enum Size { 89 | Extralarge = "extralarge", 90 | Large = "large", 91 | Medium = "medium", 92 | Small = "small", 93 | } 94 | 95 | interface Streamable { 96 | fulltrack: string; 97 | "#text": string; 98 | } 99 | -------------------------------------------------------------------------------- /projects/shared/src/components/settings_button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ConfigWrapperProps } from "@shared/config/config_types"; 3 | 4 | interface SettingsButtonProps { 5 | configWrapper: ConfigWrapperProps; 6 | } 7 | 8 | function SettingsIcon(): React.ReactElement { 9 | return ( 10 | ', 14 | }} 15 | iconSize={16} 16 | /> 17 | ); 18 | } 19 | 20 | function SettingsButton(props: SettingsButtonProps): React.ReactElement { 21 | const { TooltipWrapper, ButtonTertiary } = Spicetify.ReactComponent; 22 | const { configWrapper } = props; 23 | 24 | return ( 25 | 26 | 32 | 33 | ); 34 | } 35 | 36 | export default SettingsButton; 37 | -------------------------------------------------------------------------------- /projects/library/src/styles/app.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --library-card-size: 180px; 3 | --library-searchbar-size: 200px; 4 | } 5 | 6 | #library-app { 7 | .header-right .x-filterBox-expandedOrHasFilter .x-filterBox-filterInput { 8 | width: var(--library-searchbar-size); 9 | } 10 | 11 | .grid { 12 | grid-template-columns: repeat(auto-fill, minmax(var(--library-card-size), 1fr)) !important; 13 | } 14 | 15 | .main-card-cardContainer { 16 | width: 100%; 17 | height: 100%; 18 | } 19 | 20 | .load-more-card { 21 | div:nth-child(2) { 22 | text-align: center; 23 | font-size: var(--encore-text-size-base); 24 | } 25 | 26 | div:first-child { 27 | fill: var(--text-subdued); 28 | width: 80%; 29 | margin: 0 auto; 30 | } 31 | 32 | &:hover { 33 | cursor: pointer; 34 | } 35 | 36 | display: flex; 37 | gap: 10px; 38 | flex-direction: column; 39 | justify-content: center; 40 | } 41 | 42 | .x-filterBox-searchIcon { 43 | --encore-icon-height: var(--encore-graphic-size-decorative-smaller); 44 | --encore-icon-width: var(--encore-graphic-size-decorative-smaller); 45 | } 46 | 47 | } 48 | 49 | .text-input-form { 50 | display: flex; 51 | flex-direction: column; 52 | gap: 18px; 53 | 54 | .text-input { 55 | background: rgba(var(--spice-rgb-selected-row), .1); 56 | border: 1px solid transparent; 57 | border-radius: 4px; 58 | color: var(--spice-text); 59 | font-family: inherit; 60 | font-size: 14px; 61 | height: 32px; 62 | padding: 0 12px; 63 | width: 100%; 64 | 65 | &:focus { 66 | background-color: var(--spice-tab-active); 67 | border: 1px solid var(--spice-button-disabled); 68 | outline: none; 69 | } 70 | } 71 | 72 | button { 73 | align-self: end; 74 | } 75 | } -------------------------------------------------------------------------------- /projects/shared/src/config/config_modal.scss: -------------------------------------------------------------------------------- 1 | .config-container { 2 | gap: 10px; 3 | display: flex; 4 | flex-direction: column; 5 | 6 | .section-header { 7 | box-sizing: border-box; 8 | -webkit-tap-highlight-color: transparent; 9 | margin-block: 0px; 10 | font-size: 1.125rem; 11 | font-weight: 700; 12 | color: var(--spice-text); 13 | } 14 | 15 | .col.description { 16 | box-sizing: border-box; 17 | -webkit-tap-highlight-color: transparent; 18 | margin-block: 0px; 19 | font-size: 0.875rem; 20 | font-weight: 400; 21 | color: var(--spice-subtext); 22 | } 23 | 24 | 25 | // Hide disabled toggle rows (i.e. the "Extensions" one) 26 | .disabled { 27 | opacity: 0; 28 | pointer-events: none; 29 | 30 | } 31 | 32 | .text-input { 33 | background: rgba(var(--spice-rgb-selected-row),.1); 34 | border: 1px solid transparent; 35 | border-radius: 4px; 36 | color: var(--spice-text); 37 | font-family: inherit; 38 | font-size: 14px; 39 | height: 32px; 40 | padding: 0 12px; 41 | width: 100%; 42 | 43 | &:focus { 44 | background-color: var(--spice-tab-active); 45 | border: 1px solid var(--spice-button-disabled); 46 | outline: none; 47 | } 48 | } 49 | 50 | .dropdown-input { 51 | background-color: var(--spice-tab-active); 52 | border: 0; 53 | border-radius: 4px; 54 | color: rgba(var(--spice-rgb-selected-row),.7); 55 | font-size: 14px; 56 | font-weight: 400; 57 | height: 32px; 58 | letter-spacing: .24px; 59 | line-height: 20px; 60 | padding: 0 32px 0 12px; 61 | width: 100%; 62 | } 63 | 64 | .tooltip-icon { 65 | float: right; 66 | margin-left: 10px; 67 | display: flex; 68 | align-items: center; 69 | height: 22px; 70 | fill: var(--spice-subtext); 71 | 72 | &:hover { 73 | fill: var(--spice-text); 74 | } 75 | } 76 | 77 | .tooltip { 78 | text-align: center; 79 | } 80 | 81 | .setting-row { 82 | display: flex; 83 | justify-content: space-between; 84 | height: 25px; 85 | } 86 | 87 | .playback-progressbar { 88 | width: 200px; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spicetify Apps 2 | 3 | ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/harbassan/spicetify-apps/total?style=for-the-badge) 4 | ![GitHub Downloads (stats, all releases)](https://img.shields.io/github/downloads/harbassan/spicetify-apps/spicetify-stats.release.zip?displayAssetName=false&style=for-the-badge&label=stats%20dls&color=red) 5 | ![GitHub Downloads (library, all releases)](https://img.shields.io/github/downloads/harbassan/spicetify-apps/spicetify-library.release.zip?displayAssetName=false&style=for-the-badge&label=library%20dls&color=blue) 6 | 7 | --- 8 | 9 | ## [Statistics](projects/stats/README.md) 10 | 11 | | Top Artists | Library Analysis | Top Tracks | | 12 | | :----------------------------------------: | :---------------------------------------------: | :---------------------------------------: | :-----------: | 13 | | ![Image 1](projects/stats/previews/top_artists.png) | ![Image 2](projects/stats/previews/library_analysis.png) | ![Image 3](projects/stats/previews/top_tracks.png) | And Much More | 14 | 15 | 16 | --- 17 | 18 | ## [Library](projects/library/README.md) 19 | 20 | | Full Pages | Album Collections | Folder Images | 21 | | :----------------------------------------: | :---------------------------------------------: | :---------------------------------------: | 22 | | ![Image 1](projects/library/previews/artists.png) | ![Image 2](projects/library/previews/albums.png) | ![Image 3](projects/library/previews/playlists.png) | 23 | 24 | 25 | --- 26 | 27 | The installation instructions are the same for both apps, and can be found in either's readme. 28 | 29 | If you have any questions or issues regarding the apps open an issue on this repo. Please specify your spicetify version, and the app you're reffering to, e.g 30 | 31 | [Statistics] Artist cards not rendering ... 32 | 33 | If you really like the apps i'd be grateful if you liked the repo ❤️. 34 | -------------------------------------------------------------------------------- /projects/shared/src/config/config_wrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ConfigModal from "./config_modal"; 3 | import { ConfigProps, ModalStructureProps } from "./config_types"; 4 | 5 | // works with both extensions and custom apps 6 | 7 | class ConfigWrapper { 8 | Config: ConfigProps; 9 | launchModal: (callback?: (config: ConfigProps) => void) => void; 10 | 11 | constructor(modalStructure: ModalStructureProps, key: string) { 12 | const config = modalStructure.map((modalStructureRow) => { 13 | const value = ConfigWrapper.getLocalStorageDataFromKey( 14 | `${key}:config:${modalStructureRow.key}`, 15 | modalStructureRow.def 16 | ); 17 | modalStructureRow.callback?.(value); 18 | return { [modalStructureRow.key]: value }; 19 | }); 20 | 21 | this.Config = Object.assign({}, ...config); 22 | 23 | this.launchModal = (callback) => { 24 | const updateConfig = (config: ConfigProps) => { 25 | this.Config = { ...config }; 26 | callback?.(config); 27 | }; 28 | 29 | Spicetify.PopupModal.display({ 30 | title: `${key.charAt(0).toUpperCase() + key.slice(1)} Settings`, 31 | // @ts-ignore 32 | content: ( 33 | 39 | ), 40 | isLarge: true, 41 | }); 42 | }; 43 | } 44 | 45 | static getLocalStorageDataFromKey = (key: string, fallback?: any) => { 46 | const data = localStorage.getItem(key); 47 | if (data) { 48 | try { 49 | return JSON.parse(data); 50 | } catch (err) { 51 | return data; 52 | } 53 | } else { 54 | return fallback; 55 | } 56 | }; 57 | } 58 | 59 | export default ConfigWrapper; 60 | -------------------------------------------------------------------------------- /projects/stats/src/api/lastfm.ts: -------------------------------------------------------------------------------- 1 | import type * as LastFM from "../types/lastfm"; 2 | import { apiFetch } from "./spotify"; 3 | 4 | const lfmperiods = { 5 | extra_short_term: "7day", 6 | short_term: "1month", 7 | medium_term: "6month", 8 | long_term: "overall", 9 | } as const; 10 | 11 | const val = (res: T | undefined) => { 12 | if (!res || (Array.isArray(res) && !res.length)) throw new Error("Lastfm returned an empty result. Try again later."); 13 | return res; 14 | }; 15 | 16 | export const getTopTracks = (key: string, user: string, range: keyof typeof lfmperiods) => { 17 | const url = `http://ws.audioscrobbler.com/2.0/?method=user.gettoptracks&user=${user}&api_key=${key}&limit=100&format=json&period=${lfmperiods[range]}`; 18 | return apiFetch("lfmTopTracks", url).then((res) => val(res?.toptracks?.track)); 19 | }; 20 | 21 | export const getTopArtists = (key: string, user: string, range: keyof typeof lfmperiods) => { 22 | const url = `http://ws.audioscrobbler.com/2.0/?method=user.gettopartists&user=${user}&api_key=${key}&limit=100&format=json&period=${lfmperiods[range]}`; 23 | return apiFetch("lfmTopArtists", url).then((res) => val(res?.topartists?.artist)); 24 | }; 25 | 26 | export const getTopAlbums = (key: string, user: string, range: keyof typeof lfmperiods) => { 27 | const url = `http://ws.audioscrobbler.com/2.0/?method=user.gettopalbums&user=${user}&api_key=${key}&limit=100&format=json&period=${lfmperiods[range]}`; 28 | return apiFetch("lfmTopAlbums", url).then((res) => val(res?.topalbums?.album)); 29 | }; 30 | 31 | export const getArtistChart = (key: string) => { 32 | const url = `http://ws.audioscrobbler.com/2.0/?method=chart.gettopartists&api_key=${key}&format=json`; 33 | return apiFetch("lfmArtistChart", url).then((res) => val(res?.artists?.artist)); 34 | }; 35 | 36 | export const getTrackChart = (key: string) => { 37 | const url = `http://ws.audioscrobbler.com/2.0/?method=chart.gettoptracks&api_key=${key}&format=json`; 38 | return apiFetch("lfmTrackChart", url).then((res) => val(res?.tracks?.track)); 39 | }; 40 | -------------------------------------------------------------------------------- /projects/library/src/types/platform.ts: -------------------------------------------------------------------------------- 1 | export interface GetContentsResponse { 2 | primaryFilter: string; 3 | passedFilterIds: string[]; 4 | availableFilters: SelectedSortOrder[]; 5 | selectedFilters: SelectedSortOrder[]; 6 | availableSortOrders: SelectedSortOrder[]; 7 | selectedSortOrder: SelectedSortOrder; 8 | limit: number; 9 | offset: number; 10 | items: T[]; 11 | unfilteredTotalLength: number; 12 | totalLength: number; 13 | hasUnfilteredItems: boolean; 14 | hasTextFilter: boolean; 15 | reorderAllowed: boolean; 16 | openedFolderName: string; 17 | parentFolderUri: string; 18 | openedFolderIsPlayable: boolean; 19 | } 20 | 21 | export interface SelectedSortOrder { 22 | id: string; 23 | name: string; 24 | } 25 | 26 | export interface Item { 27 | uri: string; 28 | name: string; 29 | images?: Image[]; 30 | pinned: boolean; 31 | addedAt: Date; 32 | lastPlayedAt: Date | null; 33 | canPin: number; 34 | } 35 | 36 | export interface ArtistItem extends Item { 37 | type: "artist"; 38 | } 39 | 40 | export interface AlbumItem extends Item { 41 | type: "album"; 42 | artists: Artist[]; 43 | isPremiumOnly: boolean; 44 | } 45 | 46 | export interface PlaylistItem extends Item { 47 | type: "playlist"; 48 | canReorder: boolean; 49 | isEmpty: boolean; 50 | owner: Owner; 51 | isOwnedBySelf: boolean; 52 | isLoading: boolean; 53 | canAddTo: boolean; 54 | isBooklist: boolean; 55 | } 56 | 57 | export interface FolderItem extends Item { 58 | type: "folder"; 59 | rowId: string; 60 | canReorder: boolean; 61 | isEmpty: boolean; 62 | numberOfFolders: number; 63 | numberOfPlaylists: number; 64 | isFlattened: boolean; 65 | } 66 | 67 | export interface ShowItem extends Item { 68 | type: "show"; 69 | publisher: string; 70 | } 71 | 72 | export interface Artist { 73 | type: "artist"; 74 | name: string; 75 | uri: string; 76 | } 77 | 78 | export interface Image { 79 | url: string; 80 | width: number; 81 | height: number; 82 | } 83 | 84 | export interface Owner { 85 | type: "user"; 86 | name: string; 87 | uri: string; 88 | id: string; 89 | username: string; 90 | images: Image[]; 91 | } 92 | 93 | export interface UpdateEvent { 94 | data: { list: string }; 95 | defaultPrevented: boolean; 96 | immediateStopped: boolean; 97 | stopped: boolean; 98 | type: "update"; 99 | } 100 | -------------------------------------------------------------------------------- /projects/shared/src/components/spotify_card.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface SpotifyCardProps { 4 | type: "artist" | "album" | "lastfm" | "playlist"; 5 | uri: string; 6 | header: string; 7 | subheader: string; 8 | imageUrl?: string; 9 | artistUri?: string; 10 | badge?: string | React.ReactElement; 11 | provider?: "spotify" | "lastfm"; 12 | } 13 | 14 | /** 15 | * renders a Spotify card component with contextual menu support 16 | * - for "lastfm" provider, the card opens external links instead of navigating within Spotify 17 | * - right-click triggers a context menu with type-specific actions (ArtistMenu, AlbumMenu, or PlaylistMenu) 18 | */ 19 | function SpotifyCard(props: SpotifyCardProps): React.ReactElement { 20 | // @ts-ignore 21 | const { Cards, TextComponent, ArtistMenu, AlbumMenu, PlaylistMenu, ContextMenu } = Spicetify.ReactComponent; 22 | const { FeatureCard: Card, CardImage } = Cards; 23 | const { type, header, uri, imageUrl, subheader, artistUri, badge, provider = "spotify" } = props; 24 | 25 | const Menu = () => { 26 | switch (type) { 27 | case "artist": 28 | return ; 29 | case "album": 30 | return ; 31 | case "playlist": 32 | return ; 33 | default: 34 | return <>; 35 | } 36 | }; 37 | 38 | let lastfmProps = {}; 39 | 40 | if (provider === "lastfm") { 41 | lastfmProps = { 42 | onClick: () => window.open(uri, "_blank"), 43 | isPlayable: false, 44 | delegateNavigation: true, 45 | }; 46 | }; 47 | 48 | return ( 49 | 50 |
51 | ( 55 | 65 | )} 66 | renderSubHeaderContent={() => ( 67 | 68 | {subheader} 69 | 70 | )} 71 | uri={uri} 72 | {...lastfmProps} 73 | /> 74 | {badge &&
{badge}
} 75 |
76 |
77 | ); 78 | } 79 | 80 | export default SpotifyCard; 81 | -------------------------------------------------------------------------------- /projects/shared/src/shared.scss: -------------------------------------------------------------------------------- 1 | .grid { 2 | --grid-gap: 24px; 3 | grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)) !important; 4 | } 5 | 6 | .loadingWrapper { 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | min-height: 60vh; 11 | flex-direction: column; 12 | gap: 16px; 13 | 14 | .status-icon { 15 | width: 40px; 16 | height: 40px; 17 | fill: currentColor; 18 | } 19 | } 20 | 21 | .page-content { 22 | display: flex; 23 | flex-direction: column; 24 | gap: 24px; 25 | } 26 | 27 | .badge { 28 | position: absolute; 29 | top: 3%; 30 | left: 3%; 31 | height: 30px; 32 | width: 30px; 33 | border-radius: 50%; 34 | background-color: rgb(65, 110, 170); 35 | color: white; 36 | display: flex; 37 | align-items: center; 38 | justify-content: center; 39 | font-size: 12px; 40 | } 41 | 42 | .page-header { 43 | align-content: space-between; 44 | align-items: center; 45 | display: flex; 46 | justify-content: space-between; 47 | margin: 16px 0; 48 | 49 | .header-right, 50 | .header-left { 51 | display: flex; 52 | align-items: center; 53 | gap: 8px; 54 | } 55 | 56 | .header-right { 57 | justify-content: flex-end; 58 | } 59 | 60 | .header-left { 61 | justify-content: flex-start; 62 | } 63 | } 64 | 65 | .new-update { 66 | background-color: var(--spice-player); 67 | color: var(--spice-text); 68 | border-radius: 8px; 69 | padding: 2px 12px; 70 | margin: 0 24px; 71 | border: 0px; 72 | } 73 | 74 | .x-sortBox-sortDropdown { 75 | fill: rgba(var(--spice-rgb-selected-row), .7); 76 | 77 | &>span { 78 | display: flex; 79 | align-items: center; 80 | gap: 10px; 81 | 82 | &>svg { 83 | width: 14px; 84 | height: 14px; 85 | } 86 | } 87 | } 88 | 89 | // make sure the popup modal close button is always clickable 90 | .main-trackCreditsModal-closeBtn { 91 | -webkit-app-region: no-drag; 92 | } 93 | 94 | .navbar-container { 95 | padding-inline: 40px; 96 | margin-inline: auto; 97 | display: flex; 98 | align-items: center; 99 | height: 56px; 100 | padding: 0 var(--content-spacing); 101 | } 102 | 103 | #library-app > :first-child, #stats-app > :first-child { 104 | margin-top: 64px; 105 | } 106 | -------------------------------------------------------------------------------- /projects/stats/src/pages/top_albums.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import useDropdownMenu from "@shared/dropdown/useDropdownMenu"; 3 | import SpotifyCard from "@shared/components/spotify_card"; 4 | import PageContainer from "@shared/components/page_container"; 5 | import type { Config, ConfigWrapper } from "../types/stats_types"; 6 | import RefreshButton from "../components/buttons/refresh_button"; 7 | import SettingsButton from "@shared/components/settings_button"; 8 | import type { SpotifyRange } from "../types/spotify"; 9 | import * as lastFM from "../api/lastfm"; 10 | import { convertAlbum } from "../utils/converter"; 11 | import { useQuery } from "@shared/types/react_query"; 12 | import useStatus from "@shared/status/useStatus"; 13 | import { DropdownOptions } from "./top_artists"; 14 | import { cacher, invalidator } from "../extensions/cache"; 15 | 16 | export const getTopAlbums = async (timeRange: SpotifyRange, config: Config) => { 17 | const { "lastfm-user": user, "api-key": key } = config; 18 | if (!user || !key) throw new Error("Missing LastFM API Key or Username"); 19 | const response = await lastFM.getTopAlbums(key, user, timeRange); 20 | return Promise.all(response.map(convertAlbum)); 21 | }; 22 | 23 | const AlbumsPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => { 24 | const [dropdown, activeOption] = useDropdownMenu(DropdownOptions(configWrapper), "stats:top-albums"); 25 | 26 | const { status, error, data, refetch } = useQuery({ 27 | queryKey: ["top-albums", activeOption.id], 28 | queryFn: cacher(() => getTopAlbums(activeOption.id as SpotifyRange, configWrapper.config)), 29 | }); 30 | 31 | const Status = useStatus(status, error); 32 | 33 | const props = { 34 | lhs: ["Top Albums"], 35 | rhs: [ 36 | dropdown, 37 | invalidator(["top-albums", activeOption.id], refetch)} />, 38 | , 39 | ], 40 | }; 41 | 42 | if (Status) return {Status}; 43 | 44 | const topAlbums = data as NonNullable; 45 | 46 | const albumCards = topAlbums.map((album, index) => { 47 | return ( 48 | 57 | ); 58 | }); 59 | 60 | return ( 61 | 62 |
{albumCards}
63 |
64 | ); 65 | }; 66 | 67 | export default React.memo(AlbumsPage); 68 | -------------------------------------------------------------------------------- /projects/shared/src/dropdown/sort_dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { DownArrow, UpArrow } from "../icons/arrows"; 2 | import React from "react"; 3 | 4 | interface Option { 5 | id: string; 6 | name: string; 7 | } 8 | 9 | interface DropdownMenuProps { 10 | options: Option[]; 11 | activeOption: Option; 12 | switchCallback: (option: Option) => void; 13 | } 14 | 15 | interface SortDropdownMenuProps extends DropdownMenuProps { 16 | isReversed: boolean; 17 | } 18 | 19 | interface MenuItemProps { 20 | option: Option; 21 | isActive: boolean; 22 | switchCallback: (option: Option) => void; 23 | } 24 | 25 | interface SortMenuItemProps extends MenuItemProps { 26 | isReversed: boolean; 27 | } 28 | const SortMenuItem = (props: SortMenuItemProps) => { 29 | const { ReactComponent } = Spicetify; 30 | const { option, isActive, isReversed, switchCallback } = props; 31 | 32 | const activeStyle = { 33 | backgroundColor: "rgba(var(--spice-rgb-selected-row),.1)", 34 | }; 35 | 36 | return ( 37 | switchCallback(option)} 40 | data-checked={isActive} 41 | trailingIcon={isActive ? isReversed ? : : undefined} 42 | style={isActive ? activeStyle : undefined} 43 | > 44 | {option.name} 45 | 46 | ); 47 | }; 48 | 49 | const SortDropdownMenu = (props: SortDropdownMenuProps) => { 50 | const { ContextMenu, Menu, TextComponent } = Spicetify.ReactComponent; 51 | const { options, activeOption, isReversed, switchCallback } = props; 52 | 53 | const optionItems = options.map((option) => { 54 | return ( 55 | 61 | ); 62 | }); 63 | 64 | const MenuWrapper = (props: Spicetify.ReactComponent.MenuProps) => { 65 | return {optionItems}; 66 | }; 67 | 68 | return ( 69 | } trigger="click"> 70 | 87 | 88 | ); 89 | }; 90 | 91 | export default SortDropdownMenu; -------------------------------------------------------------------------------- /projects/stats/README.md: -------------------------------------------------------------------------------- 1 | # Spicetify Stats 2 | 3 | ### A custom app that shows you your top artists, tracks, genres and an analysis of your whole library, including individual playlists. 4 | 5 | --- 6 | 7 | ### Top Artists 8 | 9 | ![preview](previews/top_artists.png) 10 | 11 | --- 12 | 13 | ### Top Tracks 14 | 15 | ![preview](previews/top_tracks.png) 16 | 17 | --- 18 | 19 | ### Top Genres 20 | 21 | ![preview](previews/top_genres.png) 22 | 23 | --- 24 | 25 | ### Library Analysis 26 | 27 | ![preview](previews/library_analysis.png) 28 | 29 | --- 30 | 31 | ### Playlist Analysis 32 | 33 | ![preview](previews/playlist_analysis.png) 34 | 35 | --- 36 | 37 | ### Top Albums (works with Last.fm Sync only) 38 | 39 | ![preview](previews/top_albums.png) 40 | 41 | --- 42 | 43 | ### Last.fm Daily Charts 44 | 45 | ![preview](previews/top_charts.png) 46 | 47 | --- 48 | ### Automatic Installation (Linux) 49 | 50 | ```sh 51 | sh <(curl -s https://raw.githubusercontent.com/harbassan/spicetify-apps/main/stats/install.sh) 52 | ``` 53 | 54 | ### Automatic Installation (Windows, Powershell) 55 | 56 | ```ps1 57 | iwr -useb "https://raw.githubusercontent.com/harbassan/spicetify-apps/refs/heads/main/projects/stats/install.ps1" | iex 58 | ``` 59 | 60 | ### Manual Installation 61 | 62 | Download the zip file in the [latest release](https://github.com/harbassan/spicetify-apps/releases?q=stats&expanded=true), rename the unzipped folder to `stats`, then place that folder into your `CustomApps` folder in the `spicetify` directory and you're all done. If everything's correct, the structure should be similar to this: 63 | 64 | ``` 65 | 📦spicetify\CustomApps 66 | ┣ 📂marketplace 67 | ┣ etc... 68 | ┗ 📂stats 69 | ┃ ┣ 📜extension.js 70 | ┃ ┣ 📜index.js 71 | ┃ ┣ 📜manifest.json 72 | ┃ ┗ 📜style.css 73 | ``` 74 | 75 | Finally, run these commands to apply: 76 | 77 | ```powershell 78 | spicetify config custom_apps stats 79 | spicetify apply 80 | ``` 81 | 82 | That's it. Enjoy. 83 | 84 | For more help on installing visit the [Spicetify Docs](https://spicetify.app/docs/advanced-usage/custom-apps#installing). 85 | 86 | ### Uninstallation 87 | 88 | To uninstall the app, run these commands: 89 | 90 | ```powershell 91 | spicetify config custom_apps stats- 92 | spicetify apply 93 | ``` 94 | 95 | If you want to remove the app completely, just delete the `stats` folder after running the above commands. 96 | 97 | --- 98 | 99 | If you have any questions or issues regarding the app, open an issue on this repo. While doing so, please specify your spicetify version and installation method. 100 | 101 | If you like the app, I'd be really grateful if you liked the repo ❤️. 102 | -------------------------------------------------------------------------------- /projects/shared/src/types/platform.ts: -------------------------------------------------------------------------------- 1 | export interface PlaylistResponse { 2 | metadata: Metadata; 3 | contents: Contents; 4 | } 5 | 6 | export interface RootlistResponse { 7 | items: Metadata[]; 8 | } 9 | 10 | export interface Contents { 11 | items: (ContentsTrack | ContentsEpisode)[]; 12 | offset: number; 13 | limit: number; 14 | totalLength: number; 15 | } 16 | 17 | export interface ContentsTrack { 18 | uid: string; 19 | playIndex: null; 20 | addedAt: Date; 21 | addedBy: Owner; 22 | formatListAttributes: unknown; 23 | type: "track"; 24 | uri: string; 25 | name: string; 26 | album: Album; 27 | artists: Artist[]; 28 | discNumber: number; 29 | trackNumber: number; 30 | duration: { 31 | milliseconds: number; 32 | }; 33 | isExplicit: boolean; 34 | isLocal: boolean; 35 | isPlayable: boolean; 36 | is19PlusOnly: boolean; 37 | } 38 | 39 | export interface ContentsEpisode { 40 | type: "episode"; 41 | } 42 | 43 | export interface Owner { 44 | type: "user"; 45 | uri: string; 46 | username: string; 47 | displayName: string; 48 | images: Image[]; 49 | } 50 | 51 | export interface Image { 52 | url: string; 53 | label: Label; 54 | } 55 | 56 | export enum Label { 57 | Large = "large", 58 | Small = "small", 59 | Standard = "standard", 60 | Xlarge = "xlarge", 61 | } 62 | 63 | export interface Album { 64 | type: "album"; 65 | uri: string; 66 | name: string; 67 | artist: Artist; 68 | images: Image[]; 69 | } 70 | 71 | export interface Artist { 72 | type: "artist"; 73 | uri: string; 74 | name: string; 75 | } 76 | 77 | export interface Metadata { 78 | type: string; 79 | uri: string; 80 | name: string; 81 | description: string; 82 | images: Image[]; 83 | madeFor: null; 84 | owner: Owner; 85 | totalLength: number; 86 | unfilteredTotalLength: number; 87 | totalLikes: number; 88 | duration: MetadataDuration; 89 | isLoaded: boolean; 90 | isOwnedBySelf: boolean; 91 | isPublished: boolean; 92 | hasEpisodes: boolean; 93 | hasSpotifyTracks: boolean; 94 | hasSpotifyAudiobooks: boolean; 95 | canAdd: boolean; 96 | canRemove: boolean; 97 | canPlay: boolean; 98 | formatListData: null; 99 | canReportAnnotationAbuse: boolean; 100 | hasDateAdded: boolean; 101 | permissions: Permissions; 102 | collaborators: Collaborators; 103 | } 104 | 105 | export interface Collaborators { 106 | count: number; 107 | items: CollaboratorsItem[]; 108 | } 109 | 110 | export interface CollaboratorsItem { 111 | isOwner: boolean; 112 | tracksAdded: number; 113 | user: Owner; 114 | } 115 | 116 | export interface MetadataDuration { 117 | milliseconds: number; 118 | isEstimate: boolean; 119 | } 120 | 121 | export interface Permissions { 122 | canView: boolean; 123 | canAdministratePermissions: boolean; 124 | canCancelMembership: boolean; 125 | isPrivate: boolean; 126 | } 127 | -------------------------------------------------------------------------------- /projects/stats/src/pages/top_tracks.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TrackRow from "../components/track_row"; 3 | import PageContainer from "@shared/components/page_container"; 4 | import Tracklist from "../components/tracklist"; 5 | import useDropdownMenu from "@shared/dropdown/useDropdownMenu"; 6 | import type { Config, ConfigWrapper } from "../types/stats_types"; 7 | import * as lastFM from "../api/lastfm"; 8 | import * as spotify from "../api/spotify"; 9 | import RefreshButton from "../components/buttons/refresh_button"; 10 | import SettingsButton from "@shared/components/settings_button"; 11 | import { DropdownOptions } from "./top_artists"; 12 | import type { SpotifyRange } from "../types/spotify"; 13 | import { convertTrack, minifyTrack } from "../utils/converter"; 14 | import { useQuery } from "@shared/types/react_query"; 15 | import useStatus from "@shared/status/useStatus"; 16 | import { cacher, invalidator } from "../extensions/cache"; 17 | import { parseLiked } from "../utils/track_helper"; 18 | 19 | export const getTopTracks = async (timeRange: SpotifyRange, config: Config) => { 20 | if (config["use-lastfm"]) { 21 | const { "lastfm-user": user, "api-key": key } = config; 22 | if (!user || !key) throw new Error("Missing LastFM API Key or Username"); 23 | const response = await lastFM.getTopTracks(key, user, timeRange); 24 | return Promise.all(response.map(convertTrack)); 25 | } 26 | const response = await spotify.getTopTracks(timeRange); 27 | return response.map(minifyTrack); 28 | }; 29 | 30 | const TracksPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => { 31 | const [dropdown, activeOption] = useDropdownMenu(DropdownOptions(configWrapper), "stats:top-tracks"); 32 | 33 | const { status, error, data, refetch } = useQuery({ 34 | queryKey: ["top-tracks", activeOption.id], 35 | queryFn: (props) => 36 | cacher(() => getTopTracks(activeOption.id as SpotifyRange, configWrapper.config))(props).then(parseLiked), 37 | }); 38 | 39 | const Status = useStatus(status, error); 40 | 41 | const props = { 42 | lhs: ["Top Tracks"], 43 | rhs: [ 44 | dropdown, 45 | invalidator(["top-tracks", activeOption.id], refetch)} />, 46 | , 47 | ], 48 | }; 49 | 50 | if (Status) return {Status}; 51 | 52 | const topTracks = data as NonNullable; 53 | 54 | const infoToCreatePlaylist = { 55 | playlistName: `Top Songs - ${activeOption.name}`, 56 | itemsUris: topTracks.map((track) => track.uri), 57 | }; 58 | 59 | const trackRows = topTracks.map((track, index) => ( 60 | track.uri)} /> 61 | )); 62 | 63 | return ( 64 | 65 | {trackRows} 66 | 67 | ); 68 | }; 69 | 70 | export default React.memo(TracksPage); 71 | -------------------------------------------------------------------------------- /projects/shared/src/dropdown/dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Option { 4 | id: string; 5 | name: string; 6 | } 7 | 8 | interface DropdownMenuProps { 9 | options: Option[]; 10 | activeOption: Option; 11 | switchCallback: (option: Option) => void; 12 | } 13 | 14 | interface MenuItemProps { 15 | option: Option; 16 | isActive: boolean; 17 | switchCallback: (option: Option) => void; 18 | } 19 | 20 | function CheckIcon() { 21 | return ( 22 | ', 27 | }} 28 | /> 29 | ); 30 | } 31 | 32 | const MenuItem = (props: MenuItemProps) => { 33 | const { ReactComponent } = Spicetify; 34 | const { option, isActive, switchCallback } = props; 35 | 36 | const activeStyle = { 37 | backgroundColor: "rgba(var(--spice-rgb-selected-row),.1)", 38 | }; 39 | 40 | return ( 41 | switchCallback(option)} 44 | data-checked={isActive} 45 | trailingIcon={isActive ? : undefined} 46 | style={isActive ? activeStyle : undefined} 47 | > 48 | {option.name} 49 | 50 | ); 51 | }; 52 | 53 | const DropdownMenu = (props: DropdownMenuProps) => { 54 | const { ContextMenu, Menu, TextComponent } = Spicetify.ReactComponent; 55 | const { options, activeOption, switchCallback } = props; 56 | 57 | const optionItems = options.map((option) => { 58 | return ; 59 | }); 60 | 61 | const MenuWrapper = (props: Spicetify.ReactComponent.MenuProps) => { 62 | return {optionItems}; 63 | }; 64 | 65 | return ( 66 | } trigger="click"> 67 | 83 | 84 | ); 85 | }; 86 | 87 | export default DropdownMenu; 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/node 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 4 | 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | .pnpm-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # Snowpack dependency directory (https://snowpack.dev/) 51 | web_modules/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional stylelint cache 63 | .stylelintcache 64 | 65 | # Microbundle cache 66 | .rpt2_cache/ 67 | .rts2_cache_cjs/ 68 | .rts2_cache_es/ 69 | .rts2_cache_umd/ 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment variable files 81 | .env 82 | .env.development.local 83 | .env.test.local 84 | .env.production.local 85 | .env.local 86 | 87 | # parcel-bundler cache (https://parceljs.org/) 88 | .cache 89 | .parcel-cache 90 | 91 | # Next.js build output 92 | .next 93 | out 94 | 95 | # Nuxt.js build / generate output 96 | .nuxt 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | ### Node Patch ### 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # Optional stylelint cache 140 | 141 | # SvelteKit build / generate output 142 | .svelte-kit 143 | 144 | # End of https://www.toptal.com/developers/gitignore/api/node 145 | -------------------------------------------------------------------------------- /projects/library/src/components/custom_card.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import CollectionMenu from "./collection_menu"; 4 | import FolderMenu from "./folder_menu"; 5 | import FolderSVG from "./folder_fallback"; 6 | import LocalAlbumMenu from "./local_album_menu"; 7 | 8 | interface CustomCardProps { 9 | type: "folder" | "show" | "collection" | "localalbum"; 10 | uri: string; 11 | header: string; 12 | subheader: string; 13 | imageUrl?: string; 14 | badge?: string | React.ReactElement; 15 | } 16 | 17 | function CustomCard(props: CustomCardProps): React.ReactElement { 18 | // @ts-ignore 19 | const { Cards, TextComponent, PodcastShowMenu, ContextMenu } = Spicetify.ReactComponent; 20 | const { FeatureCard: Card, CardImage } = Cards; 21 | const { History } = Spicetify.Platform; 22 | const { type, header, uri, imageUrl, subheader, badge } = props; 23 | 24 | const Menu = () => { 25 | switch (type) { 26 | case "show": 27 | return ; 28 | case "collection": 29 | return ; 30 | case "folder": 31 | return ; 32 | case "localalbum": 33 | return ; 34 | default: 35 | return <>; 36 | } 37 | }; 38 | 39 | 40 | const additionalProps = (() => { 41 | switch (type) { 42 | case "folder": 43 | return { 44 | delegateNavigation: true, 45 | onClick: () => { 46 | Spicetify.Platform.History.replace(`/library/Playlists/${uri}`); 47 | Spicetify.LocalStorage.set("library:active-link", `Playlists/${uri}`); 48 | }, 49 | } 50 | case "collection": 51 | return { 52 | delegateNavigation: true, 53 | onClick: () => { 54 | Spicetify.Platform.History.replace(`/library/Collections/${uri}`); 55 | Spicetify.LocalStorage.set("library:active-link", `Collections/${uri}`); 56 | }, 57 | } 58 | case "localalbum": 59 | return { 60 | delegateNavigation: true, 61 | onClick: () => { 62 | History.push({ pathname: "better-local-files/album", state: { uri } }); 63 | }, 64 | } 65 | } 66 | })() 67 | 68 | const isCollection = type === "collection" || type === "folder"; 69 | 70 | return ( 71 | 72 |
73 | ( 77 | 87 | )} 88 | renderSubHeaderContent={() => ( 89 | 90 | {subheader} 91 | 92 | )} 93 | uri={uri} 94 | {...additionalProps} 95 | /> 96 | {badge &&
{badge}
} 97 |
98 |
99 | ); 100 | } 101 | 102 | export default CustomCard; 103 | -------------------------------------------------------------------------------- /projects/stats/src/pages/top_artists.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import useDropdownMenu from "@shared/dropdown/useDropdownMenu"; 3 | import SpotifyCard from "@shared/components/spotify_card"; 4 | import PageContainer from "@shared/components/page_container"; 5 | import type { Config, ConfigWrapper } from "../types/stats_types"; 6 | import SettingsButton from "@shared/components/settings_button"; 7 | import RefreshButton from "../components/buttons/refresh_button"; 8 | import * as lastFM from "../api/lastfm"; 9 | import * as spotify from "../api/spotify"; 10 | import { SpotifyRange } from "../types/spotify"; 11 | import { convertArtist, minifyArtist } from "../utils/converter"; 12 | import useStatus from "@shared/status/useStatus"; 13 | import { useQuery } from "@shared/types/react_query"; 14 | import { cacher, invalidator } from "../extensions/cache"; 15 | 16 | export const getTopArtists = async (timeRange: SpotifyRange, config: Config) => { 17 | if (config["use-lastfm"]) { 18 | const { "lastfm-user": user, "api-key": key } = config; 19 | if (!user || !key) throw new Error("Missing LastFM API Key or Username"); 20 | const response = await lastFM.getTopArtists(key, user, timeRange); 21 | return Promise.all(response.map(convertArtist)); 22 | } 23 | const response = await spotify.getTopArtists(timeRange); 24 | return response.map(minifyArtist); 25 | }; 26 | 27 | export const DropdownOptions = ({ config: { "use-lastfm": useLastFM } }: ConfigWrapper) => 28 | [ 29 | useLastFM && { id: "extra_short_term", name: "Past Week" }, 30 | { id: SpotifyRange.Short, name: "Past Month" }, 31 | { id: SpotifyRange.Medium, name: "Past 6 Months" }, 32 | { id: SpotifyRange.Long, name: "All Time" }, 33 | ].filter(Boolean) as { id: string; name: string }[]; 34 | 35 | const ArtistsPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => { 36 | const [dropdown, activeOption] = useDropdownMenu(DropdownOptions(configWrapper), "stats:top-artists"); 37 | 38 | const { status, error, data, refetch } = useQuery({ 39 | queryKey: ["top-artists", activeOption.id], 40 | queryFn: cacher(() => getTopArtists(activeOption.id as SpotifyRange, configWrapper.config)), 41 | }); 42 | 43 | const Status = useStatus(status, error); 44 | 45 | const props = { 46 | lhs: ["Top Artists"], 47 | rhs: [ 48 | dropdown, 49 | invalidator(["top-artists", activeOption.id], refetch)} />, 50 | , 51 | ], 52 | }; 53 | 54 | if (Status) return {Status}; 55 | 56 | const topArtists = data as NonNullable; 57 | 58 | const artistCards = topArtists.map((artist, index) => ( 59 | 68 | )); 69 | 70 | return ( 71 | <> 72 | 73 | {
{artistCards}
} 74 |
75 | 76 | ); 77 | }; 78 | 79 | export default React.memo(ArtistsPage); 80 | -------------------------------------------------------------------------------- /projects/library/src/components/collection_menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TextInputDialog from "./text_input_dialog"; 3 | import LeadingIcon from "./leading_icon"; 4 | 5 | const editIconPath = 6 | ''; 7 | 8 | const deleteIconPath = 9 | ''; 10 | 11 | const addIconPath = 12 | ''; 13 | 14 | const CollectionMenu = ({ id }: { id: string }) => { 15 | const { Menu, MenuItem } = Spicetify.ReactComponent; 16 | 17 | const deleteCollection = () => { 18 | CollectionsWrapper.deleteCollection(id); 19 | }; 20 | 21 | const deleteCollectionAndAlbums = () => { 22 | CollectionsWrapper.deleteCollectionAndAlbums(id); 23 | }; 24 | 25 | const renameCollection = () => { 26 | const name = CollectionsWrapper.getCollection(id)?.name; 27 | 28 | const rename = (newName: string) => { 29 | CollectionsWrapper.renameCollection(id, newName); 30 | }; 31 | 32 | Spicetify.PopupModal.display({ 33 | title: "Rename Collection", 34 | content: , 35 | }); 36 | }; 37 | 38 | const collection = CollectionsWrapper.getCollection(id); 39 | const image = collection?.image; 40 | const synced = collection?.syncedPlaylistUri; 41 | 42 | const setCollectionImage = () => { 43 | const setImg = (imgUrl: string) => { 44 | CollectionsWrapper.setCollectionImage(id, imgUrl); 45 | }; 46 | 47 | Spicetify.PopupModal.display({ 48 | title: "Set Collection Image", 49 | content: , 50 | }); 51 | }; 52 | 53 | const removeImage = () => { 54 | CollectionsWrapper.removeCollectionImage(id); 55 | }; 56 | 57 | const convertToPlaylist = () => { 58 | CollectionsWrapper.convertToPlaylist(id); 59 | }; 60 | 61 | const unsyncPlaylist = () => { 62 | CollectionsWrapper.unsyncCollection(id); 63 | }; 64 | 65 | return ( 66 | 67 | } onClick={renameCollection}> 68 | Rename 69 | 70 | } onClick={deleteCollection}> 71 | Delete (Only Collection) 72 | 73 | } onClick={deleteCollectionAndAlbums}> 74 | Delete (Collection and Albums) 75 | 76 | {synced ? ( 77 | } onClick={unsyncPlaylist}> 78 | Unsync from Playlist 79 | 80 | ) : ( 81 | } onClick={convertToPlaylist}> 82 | Sync to Playlist 83 | 84 | )} 85 | } onClick={setCollectionImage}> 86 | Set Collection Image 87 | 88 | {image && ( 89 | } onClick={removeImage}> 90 | Remove Collection Image 91 | 92 | )} 93 | 94 | ); 95 | }; 96 | 97 | export default CollectionMenu; 98 | -------------------------------------------------------------------------------- /projects/library/src/components/album_menu_item.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import LeadingIcon from "./leading_icon"; 3 | import TextInputDialog from "./text_input_dialog"; 4 | import SearchBar from "./searchbar"; 5 | 6 | const createCollection = () => { 7 | const onSave = (value: string) => { 8 | CollectionsWrapper.createCollection(value); 9 | }; 10 | 11 | Spicetify.PopupModal.display({ 12 | title: "Create Collection", 13 | content: , 14 | }); 15 | }; 16 | 17 | const CollectionSearchMenu = () => { 18 | const { MenuItem } = Spicetify.ReactComponent; 19 | const { SVGIcons } = Spicetify; 20 | 21 | const [textFilter, setTextFilter] = React.useState(""); 22 | const [collections, setCollections] = React.useState 24 | > | null>(null); 25 | 26 | const context = React.useContext(Spicetify.ContextMenuV2._context); 27 | const uri = context?.props?.uri || context?.props?.id; 28 | 29 | React.useEffect(() => { 30 | const fetchCollections = async () => { 31 | setCollections(await CollectionsWrapper.getContents({ textFilter, limit: 20, offset: 0 })); 32 | }; 33 | fetchCollections(); 34 | }, [textFilter]); 35 | 36 | if (!collections) return <>; 37 | 38 | const addToCollection = (collectionUri: string) => { 39 | CollectionsWrapper.addAlbumToCollection(collectionUri, uri); 40 | }; 41 | 42 | const activeCollections = CollectionsWrapper.getCollectionsWithAlbum(uri); 43 | const hasCollections = activeCollections.length > 0; 44 | 45 | const removeFromCollections = () => { 46 | for (const collection of activeCollections) { 47 | CollectionsWrapper.removeAlbumFromCollection(collection.uri, uri); 48 | } 49 | }; 50 | 51 | const allCollectionsLength = collections.totalLength; 52 | 53 | const menuItems = collections.items.map((collection, index) => { 54 | return ( 55 | { 58 | addToCollection(collection.uri); 59 | }} 60 | divider={index === 0 ? "before" : undefined} 61 | > 62 | {collection.name} 63 | 64 | ); 65 | }); 66 | 67 | const menuLength = allCollectionsLength + (hasCollections ? 1 : 0); 68 | 69 | return ( 70 |
75 |
  • 76 |
    77 | 78 |
    79 |
  • 80 | } onClick={createCollection}> 81 | Create collection 82 | 83 | {hasCollections && ( 84 | } 87 | onClick={removeFromCollections} 88 | > 89 | Remove from all 90 | 91 | )} 92 | {menuItems} 93 |
    94 | ); 95 | }; 96 | 97 | const AlbumMenuItem = () => { 98 | // @ts-expect-error 99 | const { MenuSubMenuItem } = Spicetify.ReactComponent; 100 | const { SVGIcons } = Spicetify; 101 | 102 | return ( 103 | } 107 | > 108 | 109 | 110 | ); 111 | }; 112 | 113 | export default AlbumMenuItem; 114 | -------------------------------------------------------------------------------- /projects/stats/src/extensions/extension.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PlaylistPage from "../pages/playlist"; 3 | import { version as STATS_VERSION } from "../../package.json"; 4 | import ConfigWrapper from "@shared/config/config_wrapper"; 5 | 6 | // contruct global class for stats methods 7 | class SpicetifyStats { 8 | ConfigWrapper = new ConfigWrapper( 9 | [ 10 | { 11 | name: "Last.fm Api Key", 12 | key: "api-key", 13 | type: "text", 14 | def: null, 15 | placeholder: "Enter API Key", 16 | desc: `You can get this by visiting www.last.fm/api/account/create and simply entering any name.
    You'll need to make an account first, which is a plus.`, 17 | sectionHeader: "Last.fm Integration", 18 | }, 19 | { 20 | name: "Last.fm Username", 21 | key: "lastfm-user", 22 | type: "text", 23 | def: null, 24 | placeholder: "Enter Username", 25 | }, 26 | { 27 | name: "Use Last.fm for Stats", 28 | key: "use-lastfm", 29 | type: "toggle", 30 | def: false, 31 | desc: "Last.fm charts your stats purely based on the streaming count, whereas Spotify factors in other variables", 32 | }, 33 | { 34 | name: "Artists Page", 35 | key: "show-artists", 36 | type: "toggle", 37 | def: true, 38 | sectionHeader: "Pages", 39 | }, 40 | { name: "Tracks Page", key: "show-tracks", type: "toggle", def: true }, 41 | { 42 | name: "Albums Page", 43 | key: "show-albums", 44 | type: "toggle", 45 | def: false, 46 | desc: "Requires Last.fm API key and username", 47 | }, 48 | { name: "Genres Page", key: "show-genres", type: "toggle", def: true }, 49 | { name: "Library Page", key: "show-library", type: "toggle", def: true }, 50 | { 51 | name: "Charts Page", 52 | key: "show-charts", 53 | type: "toggle", 54 | def: true, 55 | desc: "Requires Last.fm API key", 56 | }, 57 | ], 58 | "stats", 59 | ); 60 | } 61 | window.SpicetifyStats = new SpicetifyStats(); 62 | 63 | (function stats() { 64 | const { 65 | PopupModal, 66 | LocalStorage, 67 | Topbar, 68 | Platform: { History }, 69 | } = Spicetify; 70 | 71 | if (!PopupModal || !LocalStorage || !Topbar || !History) { 72 | setTimeout(stats, 300); 73 | return; 74 | } 75 | 76 | const version = localStorage.getItem("stats:version"); 77 | if (!version || version !== STATS_VERSION) { 78 | for (let i = 0; i < localStorage.length; i++) { 79 | const key = localStorage.key(i) as string; 80 | if (key.startsWith("stats:") && !key.startsWith("stats:config:")) { 81 | localStorage.removeItem(key); 82 | } 83 | } 84 | localStorage.setItem("stats:version", STATS_VERSION); 85 | } 86 | 87 | const styleLink = document.createElement("link"); 88 | styleLink.rel = "stylesheet"; 89 | styleLink.href = "/spicetify-routes-stats.css"; 90 | document.head.appendChild(styleLink); 91 | 92 | const playlistEdit = new Topbar.Button("playlist-stats", "visualizer", () => { 93 | const playlistUri = `spotify:playlist:${History.location.pathname.split("/")[2]}`; 94 | // @ts-ignore 95 | PopupModal.display({ title: "Playlist Stats", content: , isLarge: true }); 96 | }, false, true); 97 | playlistEdit.element.classList.add("playlist-stats-button"); 98 | playlistEdit.element.classList.toggle("hidden", true); 99 | 100 | function setTopbarButtonVisibility(pathname: string): void { 101 | const [, type, uid] = pathname.split("/"); 102 | const isPlaylistPage = type === "playlist" && uid; 103 | playlistEdit.element.classList.toggle("hidden", !isPlaylistPage); 104 | } 105 | setTopbarButtonVisibility(History.location.pathname); 106 | 107 | History.listen(({ pathname }: { pathname: string }) => { 108 | setTopbarButtonVisibility(pathname); 109 | }); 110 | })(); 111 | -------------------------------------------------------------------------------- /projects/stats/src/pages/playlist.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import StatCard from "../components/cards/stat_card"; 3 | import ChartCard from "../components/cards/chart_card"; 4 | import SpotifyCard from "@shared/components/spotify_card"; 5 | import Shelf from "../components/shelf"; 6 | import useStatus from "@shared/status/useStatus"; 7 | import { parseStat, parseTracks } from "../utils/track_helper"; 8 | import { getFullPlaylist } from "../api/platform"; 9 | 10 | const getPlaylist = async (uri: string) => { 11 | const contents = await getFullPlaylist(uri); 12 | return parseTracks(contents); 13 | }; 14 | 15 | // ? my shitty useQuery replacement because react-query is not working within the popup 16 | const useQueryShitty = (callback: () => Promise) => { 17 | const [error, setError] = React.useState(null); 18 | const [data, setData] = React.useState(null); 19 | const [status, setStatus] = React.useState<"pending" | "error" | "success">("pending"); 20 | 21 | React.useEffect(() => { 22 | const fetchData = async () => { 23 | try { 24 | const data = await callback(); 25 | setData(data); 26 | setStatus("success"); 27 | } catch (e) { 28 | console.log(e); 29 | setError(e as Error); 30 | setStatus("error"); 31 | } 32 | }; 33 | 34 | fetchData(); 35 | }, [callback]); 36 | 37 | return { status, error, data }; 38 | }; 39 | 40 | const PlaylistPage = ({ uri }: { uri: string }) => { 41 | const query = useCallback(() => getPlaylist(uri), [uri]); 42 | 43 | const { status, error, data } = useQueryShitty(query); 44 | 45 | const Status = useStatus(status, error); 46 | 47 | if (Status) return Status; 48 | 49 | const analysis = data as NonNullable; 50 | 51 | const statCards = Object.entries(analysis.analysis).map(([key, value]) => { 52 | return ; 53 | }); 54 | 55 | const artistCards = analysis.artists.contents.slice(0, 10).map((artist) => { 56 | return ( 57 | 65 | ); 66 | }); 67 | 68 | const albumCards = analysis.albums.contents.slice(0, 10).map((album) => { 69 | return ( 70 | 78 | ); 79 | }); 80 | 81 | return ( 82 |
    83 |
    84 | 85 | 86 | 87 | 88 | 89 |
    90 | 91 | 92 |
    {statCards}
    93 |
    94 | {/* 95 | {artistCards} 96 | */} 97 | {/* 98 | {albumCards} 99 | */} 100 | 101 | 102 | 103 |
    104 | ); 105 | }; 106 | 107 | export default React.memo(PlaylistPage); 108 | -------------------------------------------------------------------------------- /projects/library/src/app.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import AlbumsPage from "./pages/albums"; 4 | import ArtistsPage from "./pages/artists"; 5 | import ShowsPage from "./pages/shows"; 6 | import PlaylistsPage from "./pages/playlists"; 7 | import CollectionsPage from "./pages/collections"; 8 | 9 | import { version } from "../package.json"; 10 | 11 | import NavigationBar from "@shared/components/navigation/navigation_bar" 12 | 13 | import "./styles/app.scss"; 14 | import "./styles/external.scss"; 15 | import "../../shared/src/config/config_modal.scss"; 16 | import "../../shared/src/shared.scss"; 17 | 18 | import { ConfigWrapper } from "./types/library_types"; 19 | 20 | const checkForUpdates = (setNewUpdate: (a: boolean) => void) => { 21 | fetch("https://api.github.com/repos/harbassan/spicetify-apps/releases") 22 | .then((res) => res.json()) 23 | .then( 24 | (result) => { 25 | const releases = result.filter((release: any) => release.name.startsWith("library")); 26 | setNewUpdate(releases[0].name.slice(9) !== version); 27 | }, 28 | (error) => { 29 | console.log("Failed to check for updates", error); 30 | }, 31 | ); 32 | }; 33 | 34 | const NavbarContainer = ({ configWrapper }: { configWrapper: ConfigWrapper }) => { 35 | const pages: Record = { 36 | ["Artists"]: , 37 | ["Albums"]: , 38 | ["Shows"]: , 39 | ["Playlists"]: , 40 | ["Collections"]: , 41 | }; 42 | 43 | const tabPages = ["Playlists", "Albums", "Collections", "Artists", "Shows"].filter( 44 | (page) => configWrapper.config[`show-${page.toLowerCase()}` as keyof ConfigWrapper["config"]], 45 | ); 46 | 47 | const [newUpdate, setNewUpdate] = React.useState(false); 48 | 49 | const activePage = Spicetify.Platform.History.location.pathname.split("/")[2]; 50 | 51 | React.useEffect(() => { 52 | checkForUpdates(setNewUpdate); 53 | }, []); 54 | 55 | React.useEffect(() => { 56 | if (activePage === undefined) { 57 | const stored = Spicetify.LocalStorage.get("library:active-link") || "Playlists"; 58 | Spicetify.Platform.History.replace(`library/${stored}`); 59 | } 60 | }, [activePage]); 61 | 62 | if (activePage === undefined) return <>; 63 | 64 | return ( 65 | <> 66 | 67 | {newUpdate && ( 68 |
    69 | New app update available! Visit{" "} 70 | harbassan/spicetify-apps to install. 71 |
    72 | )} 73 | {pages[activePage]} 74 | 75 | ); 76 | }; 77 | 78 | const waitForReady = async (callback: () => void) => { 79 | if (Spicetify.Platform && Spicetify.Platform.LibraryAPI && Spicetify.ReactQuery && SpicetifyLibrary) { 80 | callback(); 81 | } else { 82 | setTimeout(() => waitForReady(callback), 1000); 83 | } 84 | } 85 | 86 | const App = () => { 87 | const [config, setConfig] = React.useState({} as ConfigWrapper["config"]); 88 | const [ready, setReady] = React.useState(false); 89 | 90 | // otherwise app crashes if its first page on spotify load 91 | if (!ready) { 92 | waitForReady(() => { 93 | setConfig({ ...SpicetifyLibrary.ConfigWrapper.Config }); 94 | setReady(true); 95 | }); 96 | return <>; 97 | } 98 | 99 | 100 | const launchModal = () => { 101 | SpicetifyLibrary.ConfigWrapper.launchModal(setConfig); 102 | }; 103 | 104 | const configWrapper = { 105 | config: config, 106 | launchModal, 107 | }; 108 | 109 | return ( 110 |
    111 | 112 |
    113 | ); 114 | }; 115 | 116 | export default App; 117 | -------------------------------------------------------------------------------- /projects/stats/src/app.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import ArtistsPage from "./pages/top_artists"; 4 | import TracksPage from "./pages/top_tracks"; 5 | import GenresPage from "./pages/top_genres"; 6 | import LibraryPage from "./pages/library"; 7 | import ChartsPage from "./pages/charts"; 8 | import AlbumsPage from "./pages/top_albums"; 9 | 10 | import { version } from "../package.json"; 11 | 12 | import NavigationBar from "@shared/components/navigation/navigation_bar" 13 | 14 | import "./styles/app.scss"; 15 | import "../../shared/src/config/config_modal.scss"; 16 | import "../../shared/src/shared.scss"; 17 | 18 | import { ConfigWrapper } from "./types/stats_types"; 19 | 20 | 21 | const checkForUpdates = (setNewUpdate: (a: boolean) => void) => { 22 | fetch("https://api.github.com/repos/harbassan/spicetify-apps/releases") 23 | .then((res) => res.json()) 24 | .then( 25 | (result) => { 26 | const releases = result.filter((release: any) => release.name.startsWith("stats")); 27 | setNewUpdate(releases[0].name.slice(7) !== version); 28 | }, 29 | (error) => { 30 | console.log("Failed to check for updates", error); 31 | }, 32 | ); 33 | }; 34 | 35 | const NavbarContainer = ({ configWrapper }: { configWrapper: ConfigWrapper }) => { 36 | const pages: Record = { 37 | ["Artists"]: , 38 | ["Tracks"]: , 39 | ["Albums"]: , 40 | ["Genres"]: , 41 | ["Library"]: , 42 | ["Charts"]: , 43 | }; 44 | 45 | const tabPages = ["Artists", "Tracks", "Albums", "Genres", "Library", "Charts"].filter( 46 | (page) => configWrapper.config[`show-${page.toLowerCase()}` as keyof ConfigWrapper["config"]], 47 | ); 48 | 49 | const [newUpdate, setNewUpdate] = React.useState(false); 50 | 51 | const activePage = Spicetify.Platform.History.location.pathname.split("/")[2]; 52 | 53 | React.useEffect(() => { 54 | checkForUpdates(setNewUpdate); 55 | }, []); 56 | 57 | React.useEffect(() => { 58 | if (activePage === undefined) { 59 | const stored = Spicetify.LocalStorage.get("stats:active-link") || "Artists"; 60 | Spicetify.Platform.History.replace(`stats/${stored}`); 61 | } 62 | }, [activePage]); 63 | 64 | if (activePage === undefined) return <>; 65 | 66 | return ( 67 | <> 68 | 69 | {newUpdate && ( 70 |
    71 | New app update available! Visit{" "} 72 | harbassan/spicetify-apps to install. 73 |
    74 | )} 75 | {pages[activePage]} 76 | 77 | ); 78 | }; 79 | 80 | const waitForReady = async (callback: () => void) => { 81 | if (Spicetify.Platform && Spicetify.Platform.RootlistAPI && Spicetify.ReactQuery && SpicetifyStats) { 82 | callback(); 83 | } else { 84 | setTimeout(() => waitForReady(callback), 1000); 85 | } 86 | } 87 | 88 | const App = () => { 89 | const [config, setConfig] = React.useState({} as ConfigWrapper["config"]); 90 | const [ready, setReady] = React.useState(false); 91 | 92 | // otherwise app crashes if its first page on spotify load 93 | if (!ready) { 94 | waitForReady(() => { 95 | setConfig({ ...SpicetifyStats.ConfigWrapper.Config }); 96 | setReady(true); 97 | }); 98 | return <>; 99 | } 100 | 101 | const launchModal = () => { 102 | SpicetifyStats.ConfigWrapper.launchModal(setConfig); 103 | }; 104 | 105 | const configWrapper = { 106 | config: config, 107 | launchModal, 108 | }; 109 | 110 | return ( 111 |
    112 | 113 |
    114 | ); 115 | 116 | }; 117 | 118 | export default App; 119 | -------------------------------------------------------------------------------- /projects/stats/src/api/spotify.ts: -------------------------------------------------------------------------------- 1 | import type * as Spotify from "../types/spotify"; 2 | 3 | export const apiFetch = async (name: string, url: string, log = true): Promise => { 4 | try { 5 | const timeStart = window.performance.now(); 6 | const response = await Spicetify.CosmosAsync.get(url); 7 | if (response.code || response.error) 8 | throw new Error( 9 | `Failed to fetch the info from server. Try again later. ${name.includes("lfm") ? "Check your LFM API key and username." : ""}`, 10 | ); 11 | if (log) console.log("stats -", name, "fetch time:", window.performance.now() - timeStart); 12 | return response; 13 | } catch (error) { 14 | console.log("stats -", name, "request failed:", error); 15 | throw error; 16 | } 17 | }; 18 | 19 | const val = (res: T | undefined) => { 20 | if (!res || (Array.isArray(res) && !res.length)) 21 | throw new Error("Spotify returned an empty result. Try again later."); 22 | return res; 23 | }; 24 | 25 | const f = (param: string) => { 26 | return encodeURIComponent(param.replace(/'/g, "")); 27 | }; 28 | 29 | export const getTopTracks = (range: Spotify.SpotifyRange) => { 30 | return apiFetch( 31 | "topTracks", 32 | `https://api.spotify.com/v1/me/top/tracks?limit=50&offset=0&time_range=${range}`, 33 | ).then((res) => val(res.items)); 34 | }; 35 | 36 | export const getTopArtists = (range: Spotify.SpotifyRange) => { 37 | return apiFetch( 38 | "topArtists", 39 | `https://api.spotify.com/v1/me/top/artists?limit=50&offset=0&time_range=${range}`, 40 | ).then((res) => val(res.items)); 41 | }; 42 | 43 | /** 44 | * @param ids - max: 50 45 | */ 46 | export const getArtistMetas = (ids: string[]) => { 47 | return apiFetch("artistMetas", `https://api.spotify.com/v1/artists?ids=${ids}`).then( 48 | (res) => res.artists, 49 | ); 50 | }; 51 | 52 | export const getAlbumMetas = (ids: string[]) => { 53 | return apiFetch("albumMetas", `https://api.spotify.com/v1/albums?ids=${ids}`).then( 54 | (res) => res.albums, 55 | ); 56 | }; 57 | 58 | export const getTrackMetas = (ids: string[]) => { 59 | return apiFetch("trackMetas", `https://api.spotify.com/v1/tracks?ids=${ids}`).then( 60 | (res) => res.tracks, 61 | ); 62 | }; 63 | 64 | export const getAudioFeatures = (ids: string[]) => { 65 | return apiFetch( 66 | "audioFeatures", 67 | `https://api.spotify.com/v1/audio-features?ids=${ids}`, 68 | ).then((res) => res.audio_features); 69 | }; 70 | 71 | export const searchForTrack = (track: string, artist: string) => { 72 | return apiFetch( 73 | "searchForTrack", 74 | `https://api.spotify.com/v1/search?q=track:${f(track)}+artist:${f(artist)}&type=track&limit=50`, 75 | ).then((res) => res.tracks.items); 76 | }; 77 | 78 | export const searchForArtist = (artist: string) => { 79 | return apiFetch( 80 | "searchForArtist", 81 | `https://api.spotify.com/v1/search?q=artist:${f(artist)}&type=artist&limit=50`, 82 | ).then((res) => res.artists.items); 83 | }; 84 | 85 | export const searchForAlbum = (album: string, artist: string) => { 86 | return apiFetch( 87 | "searchForAlbum", 88 | `https://api.spotify.com/v1/search?q=album:${f(album)}+artist:${f(artist)}&type=album&limit=50`, 89 | ).then((res) => res.albums.items); 90 | }; 91 | 92 | export const queryLiked = (ids: string[]) => { 93 | return apiFetch("queryLiked", `https://api.spotify.com/v1/me/tracks/contains?ids=${ids}`); 94 | }; 95 | 96 | export const getPlaylistMeta = (id: string) => { 97 | return apiFetch("playlistMeta", `https://api.spotify.com/v1/playlists/${id}`); 98 | }; 99 | 100 | export const getUserPlaylists = () => { 101 | return apiFetch("userPlaylists", "https://api.spotify.com/v1/me/playlists").then( 102 | (res) => res.items, 103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /projects/stats/src/components/tracklist.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Tracklist = ({ playcount = false, minified = false, children }) => { 4 | const height = children.length; 5 | return ( 6 |
    12 | {!minified && ( 13 |
    14 |
    22 |
    29 | # 30 |
    31 |
    38 | 43 |
    44 | {playcount && ( 45 |
    52 | 57 |
    58 | )} 59 |
    66 | 71 |
    72 |
    79 | 80 | 99 | 100 |
    101 |
    102 |
    103 | )} 104 |
    105 |
    {children}
    106 |
    107 |
    108 | ); 109 | }; 110 | 111 | export default Tracklist; 112 | -------------------------------------------------------------------------------- /projects/stats/src/utils/converter.ts: -------------------------------------------------------------------------------- 1 | import { searchForAlbum, searchForArtist, searchForTrack } from "../api/spotify"; 2 | import { cacher, set } from "../extensions/cache"; 3 | import type * as LastFM from "../types/lastfm"; 4 | import type * as Spotify from "../types/spotify"; 5 | import type { 6 | LastFMMinifiedAlbum, 7 | LastFMMinifiedArtist, 8 | LastFMMinifiedTrack, 9 | SpotifyMinifiedAlbum, 10 | SpotifyMinifiedArtist, 11 | SpotifyMinifiedTrack, 12 | } from "../types/stats_types"; 13 | 14 | export const minifyArtist = (artist: Spotify.Artist): SpotifyMinifiedArtist => ({ 15 | id: artist.id, 16 | name: artist.name, 17 | image: artist.images?.at(0)?.url, 18 | uri: artist.uri, 19 | genres: artist.genres, 20 | type: "spotify", 21 | }); 22 | 23 | export const minifyAlbum = (album: Spotify.SimplifiedAlbum): SpotifyMinifiedAlbum => ({ 24 | id: album.id, 25 | uri: album.uri, 26 | name: album.name, 27 | image: album.images[0]?.url, 28 | type: "spotify", 29 | }); 30 | 31 | export const minifyTrack = (track: Spotify.Track): SpotifyMinifiedTrack => ({ 32 | id: track.id, 33 | uri: track.uri, 34 | name: track.name, 35 | duration_ms: track.duration_ms, 36 | popularity: track.popularity, 37 | explicit: track.explicit, 38 | image: track.album.images.at(-1)?.url, 39 | artists: track.artists.map((artist) => ({ 40 | name: artist.name, 41 | uri: artist.uri, 42 | })), 43 | album: { 44 | name: track.album.name, 45 | uri: track.album.uri, 46 | release_date: track.album.release_date, 47 | }, 48 | type: "spotify", 49 | }); 50 | 51 | export const convertArtist = async (artist: LastFM.Artist) => { 52 | const spotifyArtist = await cacher(async () => { 53 | const searchRes = await searchForArtist(artist.name); 54 | const spotifyArtists = searchRes.filter( 55 | (a) => a.name.localeCompare(artist.name, undefined, { sensitivity: "base" }) === 0, 56 | ); 57 | return spotifyArtists.sort((a, b) => b.popularity - a.popularity)[0]; 58 | })({ queryKey: ["searchForArtist", artist.name] }); 59 | if (!spotifyArtist) 60 | return { 61 | name: artist.name, 62 | playcount: Number(artist.playcount), 63 | uri: artist.url, 64 | type: "lastfm", 65 | } as LastFMMinifiedArtist; 66 | set(`artist-${spotifyArtist.id}`, spotifyArtist); 67 | return { 68 | ...minifyArtist(spotifyArtist), 69 | playcount: Number(artist.playcount), 70 | name: artist.name, 71 | } as SpotifyMinifiedArtist; 72 | }; 73 | 74 | export const convertAlbum = async (album: LastFM.Album) => { 75 | const spotifyAlbum = await cacher(async () => { 76 | const searchRes = await searchForAlbum(album.name, album.artist.name); 77 | return searchRes.find((a) => a.name.localeCompare(album.name, undefined, { sensitivity: "base" }) === 0); 78 | })({ 79 | queryKey: ["searchForAlbum", album.name, album.artist.name], 80 | }); 81 | if (!spotifyAlbum) 82 | return { 83 | uri: album.url, 84 | name: album.name, 85 | playcount: Number(album.playcount), 86 | type: "lastfm", 87 | } as LastFMMinifiedAlbum; 88 | return { 89 | ...minifyAlbum(spotifyAlbum), 90 | playcount: Number(album.playcount), 91 | name: album.name, 92 | } as SpotifyMinifiedAlbum; 93 | }; 94 | 95 | export const convertTrack = async (track: LastFM.Track) => { 96 | const spotifyTrack = await cacher(async () => { 97 | const searchRes = await searchForTrack(track.name, track.artist.name); 98 | return searchRes.find((t) => t.name.localeCompare(track.name, undefined, { sensitivity: "base" }) === 0); 99 | })({ 100 | queryKey: ["searchForTrack", track.name, track.artist.name], 101 | }); 102 | if (!spotifyTrack) 103 | return { 104 | uri: track.url, 105 | name: track.name, 106 | playcount: Number(track.playcount), 107 | duration_ms: Number(track.duration) * 1000, 108 | artists: [ 109 | { 110 | name: track.artist.name, 111 | uri: track.artist.url, 112 | }, 113 | ], 114 | type: "lastfm", 115 | } as LastFMMinifiedTrack; 116 | return { 117 | ...minifyTrack(spotifyTrack), 118 | playcount: Number(track.playcount), 119 | name: track.name, 120 | } as SpotifyMinifiedTrack; 121 | }; 122 | -------------------------------------------------------------------------------- /projects/stats/src/styles/app.scss: -------------------------------------------------------------------------------- 1 | #stats-app { 2 | .stats-gridInline { 3 | --grid-gap: 24px; 4 | grid-template-columns: repeat(10, 180px) !important; 5 | overflow-x: hidden; 6 | scroll-behavior: smooth; 7 | margin-top: 5px; 8 | transform: none !important; 9 | will-change: auto !important; 10 | } 11 | 12 | .grid:nth-child(2) { 13 | margin-top: 24px; 14 | } 15 | 16 | 17 | 18 | [data-scroll="both"] { 19 | mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent); 20 | } 21 | 22 | [data-scroll="end"] { 23 | mask-image: linear-gradient(to right, transparent, black 10%); 24 | } 25 | 26 | [data-scroll="start"] { 27 | mask-image: linear-gradient(to right, black 90%, transparent); 28 | } 29 | 30 | .stats-libraryOverview { 31 | display: flex; 32 | gap: 24px; 33 | align-items: center; 34 | } 35 | 36 | .stats-trackPageTitle { 37 | display: flex; 38 | gap: 24px; 39 | align-items: center; 40 | } 41 | 42 | .stats-scrollButton { 43 | width: 40px; 44 | border-radius: 8px; 45 | border: none; 46 | padding: 0; 47 | margin-right: 5px; 48 | background-color: var(--spice-player); 49 | color: var(--spice-subtext); 50 | 51 | &:hover { 52 | background-color: var(--spice-card); 53 | color: var(--spice-text); 54 | } 55 | } 56 | 57 | .stats-tracklistHeader>div { 58 | display: flex; 59 | -webkit-app-region: no-drag; 60 | gap: 20px; 61 | align-items: center; 62 | } 63 | 64 | .stats-make-playlist-button { 65 | margin-inline-start: 12px; 66 | } 67 | 68 | .stats-genreCard { 69 | display: flex; 70 | flex-direction: column; 71 | gap: 10px; 72 | padding: 16px; 73 | border-radius: 8px; 74 | background: var(--spice-player); 75 | position: relative; 76 | } 77 | 78 | .stats-genreRow { 79 | width: 100%; 80 | height: 20px; 81 | display: flex; 82 | gap: 10px; 83 | } 84 | 85 | .stats-genreRowFill { 86 | background: var(--spice-button); 87 | height: 100%; 88 | border-radius: 8px; 89 | display: flex; 90 | align-items: center; 91 | } 92 | 93 | .stats-genreText { 94 | color: var(--spice-player); 95 | font-size: 0.875rem; 96 | margin-left: 7px; 97 | font-weight: bold; 98 | white-space: nowrap; 99 | overflow: hidden; 100 | text-overflow: ellipsis; 101 | } 102 | 103 | .stats-genreValue { 104 | color: var(--spice-text); 105 | font-size: 0.875rem; 106 | } 107 | 108 | .stats-genreCard+.stats-gridInlineSection { 109 | margin-top: 3px; 110 | } 111 | 112 | // fix styles for old trackrow clone component 113 | .main-trackList-rowHeartButton, 114 | .main-trackList-rowMoreButton { 115 | background-color: transparent; 116 | border: none; 117 | fill: currentColor; 118 | } 119 | 120 | .main-trackList-rowPlayPauseIcon, 121 | .main-trackList-durationHeader { 122 | fill: currentColor; 123 | } 124 | 125 | .main-trackList-rowBadges { 126 | color: var(--spice-text); 127 | } 128 | 129 | .extend-button { 130 | background-color: transparent; 131 | border: none; 132 | position: absolute; 133 | right: 20px; 134 | bottom: 20px; 135 | font-size: 14px; 136 | color: var(--spice-subtext); 137 | 138 | &:hover { 139 | color: var(--spice-text); 140 | } 141 | } 142 | 143 | .main-card-cardContainer { 144 | width: 100%; 145 | height: 100%; 146 | } 147 | 148 | } 149 | 150 | .GenericModal[aria-label="Playlist Stats"] .main-embedWidgetGenerator-container { 151 | width: 80vw; 152 | height: 80vh; 153 | background-color: var(--spice-main); 154 | } 155 | 156 | .GenericModal[aria-label="Playlist Stats"] .main-shelf-title { 157 | color: var(--spice-text); 158 | } 159 | 160 | .playlist-stats-button.hidden { 161 | display: none; 162 | } -------------------------------------------------------------------------------- /projects/stats/src/pages/top_genres.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import useDropdownMenu from "@shared/dropdown/useDropdownMenu"; 3 | import StatCard from "../components/cards/stat_card"; 4 | import ChartCard from "../components/cards/chart_card"; 5 | import InlineGrid from "../components/inline_grid"; 6 | import PageContainer from "@shared/components/page_container"; 7 | import Shelf from "../components/shelf"; 8 | import { DropdownOptions } from "./top_artists"; 9 | import { getTopTracks } from "./top_tracks"; 10 | import type { Config, ConfigWrapper, LastFMMinifiedTrack, SpotifyMinifiedTrack } from "../types/stats_types"; 11 | import RefreshButton from "../components/buttons/refresh_button"; 12 | import SettingsButton from "@shared/components/settings_button"; 13 | import type { SpotifyRange } from "../types/spotify"; 14 | import { getMeanAudioFeatures, parseArtists, parseStat } from "../utils/track_helper"; 15 | import { useQuery } from "@shared/types/react_query"; 16 | import useStatus from "@shared/status/useStatus"; 17 | import { cacher, invalidator } from "../extensions/cache"; 18 | 19 | const parseAlbums = (albums: SpotifyMinifiedTrack["album"][]) => { 20 | const releaseYears = {} as Record; 21 | for (const album of albums) { 22 | const year = album.release_date.slice(0, 4); 23 | releaseYears[year] = (releaseYears[year] || 0) + 1; 24 | } 25 | return releaseYears; 26 | }; 27 | 28 | const parseTracks = async (tracks: (SpotifyMinifiedTrack | LastFMMinifiedTrack)[]) => { 29 | const trackIDs: string[] = []; 30 | const albumsRaw: SpotifyMinifiedTrack["album"][] = []; 31 | const artistsRaw: SpotifyMinifiedTrack["artists"] = []; 32 | let explicit = 0; 33 | let popularity = 0; 34 | 35 | for (const track of tracks) { 36 | if (track.type !== "spotify") continue; 37 | popularity += track.popularity; 38 | explicit += track.explicit ? 1 : 0; 39 | trackIDs.push(track.id); 40 | albumsRaw.push(track.album); 41 | artistsRaw.push(...track.artists); 42 | } 43 | 44 | explicit = explicit / trackIDs.length; 45 | popularity = popularity / trackIDs.length; 46 | 47 | const audioFeatures = await getMeanAudioFeatures(trackIDs); 48 | const analysis = { ...audioFeatures, popularity, explicit }; 49 | const { genres } = await parseArtists(artistsRaw); 50 | const releaseYears = parseAlbums(albumsRaw); 51 | 52 | return { analysis, genres, releaseYears }; 53 | }; 54 | 55 | const getGenres = async (time_range: SpotifyRange, config: Config) => { 56 | const topTracks = await cacher(() => getTopTracks(time_range, config))({ queryKey: ["top-tracks", time_range] }); 57 | return parseTracks(topTracks); 58 | }; 59 | 60 | const GenresPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => { 61 | const [dropdown, activeOption] = useDropdownMenu(DropdownOptions(configWrapper), "stats:top-genres"); 62 | 63 | const { status, error, data, refetch } = useQuery({ 64 | queryKey: ["top-genres", activeOption.id], 65 | queryFn: cacher(() => getGenres(activeOption.id as SpotifyRange, configWrapper.config)), 66 | }); 67 | 68 | const Status = useStatus(status, error); 69 | 70 | const props = { 71 | lhs: ["Top Genres"], 72 | rhs: [ 73 | dropdown, 74 | invalidator(["top-genres", activeOption.id], refetch)} />, 75 | , 76 | ], 77 | }; 78 | 79 | if (Status) return {Status}; 80 | 81 | const analysis = data as NonNullable; 82 | 83 | const statCards = Object.entries(analysis.analysis).map(([key, value]) => { 84 | return ; 85 | }); 86 | 87 | // const obscureTracks = topGenres.obscureTracks.map((track: Track, index: number) => ( 88 | // track.uri)} /> 89 | // )); 90 | 91 | return ( 92 | 93 |
    94 | 95 |
    {statCards}
    96 |
    97 | 98 | 99 | 100 | {/* 101 | {obscureTracks} 102 | */} 103 |
    104 | ); 105 | }; 106 | 107 | export default React.memo(GenresPage); 108 | -------------------------------------------------------------------------------- /projects/stats/src/pages/library.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import useDropdownMenu from "@shared/dropdown/useDropdownMenu"; 3 | import StatCard from "../components/cards/stat_card"; 4 | import ChartCard from "../components/cards/chart_card"; 5 | import SpotifyCard from "@shared/components/spotify_card"; 6 | import InlineGrid from "../components/inline_grid"; 7 | import PageContainer from "@shared/components/page_container"; 8 | import Shelf from "../components/shelf"; 9 | import type { ConfigWrapper } from "../types/stats_types"; 10 | import RefreshButton from "../components/buttons/refresh_button"; 11 | import SettingsButton from "@shared/components/settings_button"; 12 | import { useQuery } from "@shared/types/react_query"; 13 | import useStatus from "@shared/status/useStatus"; 14 | import { parseStat, parseTracks } from "../utils/track_helper"; 15 | import { cacher, invalidator } from "../extensions/cache"; 16 | import { getFullPlaylist, getRootlist } from "../api/platform"; 17 | 18 | const DropdownOptions = [ 19 | { id: "owned", name: "My Playlists" }, 20 | { id: "all", name: "All Playlists" }, 21 | ]; 22 | 23 | const getLibrary = async (type: "owned" | "all") => { 24 | let playlists = await getRootlist(); 25 | if (type === "owned") playlists = playlists.filter((p) => p.isOwnedBySelf); 26 | if (playlists.length === 0) throw new Error("You have no playlists saved"); 27 | const contents = await Promise.all(playlists.map((p) => getFullPlaylist(p.uri))); 28 | const analysis = await parseTracks(contents.flat()); 29 | return { ...analysis, playlists: playlists.length }; 30 | }; 31 | 32 | const LibraryPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => { 33 | const [dropdown, activeOption] = useDropdownMenu(DropdownOptions, "stats:library"); 34 | 35 | const { status, error, data, refetch } = useQuery({ 36 | queryKey: ["library", activeOption.id], 37 | queryFn: cacher(() => getLibrary(activeOption.id as "owned" | "all")), 38 | }); 39 | 40 | const props = { 41 | lhs: ["Library Analysis"], 42 | rhs: [ 43 | dropdown, 44 | invalidator(["library", activeOption.id], refetch)} />, 45 | , 46 | ], 47 | }; 48 | 49 | const Status = useStatus(status, error); 50 | 51 | if (Status) return {Status}; 52 | 53 | const analysis = data as NonNullable; 54 | 55 | const statCards = Object.entries(analysis.analysis).map(([key, value]) => { 56 | return ; 57 | }); 58 | 59 | const artistCards = analysis.artists.contents.slice(0, 10).map((artist) => { 60 | return ( 61 | 69 | ); 70 | }); 71 | 72 | const albumCards = analysis.albums.contents.slice(0, 10).map((album) => { 73 | return ( 74 | 82 | ); 83 | }); 84 | 85 | return ( 86 | 87 |
    88 | 89 | 90 | 91 | 92 | 93 | 94 |
    95 | 96 | 97 |
    {statCards}
    98 |
    99 | 100 | {artistCards} 101 | 102 | 103 | {albumCards} 104 | 105 | 106 | 107 | 108 |
    109 | ); 110 | }; 111 | 112 | export default React.memo(LibraryPage); 113 | -------------------------------------------------------------------------------- /projects/library/src/pages/artists.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import SearchBar from "../components/searchbar"; 3 | import PageContainer from "@shared/components/page_container"; 4 | import SpotifyCard from "@shared/components/spotify_card"; 5 | import SettingsButton from "@shared/components/settings_button"; 6 | import type { ConfigWrapper } from "../types/library_types"; 7 | import LoadMoreCard from "../components/load_more_card"; 8 | import LeadingIcon from "../components/leading_icon"; 9 | import AddButton from "../components/add_button"; 10 | import TextInputDialog from "../components/text_input_dialog"; 11 | import useStatus from "@shared/status/useStatus"; 12 | import { useInfiniteQuery } from "@shared/types/react_query"; 13 | import type { ArtistItem, GetContentsResponse, UpdateEvent } from "../types/platform"; 14 | import PinIcon from "../components/pin_icon"; 15 | import useSortDropdownMenu from "@shared/dropdown/useSortDropdownMenu"; 16 | 17 | const AddMenu = () => { 18 | const { MenuItem, Menu } = Spicetify.ReactComponent; 19 | const { SVGIcons } = Spicetify; 20 | 21 | const addAlbum = () => { 22 | const onSave = (value: string) => { 23 | Spicetify.Platform.LibraryAPI.add({ uris: [value] }); 24 | }; 25 | 26 | Spicetify.PopupModal.display({ 27 | title: "Add Artist", 28 | // @ts-ignore 29 | content: , 30 | }); 31 | }; 32 | 33 | return ( 34 | 35 | }> 36 | Add Artist 37 | 38 | 39 | ); 40 | }; 41 | 42 | function isValidArtist(artist: ArtistItem) { 43 | return artist.name && artist.uri; 44 | } 45 | 46 | const limit = 200; 47 | 48 | const sortOptions = [ 49 | { id: "0", name: "Name" }, 50 | { id: "1", name: "Date Added" }, 51 | ]; 52 | 53 | const ArtistsPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => { 54 | const [sortDropdown, sortOption, isReversed] = useSortDropdownMenu(sortOptions, "library:artists"); 55 | const [textFilter, setTextFilter] = React.useState(""); 56 | 57 | const fetchArtists = async ({ pageParam }: { pageParam: number }) => { 58 | const res = (await Spicetify.Platform.LibraryAPI.getContents({ 59 | filters: ["1"], 60 | sortOrder: sortOption.id, 61 | textFilter, 62 | offset: pageParam, 63 | sortDirection: isReversed ? "reverse" : undefined, 64 | limit, 65 | })) as GetContentsResponse; 66 | if (!res.items?.length) throw new Error("No artists found"); 67 | return res; 68 | }; 69 | 70 | const { data, status, error, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery({ 71 | queryKey: ["library:artists", sortOption.id, isReversed, textFilter], 72 | queryFn: fetchArtists, 73 | initialPageParam: 0, 74 | getNextPageParam: (lastPage) => { 75 | const current = lastPage.offset + limit; 76 | if (lastPage.totalLength > current) return current; 77 | }, 78 | }); 79 | 80 | useEffect(() => { 81 | const update = (e: UpdateEvent) => { 82 | if (e.data.list === "artists") refetch(); 83 | }; 84 | Spicetify.Platform.LibraryAPI.getEvents()._emitter.addListener("update", update, {}); 85 | return () => { 86 | Spicetify.Platform.LibraryAPI.getEvents()._emitter.removeListener("update", update); 87 | }; 88 | }, [refetch]); 89 | 90 | const Status = useStatus(status, error); 91 | 92 | const props = { 93 | lhs: ["Artists"], 94 | rhs: [ 95 | } />, 96 | sortDropdown, 97 | , 98 | , 99 | ], 100 | }; 101 | 102 | if (Status) return {Status}; 103 | 104 | const contents = data as NonNullable; 105 | 106 | const artists = contents.pages.flatMap((page) => page.items); 107 | 108 | const artistCards = artists.filter(isValidArtist).map((artist) => ( 109 | : undefined} 116 | /> 117 | )); 118 | 119 | if (hasNextPage) artistCards.push(); 120 | 121 | return ( 122 | 123 |
    {artistCards}
    124 |
    125 | ); 126 | }; 127 | 128 | export default ArtistsPage; 129 | -------------------------------------------------------------------------------- /projects/library/src/pages/shows.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import SearchBar from "../components/searchbar"; 3 | import PageContainer from "@shared/components/page_container"; 4 | import SettingsButton from "@shared/components/settings_button"; 5 | import type { ConfigWrapper } from "../types/library_types"; 6 | import SpotifyCard from "@shared/components/spotify_card"; 7 | import LoadMoreCard from "../components/load_more_card"; 8 | import LeadingIcon from "../components/leading_icon"; 9 | import AddButton from "../components/add_button"; 10 | import TextInputDialog from "../components/text_input_dialog"; 11 | import { useInfiniteQuery } from "@shared/types/react_query"; 12 | import type { GetContentsResponse, ShowItem, UpdateEvent } from "../types/platform"; 13 | import useStatus from "@shared/status/useStatus"; 14 | import PinIcon from "../components/pin_icon"; 15 | import useSortDropdownMenu from "@shared/dropdown/useSortDropdownMenu"; 16 | import CustomCard from "../components/custom_card"; 17 | 18 | const AddMenu = () => { 19 | const { MenuItem, Menu } = Spicetify.ReactComponent; 20 | const { SVGIcons } = Spicetify; 21 | 22 | const addAlbum = () => { 23 | const onSave = (value: string) => { 24 | Spicetify.Platform.LibraryAPI.add({ uris: [value] }); 25 | }; 26 | 27 | Spicetify.PopupModal.display({ 28 | title: "Add Show", 29 | // @ts-ignore 30 | content: , 31 | }); 32 | }; 33 | 34 | return ( 35 | 36 | }> 37 | Add Show 38 | 39 | 40 | ); 41 | }; 42 | 43 | function isValidShow(show: ShowItem) { 44 | return show.name && show.uri; 45 | } 46 | 47 | const limit = 200; 48 | 49 | const sortOptions = [ 50 | { id: "0", name: "Name" }, 51 | { id: "1", name: "Date Added" }, 52 | ]; 53 | 54 | const ShowsPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => { 55 | const [sortDropdown, sortOption, isReversed] = useSortDropdownMenu(sortOptions, "library:shows"); 56 | const [textFilter, setTextFilter] = React.useState(""); 57 | 58 | const fetchShows = async ({ pageParam }: { pageParam: number }) => { 59 | const res = (await Spicetify.Platform.LibraryAPI.getContents({ 60 | filters: ["3"], 61 | sortOrder: sortOption.id, 62 | textFilter, 63 | sortDirection: isReversed ? "reverse" : undefined, 64 | offset: pageParam, 65 | limit, 66 | })) as GetContentsResponse; 67 | if (!res.items?.length) throw new Error("No shows found"); 68 | return res; 69 | }; 70 | 71 | const { data, status, error, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery({ 72 | queryKey: ["library:shows", sortOption.id, isReversed, textFilter], 73 | queryFn: fetchShows, 74 | initialPageParam: 0, 75 | getNextPageParam: (lastPage) => { 76 | const current = lastPage.offset + limit; 77 | if (lastPage.totalLength > current) return current; 78 | }, 79 | }); 80 | 81 | useEffect(() => { 82 | const update = (e: UpdateEvent) => { 83 | if (e.data.list === "shows") refetch(); 84 | }; 85 | Spicetify.Platform.LibraryAPI.getEvents()._emitter.addListener("update", update, {}); 86 | return () => { 87 | Spicetify.Platform.LibraryAPI.getEvents()._emitter.removeListener("update", update); 88 | }; 89 | }, [refetch]); 90 | 91 | const Status = useStatus(status, error); 92 | 93 | const props = { 94 | lhs: ["Shows"], 95 | rhs: [ 96 | } />, 97 | sortDropdown, 98 | , 99 | , 100 | ], 101 | }; 102 | 103 | if (Status) return {Status}; 104 | 105 | const contents = data as NonNullable; 106 | 107 | const shows = contents.pages.flatMap((page) => page.items); 108 | 109 | const showCards = shows.filter(isValidShow).map((show) => ( 110 | : undefined} 117 | /> 118 | )); 119 | 120 | if (hasNextPage) showCards.push(); 121 | 122 | return ( 123 | 124 |
    {showCards}
    125 |
    126 | ); 127 | }; 128 | 129 | export default ShowsPage; 130 | -------------------------------------------------------------------------------- /projects/stats/src/types/graph_ql.ts: -------------------------------------------------------------------------------- 1 | export interface getAlbumResponse { 2 | data: Data; 3 | extensions: null; 4 | } 5 | 6 | export interface Data { 7 | albumUnion: AlbumUnion; 8 | } 9 | 10 | export interface AlbumUnion { 11 | __typename: string; 12 | copyright: Copyright; 13 | courtesyLine: string; 14 | date: AlbumUnionDate; 15 | label: string; 16 | name: string; 17 | playability: ItemPlayability; 18 | saved: boolean; 19 | sharingInfo: AlbumUnionSharingInfo; 20 | tracks: AlbumUnionTracks; 21 | type: Type; 22 | uri: string; 23 | artists: AlbumUnionArtists; 24 | coverArt: CoverArt; 25 | discs: Discs; 26 | releases: Releases; 27 | moreAlbumsByArtist: MoreAlbumsByArtist; 28 | } 29 | 30 | export interface AlbumUnionArtists { 31 | items: AlbumArtist[]; 32 | totalCount: number; 33 | } 34 | 35 | export interface AlbumArtist { 36 | id: string; 37 | profile: Profile; 38 | sharingInfo: SharingInfo; 39 | uri: string; 40 | visuals: Visuals; 41 | } 42 | 43 | export interface Profile { 44 | name: string; 45 | } 46 | 47 | export interface SharingInfo { 48 | shareUrl: string; 49 | } 50 | 51 | export interface Visuals { 52 | avatarImage: AvatarImage; 53 | } 54 | 55 | export interface AvatarImage { 56 | sources: Source[]; 57 | } 58 | 59 | export interface Source { 60 | height: number; 61 | url: string; 62 | width: number; 63 | } 64 | 65 | export interface Copyright { 66 | items: CopyrightItem[]; 67 | totalCount: number; 68 | } 69 | 70 | export interface CopyrightItem { 71 | text: string; 72 | type: string; 73 | } 74 | 75 | export interface CoverArt { 76 | extractedColors: ExtractedColors; 77 | sources: Source[]; 78 | } 79 | 80 | export interface ExtractedColors { 81 | colorDark: Color; 82 | colorLight: Color; 83 | colorRaw: Color; 84 | } 85 | 86 | export interface Color { 87 | hex: string; 88 | } 89 | 90 | export interface AlbumUnionDate { 91 | isoString: Date; 92 | precision: string; 93 | } 94 | 95 | export interface Discs { 96 | items: DiscsItem[]; 97 | totalCount: number; 98 | } 99 | 100 | export interface DiscsItem { 101 | number: number; 102 | tracks: AssociatedVideosClass; 103 | } 104 | 105 | export interface AssociatedVideosClass { 106 | totalCount: number; 107 | } 108 | 109 | export interface MoreAlbumsByArtist { 110 | items: MoreAlbumsByArtistItem[]; 111 | } 112 | 113 | export interface MoreAlbumsByArtistItem { 114 | discography: Discography; 115 | } 116 | 117 | export interface Discography { 118 | popularReleasesAlbums: PopularReleasesAlbums; 119 | } 120 | 121 | export interface PopularReleasesAlbums { 122 | items: PopularReleasesAlbumsItem[]; 123 | } 124 | 125 | export interface PopularReleasesAlbumsItem { 126 | coverArt: AvatarImage; 127 | date: ItemDate; 128 | id: string; 129 | name: string; 130 | playability: ItemPlayability; 131 | sharingInfo: AlbumUnionSharingInfo; 132 | type: Type; 133 | uri: string; 134 | } 135 | 136 | export interface ItemDate { 137 | year: number; 138 | } 139 | 140 | export interface ItemPlayability { 141 | playable: boolean; 142 | reason: "PLAYABLE"; 143 | } 144 | 145 | export interface AlbumUnionSharingInfo { 146 | shareId: string; 147 | shareUrl: string; 148 | } 149 | 150 | export enum Type { 151 | Album = "ALBUM", 152 | Single = "SINGLE", 153 | } 154 | 155 | export interface Releases { 156 | items: ReleasesItem[]; 157 | totalCount: number; 158 | } 159 | 160 | export interface ReleasesItem { 161 | name: string; 162 | uri: string; 163 | } 164 | 165 | export interface AlbumUnionTracks { 166 | items: TracksItem[]; 167 | totalCount: number; 168 | } 169 | 170 | export interface TracksItem { 171 | track: Track; 172 | uid: string; 173 | } 174 | 175 | export interface Track { 176 | artists: TrackArtists; 177 | associations: Associations; 178 | contentRating: ContentRating; 179 | discNumber: number; 180 | duration: Duration; 181 | name: string; 182 | playability: TrackPlayability; 183 | playcount: string; 184 | relinkingInformation: null; 185 | saved: boolean; 186 | trackNumber: number; 187 | uri: string; 188 | } 189 | 190 | export interface TrackArtists { 191 | items: TrackArtist[]; 192 | } 193 | 194 | export interface TrackArtist { 195 | profile: Profile; 196 | uri: string; 197 | } 198 | 199 | export interface Associations { 200 | associatedVideos: AssociatedVideosClass; 201 | } 202 | 203 | export interface ContentRating { 204 | label: string; 205 | } 206 | 207 | export interface Duration { 208 | totalMilliseconds: number; 209 | } 210 | 211 | export interface TrackPlayability { 212 | playable: boolean; 213 | } 214 | -------------------------------------------------------------------------------- /projects/stats/src/types/spotify.ts: -------------------------------------------------------------------------------- 1 | // note: some fields are missing from the types as they are not relevant to the project 2 | 3 | export enum SpotifyRange { 4 | Short = "short_term", 5 | Medium = "medium_term", 6 | Long = "long_term", 7 | } 8 | 9 | interface AudioFeatures { 10 | acousticness: number; 11 | danceability: number; 12 | energy: number; 13 | instrumentalness: number; 14 | key: number; 15 | liveness: number; 16 | loudness: number; 17 | mode: number; 18 | speechiness: number; 19 | tempo: number; 20 | time_signature: number; 21 | valence: number; 22 | } 23 | 24 | interface AudioFeaturesWrapper extends AudioFeatures { 25 | uri: string; 26 | id: string; 27 | analysis_url: string; 28 | duration_ms: number; 29 | track_href: string; 30 | type: "audio_features"; 31 | } 32 | 33 | interface Image { 34 | url: string; 35 | height: number | null; 36 | width: number | null; 37 | } 38 | 39 | export interface SimplifiedArtist { 40 | href: string; 41 | id: string; 42 | name: string; 43 | type: "artist"; 44 | uri: string; 45 | } 46 | 47 | export interface Artist extends SimplifiedArtist { 48 | followers: { 49 | total: number; 50 | }; 51 | genres: string[]; 52 | images: Image[]; 53 | popularity: number; 54 | } 55 | 56 | export interface SimplifiedAlbum { 57 | album_type: "album" | "single" | "compilation"; 58 | total_tracks: number; 59 | href: string; 60 | id: string; 61 | images: Image[]; 62 | name: string; 63 | release_date: string; 64 | release_date_precision: "year" | "month" | "day"; 65 | type: "album"; 66 | uri: string; 67 | artists: SimplifiedArtist[]; 68 | } 69 | 70 | export interface Album extends SimplifiedAlbum { 71 | tracks: Items; 72 | external_ids: { 73 | isrc: string; 74 | ean: string; 75 | upc: string; 76 | }; 77 | genres: string[]; // this seems to be missing in the response 78 | label: string; 79 | popularity: number; 80 | } 81 | 82 | interface SimplifiedTrack { 83 | disc_number: number; 84 | duration_ms: number; 85 | explicit: boolean; 86 | href: string; 87 | id: string; 88 | name: string; 89 | track_number: number; 90 | type: "track"; 91 | uri: string; 92 | is_local: boolean; 93 | } 94 | 95 | export interface Track extends SimplifiedTrack { 96 | album: SimplifiedAlbum; 97 | artists: SimplifiedArtist[]; 98 | external_ids?: { 99 | isrc: string; 100 | ean: string; 101 | upc: string; 102 | }; 103 | popularity: number; 104 | } 105 | 106 | interface PlaylistSimplified { 107 | collaborative: boolean; 108 | description: string | null; 109 | external_urls: { 110 | spotify: string; 111 | }; 112 | href: string; 113 | id: string; 114 | images: Image[]; 115 | name: string; 116 | owner: User; 117 | public: boolean | null; 118 | snapshot_id: string; 119 | tracks: { 120 | href: string; 121 | total: number; 122 | }; 123 | type: "playlist"; 124 | uri: string; 125 | } 126 | 127 | interface Playlist extends PlaylistSimplified { 128 | followers: { 129 | href: string | null; 130 | total: number; 131 | }; 132 | tracks: Items; 133 | } 134 | 135 | interface User { 136 | external_urls: { 137 | spotify: string; 138 | }; 139 | followers: { 140 | href: string | null; 141 | total: number; 142 | }; 143 | href: string; 144 | id: string; 145 | type: "user"; 146 | uri: string; 147 | } 148 | 149 | interface Episode { 150 | type: "episode"; 151 | } 152 | 153 | export interface PlaylistTrack { 154 | added_at: string; 155 | added_by: User; 156 | is_local: boolean; 157 | track: Track | Episode | null; // the track can be unavailable 158 | } 159 | 160 | interface Items { 161 | href: string; 162 | limit: number; 163 | next: string | null; 164 | offset: number; 165 | previous: string | null; 166 | total: number; 167 | items: T[]; 168 | } 169 | 170 | export interface SeveralTracksResponse { 171 | tracks: Track[]; 172 | } 173 | 174 | export interface SeveralArtistsResponse { 175 | artists: Artist[]; 176 | } 177 | 178 | export interface SeveralAlbumsResponse { 179 | albums: Album[]; 180 | } 181 | 182 | export interface SeveralAudioFeaturesResponse { 183 | audio_features: (AudioFeaturesWrapper | null)[]; 184 | } 185 | 186 | export interface SearchResponse { 187 | tracks: Items; 188 | artists: Items; 189 | albums: Items; 190 | // and other irrelevant object types eg. playlists 191 | } 192 | 193 | export type TopArtistsResponse = Items; 194 | 195 | export type TopTracksResponse = Items; 196 | 197 | export type UserPlaylistsResponse = Items; 198 | 199 | export type PlaylistResponse = Playlist; 200 | -------------------------------------------------------------------------------- /projects/stats/src/pages/charts.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import useDropdownMenu from "@shared/dropdown/useDropdownMenu"; 3 | import SpotifyCard from "@shared/components/spotify_card"; 4 | import TrackRow from "../components/track_row"; 5 | import Tracklist from "../components/tracklist"; 6 | import PageContainer from "@shared/components/page_container"; 7 | import type { 8 | Config, 9 | ConfigWrapper, 10 | LastFMMinifiedArtist, 11 | LastFMMinifiedTrack, 12 | SpotifyMinifiedArtist, 13 | SpotifyMinifiedTrack, 14 | } from "../types/stats_types"; 15 | import * as lastFM from "../api/lastfm"; 16 | import RefreshButton from "../components/buttons/refresh_button"; 17 | import SettingsButton from "@shared/components/settings_button"; 18 | import { convertArtist, convertTrack } from "../utils/converter"; 19 | import useStatus from "@shared/status/useStatus"; 20 | import { useQuery } from "@shared/types/react_query"; 21 | import { cacher, invalidator } from "../extensions/cache"; 22 | // @ts-ignore 23 | import _ from "lodash"; 24 | import { parseLiked } from "../utils/track_helper"; 25 | 26 | export const formatNumber = (num: number) => { 27 | if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`; 28 | if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`; 29 | if (num >= 1e3) return `${(num / 1e3).toFixed(2)}K`; 30 | return num.toString(); 31 | }; 32 | 33 | const DropdownOptions = [ 34 | { id: "artists", name: "Top Artists" }, 35 | { id: "tracks", name: "Top Tracks" }, 36 | ]; 37 | 38 | const getChart = async (type: "tracks" | "artists", config: Config) => { 39 | const { "api-key": key } = config; 40 | if (!key) throw new Error("Missing LastFM API Key or Username"); 41 | if (type === "artists") { 42 | const response = await lastFM.getArtistChart(key); 43 | return Promise.all(response.map(convertArtist)); 44 | } 45 | const response = await lastFM.getTrackChart(key); 46 | return Promise.all(response.map(convertTrack)); 47 | }; 48 | 49 | const ArtistChart = ({ artists }: { artists: (LastFMMinifiedArtist | SpotifyMinifiedArtist)[] }) => { 50 | return ( 51 |
    52 | {artists.map((artist, index) => { 53 | return ( 54 | 63 | ); 64 | })} 65 |
    66 | ); 67 | }; 68 | 69 | const TrackChart = ({ tracks }: { tracks: (LastFMMinifiedTrack | SpotifyMinifiedTrack)[] }) => { 70 | return ( 71 | 72 | {tracks.map((track, index) => ( 73 | track.uri)} /> 74 | ))} 75 | 76 | ); 77 | }; 78 | 79 | const getDate = () => { 80 | return new Date().toLocaleDateString("en-US", { 81 | year: "numeric", 82 | month: "2-digit", 83 | day: "2-digit", 84 | }); 85 | }; 86 | 87 | const ChartsPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => { 88 | const [dropdown, activeOption] = useDropdownMenu(DropdownOptions, "stats:charts"); 89 | 90 | const { status, error, data, refetch } = useQuery({ 91 | queryKey: ["top-charts", activeOption.id], 92 | queryFn: (props) => 93 | cacher(() => getChart(activeOption.id as "tracks" | "artists", configWrapper.config))(props).then((res) => 94 | "artists" in res[0] ? parseLiked(res) : res, 95 | ), 96 | }); 97 | 98 | const Status = useStatus(status, error); 99 | 100 | const props = { 101 | lhs: [`Top Charts - ${_.startCase(activeOption.id)}`], 102 | rhs: [ 103 | dropdown, 104 | invalidator(["top-charts", activeOption.id], refetch)} />, 105 | , 106 | ], 107 | }; 108 | 109 | if (Status) return {Status}; 110 | 111 | const items = data as NonNullable; 112 | 113 | const infoToCreatePlaylist = { 114 | playlistName: `Top Track Chart - ${getDate()}`, 115 | itemsUris: items.map((track) => track.uri), 116 | }; 117 | 118 | if (activeOption.id === "tracks") { 119 | // @ts-ignore 120 | props.infoToCreatePlaylist = infoToCreatePlaylist; 121 | } 122 | 123 | // @ts-ignore 124 | const chartToRender = activeOption.id === "artists" ? : ; 125 | 126 | return {chartToRender}; 127 | }; 128 | 129 | export default React.memo(ChartsPage); 130 | -------------------------------------------------------------------------------- /projects/library/src/styles/external.scss: -------------------------------------------------------------------------------- 1 | body.hide-library-button { 2 | // hide sidebar heading element 3 | &:not(.show-ylx-filters) .main-yourLibraryX-filterArea:not(:has(> .main-yourLibraryX-libraryFilter)), 4 | .main-yourLibraryX-header:not(:has(> .main-yourLibraryX-headerContent > .main-yourLibraryX-collapseButton > button:nth-child(2))), 5 | // hide back button and default collapse button 6 | .main-yourLibraryX-collapseButton > button:first-child, 7 | .main-yourLibraryX-headerContent > button { 8 | display: none; 9 | } 10 | 11 | // adjust sidebar padding 12 | .main-yourLibraryX-library { 13 | padding-top: 8px; 14 | } 15 | .main-yourLibraryX-header { 16 | margin-top: -8px; 17 | } 18 | 19 | .main-yourLibraryX-libraryFilter { 20 | --encore-spacing-base: 8px; 21 | 22 | // hide sort-by text 23 | span[role="presentation"] button span:first-child, 24 | span[role="presentation"] button span:first-child { 25 | display: none; 26 | } 27 | 28 | // push sort button to the right within its flex parent 29 | span[role="presentation"] { 30 | margin-left: auto; 31 | } 32 | } 33 | } 34 | 35 | 36 | // hide weird button effect 37 | .toggle-filters-button > button:after, 38 | .collapse-button > button:after, 39 | .expand-button > button:after { 40 | display: none; 41 | } 42 | 43 | 44 | // expand button positioning 45 | .expand-button { 46 | display: flex; 47 | align-items:center; 48 | z-index: 1; 49 | margin-left: 5px; 50 | } 51 | .expand-button > button { 52 | visibility: hidden; 53 | margin: 0 5px; 54 | } 55 | li.main-yourLibraryX-navItem[data-id='/library'] { 56 | display: flex; 57 | } 58 | li.main-yourLibraryX-navItem[data-id='/library'] > a { 59 | flex-grow: 1; 60 | } 61 | .main-yourLibraryX-libraryFilter { 62 | // ylx button styling 63 | .toggle-filters-button > button, 64 | .collapse-button > button, 65 | .main-yourLibraryX-librarySortWrapper > button, 66 | span[role="presentation"] span[role="presentation"] > button { 67 | padding: 0; 68 | } 69 | .toggle-filters-button, 70 | .collapse-button, 71 | .main-yourLibraryX-librarySortWrapper, 72 | span[role="presentation"] span[role="presentation"] { 73 | display: flex; 74 | flex-basis:32px; 75 | justify-content: center; 76 | min-width: 24px; 77 | flex-shrink: 10; 78 | } 79 | .main-yourLibraryX-librarySortWrapper > button > span:nth-child(2), 80 | span[role="presentation"] span[role="presentation"] > button > span:nth-child(2) { 81 | margin: 0; 82 | } 83 | } 84 | 85 | 86 | // remove resize bar hover effect 87 | .LayoutResizer__resize-bar { 88 | opacity: 0 !important; 89 | } 90 | 91 | 92 | .Root__nav-bar { 93 | // fix search bar expansion 94 | .x-filterBox-expandedOrHasFilter .x-filterBox-filterInput { 95 | width: 100%; 96 | } 97 | .x-filterBox-expandedOrHasFilter { 98 | flex-grow: 1; 99 | margin-right: 5px; 100 | } 101 | 102 | // expand button visibility 103 | &:has(> .LayoutResizer__resize-bar:hover) .expand-button > button, 104 | .expand-button:hover > button { 105 | visibility: visible; 106 | height: 32px; 107 | background-color: black; 108 | } 109 | } 110 | 111 | .text-input-form { 112 | // copied directly from spotify 113 | .Button-small-buttonPrimary { 114 | box-sizing: border-box; 115 | -webkit-tap-highlight-color: transparent; 116 | font-size: 0.875rem; 117 | font-weight: 700; 118 | font-family: var(--font-family,CircularSp,CircularSp-Arab,CircularSp-Hebr,CircularSp-Cyrl,CircularSp-Grek,CircularSp-Deva,var(--fallback-fonts,sans-serif)); 119 | background-color: transparent; 120 | border: 0px; 121 | border-radius: 9999px; 122 | cursor: pointer; 123 | display: inline-block; 124 | position: relative; 125 | text-align: center; 126 | text-decoration: none; 127 | text-transform: none; 128 | touch-action: manipulation; 129 | transition-duration: 33ms; 130 | transition-property: background-color, border-color, color, box-shadow, filter, transform; 131 | user-select: none; 132 | vertical-align: middle; 133 | transform: translate3d(0px, 0px, 0px); 134 | padding: 0px; 135 | min-inline-size: 0px; 136 | } 137 | .ButtonInner-small { 138 | box-sizing: border-box; 139 | -webkit-tap-highlight-color: transparent; 140 | position: relative; 141 | background-color: var(--background-base,#1ed760); 142 | color: var(--text-base,#000000); 143 | display: flex; 144 | border-radius: 9999px; 145 | font-size: inherit; 146 | min-block-size: var(--encore-control-size-smaller,32px); 147 | -webkit-box-align: center; 148 | align-items: center; 149 | -webkit-box-pack: center; 150 | justify-content: center; 151 | padding-block-start: var(--encore-spacing-tighter-4,4px); 152 | padding-block-end: var(--encore-spacing-tighter-4,4px); 153 | padding-inline-start: var(--encore-spacing-base,16px); 154 | padding-inline-end: var(--encore-spacing-base,16px); 155 | } 156 | .Button-small-buttonPrimary:hover .ButtonInner-sc-14ud5tc-0, .Button-small-buttonPrimary:hover .ButtonFocus-sc-2hq6ey-0 { 157 | transform: scale(1.04); 158 | } 159 | } 160 | 161 | -------------------------------------------------------------------------------- /projects/library/src/pages/albums.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import SearchBar from "../components/searchbar"; 3 | import type { ConfigWrapper } from "../types/library_types"; 4 | import SettingsButton from "@shared/components/settings_button"; 5 | import useDropdownMenu from "@shared/dropdown/useDropdownMenu"; 6 | import PageContainer from "@shared/components/page_container"; 7 | import SpotifyCard from "@shared/components/spotify_card"; 8 | import LoadMoreCard from "../components/load_more_card"; 9 | import AddButton from "../components/add_button"; 10 | import TextInputDialog from "../components/text_input_dialog"; 11 | import LeadingIcon from "../components/leading_icon"; 12 | import useStatus from "@shared/status/useStatus"; 13 | import { useInfiniteQuery, useQuery } from "@shared/types/react_query"; 14 | import type { AlbumItem, GetContentsResponse, UpdateEvent } from "../types/platform"; 15 | import PinIcon from "../components/pin_icon"; 16 | import useSortDropdownMenu from "@shared/dropdown/useSortDropdownMenu"; 17 | import collectionSort from "../utils/collection_sort"; 18 | 19 | const AddMenu = () => { 20 | const { MenuItem, Menu } = Spicetify.ReactComponent; 21 | const { SVGIcons } = Spicetify; 22 | 23 | const addAlbum = () => { 24 | const onSave = (value: string) => { 25 | Spicetify.Platform.LibraryAPI.add({ uris: [value] }); 26 | }; 27 | 28 | Spicetify.PopupModal.display({ 29 | title: "Add Album", 30 | // @ts-ignore 31 | content: , 32 | }); 33 | }; 34 | 35 | return ( 36 | 37 | }> 38 | Add Album 39 | 40 | 41 | ); 42 | }; 43 | 44 | const limit = 200; 45 | 46 | const sortOptions = [ 47 | { id: "0", name: "Name" }, 48 | { id: "1", name: "Date Added" }, 49 | { id: "2", name: "Artist Name" }, 50 | { id: "6", name: "Recents" }, 51 | ]; 52 | 53 | const filterOptions = [ 54 | { id: "0", name: "All" }, 55 | { id: "1", name: "Albums" }, 56 | { id: "2", name: "Local Albums" }, 57 | ]; 58 | 59 | function isValidAlbum(album: AlbumItem) { 60 | const primaryArtist = album.artists?.[0]; 61 | return album.name && album.uri && primaryArtist?.name && primaryArtist?.uri; 62 | } 63 | 64 | const AlbumsPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => { 65 | const [sortDropdown, sortOption, isReversed] = useSortDropdownMenu(sortOptions, "library:albums"); 66 | const [filterDropdown, filterOption] = useDropdownMenu(filterOptions); 67 | const [textFilter, setTextFilter] = React.useState(""); 68 | 69 | const fetchAlbums = async ({ pageParam }: { pageParam: number }) => { 70 | const res = (await Spicetify.Platform.LibraryAPI.getContents({ 71 | filters: ["0"], 72 | sortOrder: sortOption.id, 73 | textFilter, 74 | sortDirection: isReversed ? "reverse" : undefined, 75 | offset: pageParam, 76 | limit, 77 | })) as GetContentsResponse; 78 | return res; 79 | }; 80 | 81 | const fetchLocalAlbums = async () => { 82 | const localAlbums = await CollectionsWrapper.getLocalAlbums(); 83 | let albums = localAlbums.values().toArray() as AlbumItem[]; 84 | 85 | if (textFilter) { 86 | const regex = new RegExp(`\\b${textFilter}`, "i"); 87 | albums = albums.filter((album) => { 88 | return regex.test(album.name) || album.artists.some((artist) => regex.test(artist.name)); 89 | }); 90 | } 91 | 92 | return albums; 93 | }; 94 | 95 | const { data, status, error, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery({ 96 | queryKey: ["library:albums", sortOption.id, isReversed, textFilter], 97 | queryFn: fetchAlbums, 98 | initialPageParam: 0, 99 | getNextPageParam: (lastPage) => { 100 | const current = lastPage.offset + limit; 101 | if (lastPage.totalLength > current) return current; 102 | }, 103 | }); 104 | 105 | const { 106 | data: localData, 107 | status: localStatus, 108 | error: localError, 109 | } = useQuery({ 110 | queryKey: ["library:localAlbums", textFilter], 111 | queryFn: fetchLocalAlbums, 112 | enabled: configWrapper.config.localAlbums, 113 | }); 114 | 115 | useEffect(() => { 116 | const update = (e: UpdateEvent) => { 117 | if (e.data.list === "albums") refetch(); 118 | }; 119 | Spicetify.Platform.LibraryAPI.getEvents()._emitter.addListener("update", update, {}); 120 | return () => { 121 | Spicetify.Platform.LibraryAPI.getEvents()._emitter.removeListener("update", update); 122 | }; 123 | }, [refetch]); 124 | 125 | const Status = useStatus(status, error); 126 | const LocalStatus = configWrapper.config.localAlbums && useStatus(localStatus, localError); 127 | const EmptyStatus = useStatus("error", new Error("No albums found")) as React.ReactElement; 128 | 129 | const props = { 130 | lhs: ["Albums"], 131 | rhs: [ 132 | } />, 133 | filterDropdown, 134 | sortDropdown, 135 | , 136 | , 137 | ], 138 | }; 139 | 140 | if (Status) return {Status}; 141 | if (LocalStatus) return {LocalStatus}; 142 | 143 | const contents = data as NonNullable; 144 | 145 | let albums = filterOption.id !== "2" ? contents.pages.flatMap((page) => page.items) : []; 146 | if (localData?.length && filterOption.id !== "1") { 147 | albums = albums.concat(localData).sort(collectionSort(sortOption.id, isReversed)); 148 | } 149 | 150 | if (albums.length === 0) return {EmptyStatus}; 151 | 152 | const albumCards = albums.filter(isValidAlbum).map((item) => ( 153 | : undefined} 161 | /> 162 | )); 163 | 164 | if (hasNextPage) albumCards.push(); 165 | 166 | return ( 167 | 168 |
    {albumCards}
    169 |
    170 | ); 171 | }; 172 | 173 | export default AlbumsPage; 174 | -------------------------------------------------------------------------------- /projects/library/src/pages/collections.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import SearchBar from "../components/searchbar"; 3 | import PageContainer from "@shared/components/page_container"; 4 | import SpotifyCard from "@shared/components/spotify_card"; 5 | import SettingsButton from "@shared/components/settings_button"; 6 | import AddButton from "../components/add_button"; 7 | import type { ConfigWrapper } from "../types/library_types"; 8 | import LoadMoreCard from "../components/load_more_card"; 9 | import TextInputDialog from "../components/text_input_dialog"; 10 | import LeadingIcon from "../components/leading_icon"; 11 | import { useInfiniteQuery } from "@shared/types/react_query"; 12 | import useStatus from "@shared/status/useStatus"; 13 | import useSortDropdownMenu from "@shared/dropdown/useSortDropdownMenu"; 14 | import BackButton from "../components/back_button"; 15 | import CustomCard from "../components/custom_card"; 16 | import { CollectionChild } from "../extensions/collections_wrapper"; 17 | 18 | const AddMenu = ({ collection }: { collection?: string }) => { 19 | const { MenuItem, Menu } = Spicetify.ReactComponent; 20 | const { SVGIcons } = Spicetify; 21 | 22 | const createCollection = () => { 23 | const onSave = (value: string) => { 24 | CollectionsWrapper.createCollection(value || "New Collection", collection); 25 | }; 26 | 27 | Spicetify.PopupModal.display({ 28 | title: "Create Collection", 29 | content: , 30 | }); 31 | }; 32 | 33 | const createDiscogCollection = () => { 34 | const onSave = (value: string) => { 35 | CollectionsWrapper.createCollectionFromDiscog(value); 36 | }; 37 | 38 | Spicetify.PopupModal.display({ 39 | title: "Create Discog Collection", 40 | content: , 41 | }); 42 | }; 43 | 44 | const addAlbum = () => { 45 | if (!collection) return; 46 | const onSave = (value: string) => { 47 | CollectionsWrapper.addAlbumToCollection(collection, value); 48 | }; 49 | 50 | Spicetify.PopupModal.display({ 51 | title: "Add Album", 52 | content: , 53 | }); 54 | }; 55 | 56 | return ( 57 | 58 | }> 59 | Create Collection 60 | 61 | }> 62 | Create Discog Collection 63 | 64 | {collection && ( 65 | }> 66 | Add Album 67 | 68 | )} 69 | 70 | ); 71 | }; 72 | 73 | function isValidCollectionItem(item: CollectionChild) { 74 | return item.name && item.uri; 75 | } 76 | 77 | const limit = 200; 78 | 79 | const sortOptions = [ 80 | { id: "0", name: "Name" }, 81 | { id: "1", name: "Date Added" }, 82 | { id: "2", name: "Artist Name" }, 83 | { id: "6", name: "Recents" }, 84 | ]; 85 | 86 | const CollectionsPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => { 87 | const [sortDropdown, sortOption, isReversed] = useSortDropdownMenu(sortOptions, "library:collections"); 88 | const [textFilter, setTextFilter] = React.useState(""); 89 | 90 | const collection = Spicetify.Platform.History.location.pathname.split("/")[3]; 91 | 92 | const fetchRootlist = async ({ pageParam }: { pageParam: number }) => { 93 | const res = await CollectionsWrapper.getContents({ 94 | collectionUri: collection, 95 | textFilter, 96 | offset: pageParam, 97 | sortOrder: sortOption.id, 98 | sortDirection: isReversed ? "reverse" : undefined, 99 | limit, 100 | }); 101 | if (!res.items.length) throw new Error("No collections found"); 102 | return res; 103 | }; 104 | 105 | const { data, status, error, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery({ 106 | queryKey: ["library:collections", textFilter, collection, isReversed, sortOption.id], 107 | queryFn: fetchRootlist, 108 | initialPageParam: 0, 109 | getNextPageParam: (lastPage) => { 110 | const current = lastPage.offset + limit; 111 | if (lastPage.totalLength > current) return current; 112 | }, 113 | retry: false, 114 | structuralSharing: false, 115 | }); 116 | 117 | useEffect(() => { 118 | const update = (e: CustomEvent | Event) => { 119 | refetch(); 120 | }; 121 | CollectionsWrapper.addEventListener("update", update); 122 | return () => { 123 | CollectionsWrapper.removeEventListener("update", update); 124 | }; 125 | }, [refetch]); 126 | 127 | const Status = useStatus(status, error); 128 | 129 | const props = { 130 | lhs: [ 131 | collection ? : null, 132 | data?.pages[0].openedCollectionName || "Collections" 133 | ], 134 | rhs: [ 135 | } />, 136 | sortDropdown, 137 | , 138 | , 139 | ], 140 | }; 141 | 142 | if (Status) return {Status}; 143 | 144 | const contents = data as NonNullable; 145 | 146 | const items = contents.pages.flatMap((page) => page.items); 147 | 148 | // TODO: fix the typing to explictly allow localalbums 149 | const rootlistCards = items.filter(isValidCollectionItem).map((item) => ( 150 | item.type === "album" ? 151 | : 158 | 165 | )); 166 | 167 | if (hasNextPage) rootlistCards.push(); 168 | 169 | return ( 170 | 171 |
    {rootlistCards}
    172 |
    173 | ); 174 | }; 175 | 176 | export default CollectionsPage; 177 | -------------------------------------------------------------------------------- /projects/stats/src/utils/track_helper.ts: -------------------------------------------------------------------------------- 1 | import { getAlbumMetas, queryInLibrary } from "../api/platform"; 2 | import { getArtistMetas, getAudioFeatures } from "../api/spotify"; 3 | import { batchCacher } from "../extensions/cache"; 4 | import type { AlbumUnion } from "../types/graph_ql"; 5 | import type { Album, Artist, ContentsEpisode, ContentsTrack } from "../../../shared/types/platform"; 6 | import type { LastFMMinifiedTrack, SpotifyMinifiedAlbum, SpotifyMinifiedTrack } from "../types/stats_types"; 7 | import { minifyAlbum, minifyArtist } from "./converter"; 8 | 9 | export const batchRequest = (size: number, request: (batch: string[]) => Promise) => { 10 | return (ids: string[]) => { 11 | const chunks = []; 12 | for (let i = 0; i < ids.length; i += size) { 13 | chunks.push(ids.slice(i, i + size)); 14 | } 15 | 16 | return Promise.all(chunks.map((chunk) => request(chunk).catch(() => []))).then((res) => res.flat()); 17 | }; 18 | }; 19 | 20 | export const getMeanAudioFeatures = async (ids: string[]) => { 21 | const audioFeaturesSum = { 22 | danceability: 0, 23 | energy: 0, 24 | speechiness: 0, 25 | acousticness: 0, 26 | instrumentalness: 0, 27 | liveness: 0, 28 | valence: 0, 29 | tempo: 0, 30 | }; 31 | 32 | const audioFeaturesList = await batchCacher("features", batchRequest(100, getAudioFeatures))(ids); 33 | 34 | for (const audioFeatures of audioFeaturesList) { 35 | if (!audioFeatures) continue; 36 | for (const f of Object.keys(audioFeaturesSum) as (keyof typeof audioFeaturesSum)[]) { 37 | audioFeaturesSum[f] += audioFeatures[f]; 38 | } 39 | } 40 | 41 | for (const f of Object.keys(audioFeaturesSum) as (keyof typeof audioFeaturesSum)[]) { 42 | audioFeaturesSum[f] /= audioFeaturesList.length; 43 | } 44 | 45 | return audioFeaturesSum; 46 | }; 47 | 48 | export const minifyAlbumUnion = (album: AlbumUnion): SpotifyMinifiedAlbum => ({ 49 | id: album.uri.split(":")[2], 50 | uri: album.uri, 51 | name: album.name, 52 | image: album.coverArt.sources[0]?.url, 53 | type: "spotify", 54 | }); 55 | 56 | /** 57 | * Parses the raw album data and returns a list of the top 100 artists along with their frequencies and release years. 58 | * @param artistsRaw - The raw album data to be parsed. 59 | * @returns An object containing the top 100 albums with their frequencies and release years calculated from them. 60 | */ 61 | export const parseAlbums = async (albumsRaw: Album[]) => { 62 | const frequencyMap = {} as Record; 63 | const albumURIs = albumsRaw.map((album) => album.uri); 64 | for (const uri of albumURIs) { 65 | frequencyMap[uri] = (frequencyMap[uri] || 0) + 1; 66 | } 67 | const uris = Object.keys(frequencyMap) 68 | .sort((a, b) => frequencyMap[b] - frequencyMap[a]) 69 | .slice(0, 500); 70 | const albums = (await batchCacher("album", getAlbumMetas)(uris)).filter(Boolean); 71 | const releaseYears = {} as Record; 72 | const uniqueAlbums = albums.map((album) => { 73 | if (album?.date?.isoString) { 74 | const year = new Date(album.date.isoString).getFullYear().toString(); 75 | releaseYears[year] = (releaseYears[year] || 0) + frequencyMap[album.uri]; 76 | } 77 | return { ...minifyAlbumUnion(album), frequency: frequencyMap[album.uri] }; 78 | }); 79 | return { releaseYears, albums: { contents: uniqueAlbums, length: Object.keys(frequencyMap).length } }; 80 | }; 81 | 82 | /** 83 | * Parses the raw artist data and returns a list of the top 250 artists along with their frequencies and genres. 84 | * @param artistsRaw - The raw artist data to be parsed. 85 | * @returns An object containing the top 250 artists with their frequencies and genres calculated from them. 86 | */ 87 | export const parseArtists = async (artistsRaw: Omit[]) => { 88 | const frequencyMap = {} as Record; 89 | const artistIDs = artistsRaw.map((artist) => artist.uri.split(":")[2]); 90 | for (const id of artistIDs) { 91 | frequencyMap[id] = (frequencyMap[id] || 0) + 1; 92 | } 93 | const ids = Object.keys(frequencyMap) 94 | .sort((a, b) => frequencyMap[b] - frequencyMap[a]) 95 | .slice(0, 250); 96 | const artists = await batchCacher("artist", batchRequest(50, getArtistMetas))(ids); 97 | const genres = {} as Record; 98 | const uniqueArtists = artists.map((artist) => { 99 | for (const genre of artist.genres) { 100 | genres[genre] = (genres[genre] || 0) + frequencyMap[artist.id]; 101 | } 102 | return { ...minifyArtist(artist), frequency: frequencyMap[artist.id] }; 103 | }); 104 | return { genres, artists: { contents: uniqueArtists, length: Object.keys(frequencyMap).length } }; 105 | }; 106 | 107 | export const parseTracks = async (tracks: (ContentsTrack | ContentsEpisode)[]) => { 108 | const trackIDs: string[] = []; 109 | const albumsRaw: Album[] = []; 110 | const artistsRaw: Artist[] = []; 111 | let explicit = 0; 112 | // let popularity = 0; 113 | let duration = 0; 114 | 115 | for (const track of tracks) { 116 | if (track?.type !== "track" || track.isLocal) continue; 117 | // popularity += track.popularity; 118 | duration += track.duration.milliseconds; 119 | explicit += track.isExplicit ? 1 : 0; 120 | trackIDs.push(track.uri.split(":")[2]); 121 | albumsRaw.push(track.album); 122 | artistsRaw.push(...track.artists); 123 | } 124 | 125 | explicit = explicit / trackIDs.length; 126 | // popularity = popularity / trackIDs.length; 127 | 128 | const audioFeatures = await getMeanAudioFeatures(trackIDs); 129 | const analysis = { ...audioFeatures, explicit }; 130 | const { genres, artists } = await parseArtists(artistsRaw); 131 | const { releaseYears, albums } = await parseAlbums(albumsRaw); 132 | 133 | return { 134 | analysis, 135 | genres, 136 | artists, 137 | albums, 138 | releaseYears, 139 | duration, 140 | length: trackIDs.length, 141 | }; 142 | }; 143 | 144 | export const parseStat = (name: string) => { 145 | switch (name) { 146 | case "tempo": 147 | return (v: number) => `${Math.round(v)} bpm`; 148 | case "popularity": 149 | return (v: number) => `${Math.round(v)}%`; 150 | default: 151 | return (v: number) => `${Math.round(v * 100)}%`; 152 | } 153 | }; 154 | 155 | export const parseLiked = async (tracks: (SpotifyMinifiedTrack | LastFMMinifiedTrack)[]) => { 156 | const trackURIs = tracks.filter((t) => t.type === "spotify").map((t) => t.uri); 157 | const liked = await queryInLibrary(trackURIs); 158 | const likedMap = new Map(trackURIs.map((id, i) => [id, liked[i]])); 159 | return tracks.map((t) => ({ ...t, liked: t.type === "spotify" ? (likedMap.get(t.uri) as boolean) : false })); 160 | }; 161 | -------------------------------------------------------------------------------- /projects/library/src/extensions/extension.tsx: -------------------------------------------------------------------------------- 1 | import ConfigWrapper from "@shared/config/config_wrapper"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import ToggleFiltersButton from "../components/toggle_filters"; 5 | import CollapseButton from "../components/collapse_button"; 6 | import AlbumMenuItem from "../components/album_menu_item"; 7 | import ArtistMenuItem from "../components/artist_menu_item"; 8 | import "../extensions/collections_wrapper"; 9 | import "../extensions/folder_image_wrapper"; 10 | 11 | // inject css 12 | const styleLink = document.createElement("link"); 13 | styleLink.rel = "stylesheet"; 14 | styleLink.href = "/spicetify-routes-library.css"; 15 | document.head.appendChild(styleLink); 16 | 17 | const setCardSize = (size: string) => { 18 | document.documentElement.style.setProperty("--library-card-size", `${size}px`); 19 | }; 20 | 21 | const setSearchBarSize = (enlarged: boolean) => { 22 | const size = enlarged ? 300 : 200; 23 | document.documentElement.style.setProperty("--library-searchbar-size", `${size}px`); 24 | }; 25 | 26 | const FolderImage = ({ url }: { url: string }) => { 27 | return ( 28 | 36 | ); 37 | }; 38 | 39 | const FolderPlaceholder = () => { 40 | return ( 41 |
    42 | 51 |
    52 | ); 53 | }; 54 | 55 | // contruct global class for library methods 56 | class SpicetifyLibrary { 57 | ConfigWrapper = new ConfigWrapper( 58 | [ 59 | { 60 | name: "Card Size", 61 | key: "cardSize", 62 | type: "slider", 63 | min: 100, 64 | max: 200, 65 | step: 0.05, 66 | def: 180, 67 | callback: setCardSize, 68 | }, 69 | { 70 | name: "Extend Search Bar", 71 | key: "extendSearchBar", 72 | type: "toggle", 73 | def: false, 74 | callback: setSearchBarSize, 75 | }, 76 | { 77 | name: "Add Local Albums Integration", 78 | key: "localAlbums", 79 | type: "toggle", 80 | def: true, 81 | desc: "You need to install the better-local-files app for this to work.", 82 | }, 83 | { 84 | name: "Include Liked Songs Playlist", 85 | key: "includeLikedSongs", 86 | type: "toggle", 87 | def: true, 88 | }, 89 | { 90 | name: "Include Local Files Playlist", 91 | key: "includeLocalFiles", 92 | type: "toggle", 93 | def: true, 94 | }, 95 | { 96 | name: "Hide 'Your Library' Button", 97 | key: "hideLibraryButton", 98 | type: "toggle", 99 | def: false, 100 | desc: "This is experimental and may break the sidebar layout in some cases. Requires a spotify restart to take effect.", 101 | sectionHeader: "Left Sidebar", 102 | }, 103 | { 104 | name: "Playlists Page", 105 | key: "show-playlists", 106 | type: "toggle", 107 | def: true, 108 | sectionHeader: "Pages", 109 | }, 110 | { name: "Albums Page", key: "show-albums", type: "toggle", def: true }, 111 | { name: "Collections Page", key: "show-collections", type: "toggle", def: true }, 112 | { name: "Artists Page", key: "show-artists", type: "toggle", def: true }, 113 | { name: "Shows Page", key: "show-shows", type: "toggle", def: true }, 114 | ], 115 | "library", 116 | ); 117 | } 118 | window.SpicetifyLibrary = new SpicetifyLibrary(); 119 | 120 | (function wait() { 121 | const { LocalStorageAPI } = Spicetify.Platform; 122 | if (!LocalStorageAPI) { 123 | setTimeout(wait, 100); 124 | return; 125 | } 126 | main(LocalStorageAPI); 127 | })(); 128 | 129 | // biome-ignore lint: 130 | function main(LocalStorageAPI: any) { 131 | const isAlbum = (props: { uri: string; id: string }) => props.uri?.includes("album") || props.id?.includes("local"); 132 | const isArtist = (props: { uri: string }) => props.uri?.includes("artist"); 133 | 134 | // @ts-expect-error 135 | Spicetify.ContextMenuV2.registerItem(, isAlbum); 136 | // @ts-expect-error 137 | Spicetify.ContextMenuV2.registerItem(, isArtist); 138 | 139 | Spicetify.Platform.LibraryAPI.getEvents()._emitter.addListener("update", () => CollectionsWrapper.cleanCollections()); 140 | 141 | function injectFolderImages() { 142 | const rootlist = document.querySelector(".main-rootlist-wrapper > div:nth-child(2)"); 143 | if (!rootlist) return setTimeout(injectFolderImages, 100); 144 | 145 | setTimeout(() => { 146 | for (const el of Array.from(rootlist.children)) { 147 | const uri = el.querySelector("[aria-labelledby]")?.getAttribute("aria-labelledby")?.slice(14); 148 | if (uri?.includes("folder")) { 149 | const imageBox = el.querySelector(".x-entityImage-imageContainer"); 150 | if (!imageBox) return; // for compact view 151 | 152 | const imageUrl = FolderImageWrapper.getFolderImage(uri); 153 | 154 | if (imageUrl) ReactDOM.render(, imageBox); 155 | } 156 | } 157 | }, 500); // timeout is easier than waiting for certain elements 158 | } 159 | 160 | injectFolderImages(); 161 | 162 | FolderImageWrapper.addEventListener("update", injectFolderImages); 163 | 164 | function injectYLXButtons() { 165 | // wait for the sidebar to load 166 | const ylx_filter = document.querySelector(".main-yourLibraryX-libraryRootlist .main-yourLibraryX-libraryFilter"); 167 | if (!ylx_filter) { 168 | return setTimeout(injectYLXButtons, 100); 169 | } 170 | 171 | injectFiltersButton(ylx_filter); 172 | injectCollapseButton(ylx_filter); 173 | } 174 | 175 | function injectFiltersButton(ylx_filter: Element) { 176 | // inject ylx button 177 | const toggleFiltersButton = document.createElement("span"); 178 | toggleFiltersButton.classList.add("toggle-filters-button"); 179 | ylx_filter.appendChild(toggleFiltersButton); 180 | ReactDOM.render(, toggleFiltersButton); 181 | } 182 | 183 | function injectCollapseButton(ylx_filter: Element) { 184 | const collapseButton = document.createElement("span"); 185 | collapseButton.classList.add("collapse-button"); 186 | ylx_filter.appendChild(collapseButton); 187 | ReactDOM.render(, collapseButton); 188 | } 189 | 190 | if (!window.SpicetifyLibrary.ConfigWrapper.Config.hideLibraryButton) { 191 | return; 192 | } 193 | 194 | document.body.classList.add("hide-library-button"); 195 | 196 | // check if ylx is expanded on load 197 | const state = LocalStorageAPI.getItem("left-sidebar-state"); 198 | if (state === 0) injectYLXButtons(); 199 | 200 | // handle button injection on maximise/minimise 201 | LocalStorageAPI.getEvents()._emitter.addListener("update", (e: { data: Record }) => { 202 | const { key, value } = e.data; 203 | if (key === "left-sidebar-state" && value === 0) { 204 | injectFolderImages(); 205 | injectYLXButtons(); 206 | } 207 | if (key === "left-sidebar-state" && value === 1) { 208 | injectFolderImages(); 209 | } 210 | }); 211 | } 212 | -------------------------------------------------------------------------------- /projects/library/src/pages/playlists.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import SearchBar from "../components/searchbar"; 3 | import useDropdownMenu from "@shared/dropdown/useDropdownMenu"; 4 | import PageContainer from "@shared/components/page_container"; 5 | import SpotifyCard from "@shared/components/spotify_card"; 6 | import SettingsButton from "@shared/components/settings_button"; 7 | import AddButton from "../components/add_button"; 8 | import type { ConfigWrapper } from "../types/library_types"; 9 | import LoadMoreCard from "../components/load_more_card"; 10 | import TextInputDialog from "../components/text_input_dialog"; 11 | import LeadingIcon from "../components/leading_icon"; 12 | import { useInfiniteQuery } from "@shared/types/react_query"; 13 | import type { FolderItem, GetContentsResponse, PlaylistItem, UpdateEvent } from "../types/platform"; 14 | import useStatus from "@shared/status/useStatus"; 15 | import PinIcon from "../components/pin_icon"; 16 | import useSortDropdownMenu from "@shared/dropdown/useSortDropdownMenu"; 17 | import BackButton from "../components/back_button"; 18 | import CustomCard from "../components/custom_card"; 19 | 20 | const AddMenu = ({ folder }: { folder?: string }) => { 21 | const { MenuItem, Menu } = Spicetify.ReactComponent; 22 | const { RootlistAPI } = Spicetify.Platform; 23 | const { SVGIcons } = Spicetify; 24 | 25 | const insertLocation = folder ? { uri: folder } : "start"; 26 | 27 | const createFolder = () => { 28 | const onSave = (value: string) => { 29 | RootlistAPI.createFolder(value || "New Folder", { after: insertLocation }); 30 | }; 31 | 32 | Spicetify.PopupModal.display({ 33 | title: "Create Folder", 34 | // @ts-ignore 35 | content: , 36 | }); 37 | }; 38 | 39 | const createPlaylist = () => { 40 | const onSave = (value: string) => { 41 | RootlistAPI.createPlaylist(value || "New Playlist", { after: insertLocation }); 42 | }; 43 | 44 | Spicetify.PopupModal.display({ 45 | title: "Create Playlist", 46 | // @ts-ignore 47 | content: , 48 | }); 49 | }; 50 | 51 | return ( 52 | 53 | }> 54 | Create Folder 55 | 56 | }> 57 | Create Playlist 58 | 59 | 60 | ); 61 | }; 62 | 63 | function isValidRootlistItem(item: PlaylistItem | FolderItem) { 64 | return item.name && item.uri; 65 | } 66 | 67 | const limit = 200; 68 | 69 | const dropdownOptions = [ 70 | { id: "0", name: "Name" }, 71 | { id: "1", name: "Date Added" }, 72 | { id: "2", name: "Creator" }, 73 | { id: "4", name: "Custom Order" }, 74 | { id: "6", name: "Recents" }, 75 | ]; 76 | 77 | const filterOptions = [ 78 | { id: "all", name: "All" }, 79 | { id: "100", name: "Downloaded" }, 80 | { id: "102", name: "By You" }, 81 | { id: "103", name: "By Spotify" }, 82 | ]; 83 | 84 | const flattenOptions = [ 85 | { id: "false", name: "Unflattened" }, 86 | { id: "true", name: "Flattened" }, 87 | ]; 88 | 89 | const PlaylistsPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => { 90 | const [sortDropdown, sortOption, isReversed] = useSortDropdownMenu(dropdownOptions, "library:playlists-sort"); 91 | const [filterDropdown, filterOption] = useDropdownMenu(filterOptions); 92 | const [flattenDropdown, flattenOption] = useDropdownMenu(flattenOptions); 93 | const [textFilter, setTextFilter] = React.useState(""); 94 | const [images, setImages] = React.useState({ ...FolderImageWrapper.getFolderImages() }); 95 | 96 | const folder = Spicetify.Platform.History.location.pathname.split("/")[3]; 97 | 98 | const fetchRootlist = async ({ pageParam }: { pageParam: number }) => { 99 | const filters = filterOption.id === "all" ? ["2"] : ["2", filterOption.id]; 100 | const res = (await Spicetify.Platform.LibraryAPI.getContents({ 101 | filters, 102 | sortOrder: sortOption.id, 103 | sortDirection: isReversed ? "reverse" : undefined, 104 | folderUri: folder, 105 | textFilter, 106 | offset: pageParam, 107 | includeLikedSongs: configWrapper.config.includeLikedSongs, 108 | includeLocalFiles: configWrapper.config.includeLocalFiles, 109 | limit, 110 | flattenTree: JSON.parse(flattenOption.id), 111 | })) as GetContentsResponse; 112 | if (!res.items?.length) throw new Error("No playlists found"); 113 | return res; 114 | }; 115 | 116 | const { data, status, error, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery({ 117 | queryKey: ["library:playlists", sortOption.id, isReversed, filterOption.id, flattenOption.id, textFilter, folder], 118 | queryFn: fetchRootlist, 119 | initialPageParam: 0, 120 | getNextPageParam: (lastPage) => { 121 | const current = lastPage.offset + limit; 122 | if (lastPage.totalLength > current) return current; 123 | }, 124 | retry: false, 125 | }); 126 | 127 | useEffect(() => { 128 | const update = (e: UpdateEvent) => refetch(); 129 | const updateImages = (e: CustomEvent | Event) => "detail" in e && setImages({ ...e.detail }); 130 | FolderImageWrapper.addEventListener("update", updateImages); 131 | Spicetify.Platform.RootlistAPI.getEvents()._emitter.addListener("update", update, {}); 132 | return () => { 133 | FolderImageWrapper.removeEventListener("update", updateImages); 134 | Spicetify.Platform.RootlistAPI.getEvents()._emitter.removeListener("update", update); 135 | }; 136 | }, [refetch]); 137 | 138 | const Status = useStatus(status, error); 139 | 140 | const props = { 141 | lhs: [ 142 | folder ? : null, 143 | data?.pages[0].openedFolderName || "Playlists", 144 | ], 145 | rhs: [ 146 | } />, 147 | sortDropdown, 148 | filterDropdown, 149 | flattenDropdown, 150 | , 151 | , 152 | ], 153 | }; 154 | 155 | if (Status) return {Status}; 156 | 157 | const contents = data as NonNullable; 158 | 159 | const items = contents.pages.flatMap((page) => page.items); 160 | 161 | const rootlistCards = items.filter(isValidRootlistItem).map((item) => ( 162 | item.type === "folder" ? 163 | : undefined} 172 | /> : 173 | : undefined} 181 | /> 182 | )); 183 | 184 | if (hasNextPage) rootlistCards.push(); 185 | 186 | return ( 187 | 188 |
    {rootlistCards}
    189 |
    190 | ); 191 | }; 192 | 193 | export default PlaylistsPage; 194 | -------------------------------------------------------------------------------- /projects/shared/src/config/config_modal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ConfigProps, ModalStructureProps } from "./config_types"; 3 | 4 | interface ConfigModalProps { 5 | config: ConfigProps; 6 | structure: ModalStructureProps; 7 | appKey: string; 8 | updateAppConfig: (config: ConfigProps) => void; 9 | } 10 | 11 | interface TextInputProps { 12 | storageKey: string; 13 | value: string | null; 14 | placeholder?: string; 15 | callback: (e: any) => void; 16 | } 17 | 18 | interface DrowpdownInputProps { 19 | storageKey: string; 20 | value: string; 21 | options: string[]; 22 | callback: (e: any) => void; 23 | } 24 | 25 | interface ToggleInputProps { 26 | storageKey: string; 27 | value: boolean; 28 | callback: (e: any) => void; 29 | } 30 | 31 | interface SliderInputProps { 32 | storageKey: string; 33 | value: number; 34 | min: number; 35 | max: number; 36 | step: number; 37 | callback: (e: any) => void; 38 | } 39 | 40 | const TextInput = (props: TextInputProps) => { 41 | const handleTextChange = (event: React.ChangeEvent) => { 42 | props.callback(event.target.value); 43 | }; 44 | 45 | return ( 46 | 58 | ); 59 | }; 60 | 61 | const Dropdown = (props: DrowpdownInputProps) => { 62 | const handleDropdownChange = (event: React.ChangeEvent) => { 63 | props.callback(event.target.value); 64 | }; 65 | 66 | return ( 67 | 83 | ); 84 | }; 85 | 86 | const ToggleInput = (props: ToggleInputProps) => { 87 | const handleToggleChange = (e: React.ChangeEvent) => { 88 | e.stopPropagation(); 89 | props.callback(e.target.checked); 90 | }; 91 | 92 | return ( 93 | 99 | ); 100 | }; 101 | 102 | const SliderInput = (props: SliderInputProps) => { 103 | const handleSliderChange = (e: React.ChangeEvent) => { 104 | console.log("hello"); 105 | props.callback(parseFloat(e.target.value)); 106 | }; 107 | 108 | const percentage = ((props.value - props.min) / (props.max - props.min)) * 100; 109 | 110 | return ( 111 |
    112 |
    117 | {/* hidden input */} 118 | 134 |
    135 |
    136 |
    137 |
    138 | {/* thumb */} 139 |
    140 |
    141 |
    142 |
    143 | ); 144 | }; 145 | 146 | const TooltipIcon = () => { 147 | return ( 148 | 155 | 156 | 157 | 158 | ); 159 | }; 160 | 161 | const ConfigRow = (props: { name: string; desc?: string; children: React.ReactElement }) => { 162 | return ( 163 |
    164 | 181 |
    {props.children}
    182 |
    183 | ); 184 | }; 185 | 186 | const ConfigModal = (props: ConfigModalProps) => { 187 | const { config, structure, appKey, updateAppConfig } = props; 188 | // local modal state 189 | const [modalConfig, setModalConfig] = React.useState({ ...config }); 190 | 191 | const modalRows = structure.map((modalRow, index) => { 192 | const key = modalRow.key; 193 | const currentValue = modalConfig[key]; 194 | 195 | const updateItem = (state: any) => { 196 | console.debug(`toggling ${key} to ${state}`); 197 | localStorage.setItem(`${appKey}:config:${key}`, String(state)); 198 | 199 | if (modalRow.callback) modalRow.callback(state); 200 | 201 | // Saves the config settings to app as well as SettingsModal state 202 | const newConfig = { ...modalConfig }; 203 | newConfig[key] = state; 204 | updateAppConfig(newConfig); 205 | setModalConfig(newConfig); 206 | }; 207 | 208 | const header = modalRow.sectionHeader; 209 | 210 | const Element = () => { 211 | switch (modalRow.type) { 212 | case "toggle": 213 | return ; 214 | case "text": 215 | return ; 216 | case "dropdown": 217 | return ( 218 | 224 | ); 225 | case "slider": 226 | return ( 227 | 235 | ); 236 | default: 237 | return null; 238 | } 239 | }; 240 | 241 | return ( 242 | <> 243 | {header && index !== 0 &&
    } 244 | {header &&

    {modalRow.sectionHeader}

    } 245 | 246 | 247 | 248 | 249 | ); 250 | }); 251 | 252 | return
    {modalRows}
    ; 253 | }; 254 | 255 | export default ConfigModal; 256 | --------------------------------------------------------------------------------