├── .gitignore ├── .prettierrc.yaml ├── icons ├── cdj-0.77rem.png ├── cdj-2.15rem.png └── camelotdj-1.87rem.png ├── zip-me.sh ├── screenshot ├── hold-bin-1280x800.png └── top100-1400x560.png ├── manifest.json ├── console-beatport.js ├── console-traxsource.js ├── src ├── options.html ├── options.css ├── content.css └── content.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | *.zip 3 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | tabWidth: 4 2 | printWidth: 120 3 | singleQuote: true -------------------------------------------------------------------------------- /icons/cdj-0.77rem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qel/camelotdj/HEAD/icons/cdj-0.77rem.png -------------------------------------------------------------------------------- /icons/cdj-2.15rem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qel/camelotdj/HEAD/icons/cdj-2.15rem.png -------------------------------------------------------------------------------- /icons/camelotdj-1.87rem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qel/camelotdj/HEAD/icons/camelotdj-1.87rem.png -------------------------------------------------------------------------------- /zip-me.sh: -------------------------------------------------------------------------------- 1 | rm camelotdj.zip 2 | zip camelotdj.zip -r manifest.json icons/* src/* -x "*.DS_Store" -x ".*" -------------------------------------------------------------------------------- /screenshot/hold-bin-1280x800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qel/camelotdj/HEAD/screenshot/hold-bin-1280x800.png -------------------------------------------------------------------------------- /screenshot/top100-1400x560.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qel/camelotdj/HEAD/screenshot/top100-1400x560.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Camelot DJ", 3 | "version": "0.4.2", 4 | "homepage_url": "http://camelotdj.com", 5 | "description": "Filter Beatport track lists by key.", 6 | "short_name": "camelotdj", 7 | "manifest_version": 2, 8 | "permissions": [], 9 | "icons": { 10 | "16": "icons/cdj-0.77rem.png", 11 | "48": "icons/cdj-2.15rem.png", 12 | "128": "icons/camelotdj-1.87rem.png" 13 | }, 14 | "options_page": "src/options.html", 15 | "content_scripts": [ 16 | { 17 | "matches": ["https://www.beatport.com/*"], 18 | "run_at": "document_start", 19 | "js": ["src/content.js"], 20 | "css": ["src/content.css"] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /console-beatport.js: -------------------------------------------------------------------------------- 1 | // Rewrite a Beatport Top 100 page -- add key/bpm column -- works in Chrome / Firefox 2 | 3 | // prettier-ignore 4 | beatportKeyToCamelotKey = { 5 | 'G♯ min': '1A', 'A♭ min': '1A', 'B maj': '1B', 6 | 'D♯ min': '2A', 'E♭ min': '2A', 'F♯ maj': '2B', 'G♭ maj': '2B', 7 | 'A♯ min': '3A', 'B♭ min': '3A', 'C♯ maj': '3B', 'D♭ maj': '3B', 8 | 'F min': '4A', 'G♯ maj': '4B', 'A♭ maj': '4B', 9 | 'C min': '5A', 'D♯ maj': '5B', 'E♭ maj': '5B', 10 | 'G min': '6A', 'A♯ maj': '6B', 'B♭ maj': '6B', 11 | 'D min': '7A', 'F maj': '7B', 12 | 'A min': '8A', 'C maj': '8B', 13 | 'E min': '9A', 'G maj': '9B', 14 | 'B min': '10A', 'D maj': '10B', 15 | 'F♯ min': '11A', 'G♭ min': '11A', 'A maj': '11B', 16 | 'C♯ min': '12A', 'D♭ min': '12A', 'E maj': '12B' 17 | }; 18 | // prettier-ignore 19 | document.head.insertAdjacentHTML('beforeend', [ 20 | '' 25 | ].join('\n')); 26 | if ((labelColumnHeader = document.querySelector('.bucket-track-header-col.buk-track-labels'))) { 27 | labelColumnHeader.innerHTML = '
BPM
KEY
'; 28 | Array.from(document.querySelectorAll('li .buk-track-labels')).forEach((label, i) => { 29 | if ((info = window.Playables && window.Playables.tracks && window.Playables.tracks[i])) { 30 | label.innerHTML = `
${info.bpm}
${beatportKeyToCamelotKey[info.key]}
`; 31 | } 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /console-traxsource.js: -------------------------------------------------------------------------------- 1 | // Traxsource.com Top 100 pages -- add key/bpm column 2 | 3 | // This script is pretty silly. 4 | // It fetches all 100 pages for each track to populate the BPM/key data. 5 | 6 | // prettier-ignore 7 | traxsourceKeyToCamelotKey = { 8 | 'G#min': '1A', 'Bmaj': '1B', 9 | 'D#min': '2A', 'F#maj': '2B', 10 | 'A#min': '3A', 'C#maj': '3B', 11 | 'Fmin': '4A', 'G#maj': '4B', 12 | 'Cmin': '5A', 'D#maj': '5B', 13 | 'Gmin': '6A', 'A#maj': '6B', 14 | 'Dmin': '7A', 'Fmaj': '7B', 15 | 'Amin': '8A', 'Cmaj': '8B', 16 | 'Emin': '9A', 'Gmaj': '9B', 17 | 'Bmin': '10A', 'Dmaj': '10B', 18 | 'F#min': '11A', 'Amaj': '11B', 19 | 'C#min': '12A', 'Emaj': '12B' 20 | }; 21 | // prettier-ignore 22 | document.head.insertAdjacentHTML('beforeend', [ 23 | '' 28 | ].join('\n')); 29 | if ((labelColumnHeader = document.querySelector('.trk-row.hdr .label'))) { 30 | labelColumnHeader.innerHTML = '
BPM
KEY
'; 31 | Array.from(document.getElementsByClassName('trk-row play-trk')).forEach(trackRow => { 32 | const titleLink = trackRow.querySelector('.title a'); 33 | const label = trackRow.getElementsByClassName('label')[0]; 34 | if (titleLink && label) { 35 | fetch(titleLink.href) 36 | .then(r => r.text()) 37 | .then(txt => { 38 | const det = txt.split(''); 39 | if (det.length > 6) { 40 | const key = det[5].split('<')[0]; 41 | const bpm = det[6].split('<')[0]; 42 | label.innerHTML = `
${bpm}
${traxsourceKeyToCamelotKey[key]}
`; 43 | } 44 | }); 45 | } 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
camelotdj
9 |
10 |

Nothing to see here...

11 |

The Options page does not currently have options.

12 |
13 |
14 |

Top 100 Link Bar

15 |
16 |
Genre
ID
17 |
18 | 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /src/options.css: -------------------------------------------------------------------------------- 1 | 2 | div.title { 3 | margin: 0; 4 | padding: 0; 5 | color: #94d500; 6 | font-size: 6rem; 7 | font-family: "Source Sans Pro", Arial, sans-serif; 8 | font-weight: 400; 9 | text-transform: none; 10 | letter-spacing: -0.035em; 11 | line-height: 0.975; 12 | text-shadow: 4px -2px 0.5px #141414; 13 | } 14 | 15 | div.title strong { 16 | color: #fff; 17 | font-size: 0.948em; 18 | font-weight: 400; 19 | text-transform: uppercase; 20 | } 21 | 22 | div.genres { 23 | width: fit-content; 24 | display: flex; 25 | flex-direction: column; 26 | align-items: center; 27 | } 28 | 29 | h1 { 30 | margin: 0; 31 | padding: 0; 32 | width: 100%; 33 | text-align: left; 34 | color: #fff; 35 | font-size: 1.8rem; 36 | font-family: Oswald, Helvetica, sans-serif; 37 | font-weight: 200; 38 | text-transform: uppercase; 39 | } 40 | 41 | div.genres > ul { 42 | display: flex; 43 | flex-direction: column; 44 | padding-inline-start: 0; 45 | margin-block-start: 0; 46 | margin-block-end: 0; 47 | } 48 | 49 | div.genres > div.header, div.genres > ul > li { 50 | padding: 0; 51 | display: flex; 52 | justify-content: space-around; 53 | } 54 | 55 | div.genres > div.header { 56 | width: 100%; 57 | border-top: 1px solid #494949; 58 | text-transform: uppercase; 59 | } 60 | 61 | div.genres > ul > li { 62 | margin: 0.1rem 0; 63 | padding: 0.25rem; 64 | background-color: #373737; 65 | } 66 | 67 | div.link-col { 68 | width: 7em; 69 | } 70 | 71 | div.name-col { 72 | width: 20em; 73 | } 74 | 75 | div.number-col { 76 | width: 3em; 77 | } 78 | 79 | div.genres > div.header > div, div.genres > ul > li > div { 80 | display: inline-block; 81 | padding: 0 0.5rem; 82 | text-align: left; 83 | } 84 | 85 | 86 | div { 87 | padding: 0.25rem 0; 88 | text-align: center; 89 | } 90 | 91 | p { 92 | color: #fff; 93 | font-size: 1.65rem; 94 | font-family: Ubuntu; 95 | font-weight: 400; 96 | text-transform: none; 97 | letter-spacing: .02em; 98 | line-height: 1.25; 99 | } 100 | 101 | p strong { 102 | /* this should be the same as bold which is inherent to the tag, but I'll put it here for reference */ 103 | font-weight: 700; 104 | } 105 | 106 | a { 107 | font-size: 1.65rem; 108 | color: #39c1de; 109 | font-family: "Source Sans Pro", Arial, sans-serif; 110 | font-weight: 700; 111 | text-transform: none; 112 | letter-spacing: .02em; 113 | line-height: 1.25; 114 | text-decoration: none; 115 | } 116 | 117 | a:hover { 118 | text-decoration: underline; 119 | } 120 | 121 | body { 122 | width: 100%; 123 | margin: 2rem 0 0 0; 124 | padding: 0.33rem 0; 125 | display: flex; 126 | flex-direction: column; 127 | align-items: center; 128 | background-color: #262626; 129 | color: #fff; 130 | font-size: 1.1rem; 131 | font-family: "Source Sans Pro", Arial, sans-serif; 132 | font-weight: 400; 133 | } 134 | -------------------------------------------------------------------------------- /src/content.css: -------------------------------------------------------------------------------- 1 | li.track p.buk-track-labels, li.top-ten-track span.top-ten-track-label { 2 | color: #fff; 3 | font-size: 1.35rem; 4 | letter-spacing: 1px; 5 | } 6 | 7 | p.buk-track-labels div, span.top-ten-track-label div { 8 | display: inline-block; 9 | width: 3.8rem; 10 | } 11 | 12 | p.buk-track-labels div:last-child, span.top-ten-track-label div:last-child { 13 | text-align: right; 14 | } 15 | 16 | .camelotdj-main { 17 | position: absolute; 18 | right: 0; 19 | margin-top: 0.35rem; 20 | margin-right: 1.5rem; 21 | } 22 | 23 | div.inner-container { 24 | display: flex; 25 | } 26 | 27 | div.inner-container:first-child { 28 | margin-top: 0.65rem; 29 | } 30 | 31 | a.key-label, div.link-label { 32 | padding-left: 0.6rem; 33 | margin-right: 0.4rem; 34 | background-color: #26262680; 35 | border-radius: 0.5rem 0.5rem 0 0; 36 | color: #fff; 37 | text-shadow: 4px -2px 0.5px #141414; 38 | letter-spacing: 1px; 39 | text-transform: uppercase; 40 | border-bottom: 1px solid #94d500; 41 | } 42 | 43 | a.key-label { 44 | height: fit-content; 45 | margin-top: 0.35rem; 46 | font-size: 1.25rem; 47 | font-weight: 300; 48 | } 49 | 50 | a.key-label strong { 51 | margin-left: 0.1rem; 52 | font-size: 1.55rem; 53 | font-weight: 600; 54 | } 55 | 56 | div.link-label { 57 | margin-top: 0.45rem; 58 | margin-left: 1.5rem; 59 | font-size: 1.05rem; 60 | font-weight: 400; 61 | } 62 | 63 | div.link-label strong { 64 | margin-left: 0.1rem; 65 | margin-right: 0.1rem; 66 | font-size: 1.4rem; 67 | font-weight: 600; 68 | } 69 | 70 | div.key-grid { 71 | height: 48px; 72 | width: 540px; 73 | background-color: #141414; 74 | border-radius: 0.5rem; 75 | box-shadow: 4px -2px 0.5px 1.5px rgba(0, 48, 0, 0.5); 76 | display: flex; 77 | flex-direction: column; 78 | justify-content: space-evenly; 79 | user-select: none; 80 | } 81 | 82 | div.key-grid:hover { 83 | cursor: pointer; 84 | } 85 | 86 | div.link-row { 87 | margin-top: 0.7rem; 88 | width: 556px; 89 | margin-right: -20px; 90 | } 91 | 92 | div.key-row, div.link-row { 93 | display: flex; 94 | justify-content: space-around; 95 | } 96 | 97 | div.key-col { 98 | font-size: 1.2em; 99 | font-weight: 700; 100 | letter-spacing: 1px; 101 | width: 3rem; 102 | padding-right: 0.2rem; 103 | text-align: right; 104 | } 105 | 106 | div.key-grid div.key-col:hover { 107 | border-radius: 0.3rem; 108 | background: #82bc00; 109 | color: #262626; 110 | } 111 | 112 | div.key-col.selected { 113 | border-radius: 0.3rem; 114 | background: #94d500; 115 | color: #262626; 116 | } 117 | 118 | div.key-col.included { 119 | border-radius: 0.3rem; 120 | background: #ff53a0; 121 | } 122 | 123 | div.link-row a { 124 | font-size: 1rem; 125 | font-weight: 600; 126 | } 127 | 128 | div.link-row a:hover, div.link-row a.selected { 129 | border-bottom: 1px solid #94d500; 130 | } 131 | 132 | div.link-row a:hover { 133 | text-shadow: 0 1px 3px #94d500AA 134 | } 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Camelot DJ 2 | 3 | **A Chrome estension for Beatport** 4 | 5 | Get it in the [Chrome Web Store](https://chrome.google.com/webstore/detail/camelot-dj/odinfabcmlogdpdkghggkkpdjlbeloeg). 6 | 7 | 8 | 9 | Camelot DJ adds BPM and key (in Camelot notation) to track lists. 10 | This applies to all Top 100 lists and your Hold Bin. 11 | 12 | ![Top 100](/screenshot/top100-1400x560.png?raw=true "Top 100 screenshot") 13 | 14 | 15 | 16 | It also adds a selector to the page which lets you pick a key to mix with. 17 | 18 | The track lists are then filtered to show keys which mix well with the selected key. 19 | 20 | ![Hold Bin](/screenshot/hold-bin-1280x800.png?raw=true "Hold Bin screenshot") 21 | 22 | Oh, and it adds some convenient links to the top 100 lists for the bigger genres. 23 | 24 | 25 | 26 | ### Console version 27 | 28 | Obviously a Chrome extension only works on Chrome, but ... if you want to see BPM/key on a Top 100 list, you can just cut/paste this into the console. And it works in Firefox, too! 29 | 30 | ```javascript 31 | // Rewrite a Beatport Top 100 page -- add key/bpm column 32 | beatportKeyToCamelotKey = { 33 | 'G♯ min': '1A', 'A♭ min': '1A', 'B maj': '1B', 34 | 'D♯ min': '2A', 'E♭ min': '2A', 'F♯ maj': '2B', 'G♭ maj': '2B', 35 | 'A♯ min': '3A', 'B♭ min': '3A', 'C♯ maj': '3B', 'D♭ maj': '3B', 36 | 'F min': '4A', 'G♯ maj': '4B', 'A♭ maj': '4B', 37 | 'C min': '5A', 'D♯ maj': '5B', 'E♭ maj': '5B', 38 | 'G min': '6A', 'A♯ maj': '6B', 'B♭ maj': '6B', 39 | 'D min': '7A', 'F maj': '7B', 40 | 'A min': '8A', 'C maj': '8B', 41 | 'E min': '9A', 'G maj': '9B', 42 | 'B min': '10A', 'D maj': '10B', 43 | 'F♯ min': '11A', 'G♭ min': '11A', 'A maj': '11B', 44 | 'C♯ min': '12A', 'D♭ min': '12A', 'E maj': '12B' 45 | }; 46 | document.head.insertAdjacentHTML('beforeend', [ 47 | '' 52 | ].join('\n')); 53 | if ((labelColumnHeader = document.querySelector('.bucket-track-header-col.buk-track-labels'))) { 54 | labelColumnHeader.innerHTML = '
BPM
KEY
'; 55 | Array.from(document.querySelectorAll('li .buk-track-labels')).forEach((label, i) => { 56 | if ((info = window.Playables && window.Playables.tracks && window.Playables.tracks[i])) { 57 | label.innerHTML = `
${info.bpm}
${beatportKeyToCamelotKey[info.key]}
`; 58 | } 59 | }); 60 | } 61 | ``` 62 | 63 | 64 | 65 | ### The code 66 | 67 | Doing this in the console is a lot easier than doing it in an extension because of sandboxing. 68 | 69 | We can't get to the window object, and Beatport uses a lot of AJAX, and the track data lives in a dynamically-loaded script tag. Injecting all the code to hijack XHR events gets really messy, so we just grab the script tag and evaluate it. Then we have a polling function which checks to see when the URL changes after an AJAX call, and then we reload the data. 70 | 71 | 72 | 73 | ### Contact 74 | 75 | If you think I picked terrible genres, feel free to ping me on Reddit and tell me how awful I am. 76 | 77 | If you think showing the matching major/minor keys is an affront to music theory, feel free to explain to me how I can be better at this mixing thing. 78 | 79 | [u/raquel-eve](https://www.reddit.com/user/raquel-eve) 80 | -------------------------------------------------------------------------------- /src/content.js: -------------------------------------------------------------------------------- 1 | // prettier-ignore 2 | const beatportKeyToCamelotKey = { 3 | 'G♯ min': '1A', 'A♭ min': '1A', 'B maj': '1B', 4 | 'D♯ min': '2A', 'E♭ min': '2A', 'F♯ maj': '2B', 'G♭ maj': '2B', 5 | 'A♯ min': '3A', 'B♭ min': '3A', 'C♯ maj': '3B', 'D♭ maj': '3B', 6 | 'F min': '4A', 'G♯ maj': '4B', 'A♭ maj': '4B', 7 | 'C min': '5A', 'D♯ maj': '5B', 'E♭ maj': '5B', 8 | 'G min': '6A', 'A♯ maj': '6B', 'B♭ maj': '6B', 9 | 'D min': '7A', 'F maj': '7B', 10 | 'A min': '8A', 'C maj': '8B', 11 | 'E min': '9A', 'G maj': '9B', 12 | 'B min': '10A', 'D maj': '10B', 13 | 'F♯ min': '11A', 'G♭ min': '11A', 'A maj': '11B', 14 | 'C♯ min': '12A', 'D♭ min': '12A', 'E maj': '12B' 15 | }; 16 | 17 | // prettier-ignore 18 | const linkGenres = { 19 | 'min•tech': 14, 20 | 'raw•tech': 92, 21 | 'techno': 6, 22 | 'mel•h+t': 90, 23 | 'prog•hs': 15, 24 | 'trance': 7, 25 | 'electro': 3, 26 | 'breaks': 9, 27 | 'ind•dnc': 37, 28 | 'tech•hs': 11, 29 | 'house': 5, 30 | 'orgnic•dt': 93, 31 | 'deep•hs': 12, 32 | }; 33 | 34 | const HOLD_BIN_URL = 'https://www.beatport.com/hold-bin/tracks?per-page=150'; 35 | 36 | const POLLING_INTERVAL = 100; // checking for URL changes and XHR responses that update lists 37 | 38 | 39 | 40 | // logging 41 | const myLog = (...args) => console.log.apply(console, ['CamelotDJ:'].concat(args)); 42 | 43 | // convert key dataset to string -- note that the dataset stringifies everything 44 | const keyStr = datasetObj => datasetObj.keyNum + (datasetObj.minor.toString() === 'true' ? 'A' : 'B'); 45 | 46 | 47 | 48 | // UI: build the key selector 49 | const keySelectButtons = []; 50 | const mainContainer = document.createElement('div'); 51 | const keySelectContainer = document.createElement('div'); 52 | const keySelectLabel = document.createElement('a'); 53 | const keySelectGrid = document.createElement('div'); 54 | mainContainer.className = 'camelotdj-main'; 55 | keySelectContainer.className = 'inner-container'; 56 | keySelectLabel.className = 'key-label'; 57 | keySelectGrid.className = 'key-grid'; 58 | keySelectLabel.innerHTML = 'CamelotDJ'; 59 | keySelectLabel.href = HOLD_BIN_URL; 60 | keySelectContainer.appendChild(keySelectLabel); 61 | keySelectContainer.appendChild(keySelectGrid); 62 | for (const minor of [true, false]) { 63 | const rowDiv = document.createElement('div'); 64 | rowDiv.className = 'key-row'; 65 | for (let keyNum = 1; keyNum <= 12; keyNum += 1) { 66 | const colDiv = document.createElement('div'); 67 | colDiv.className = 'key-col'; 68 | colDiv.textContent = keyStr({ keyNum, minor }); 69 | colDiv.dataset.keyNum = keyNum; 70 | colDiv.dataset.minor = minor; 71 | keySelectButtons.push(colDiv); 72 | rowDiv.appendChild(colDiv); 73 | } 74 | keySelectGrid.appendChild(rowDiv); 75 | } 76 | mainContainer.appendChild(keySelectContainer); 77 | 78 | // UI: build row of links 79 | const genreLinks = {}; 80 | const linkContainer = document.createElement('div'); 81 | const linkLabel = document.createElement('div'); 82 | const linkRow = document.createElement('div'); 83 | linkContainer.className = 'inner-container'; 84 | linkLabel.className = 'link-label'; 85 | linkRow.className = 'link-row'; 86 | linkLabel.innerHTML = 'top100s'; 87 | for (const [gName, gNum] of Object.entries(linkGenres)) { 88 | const a = document.createElement('a'); 89 | a.href = `https://www.beatport.com/genre/${gName}/${gNum}/top-100`; 90 | a.textContent = gName; 91 | genreLinks[gNum] = a; 92 | linkRow.appendChild(a); 93 | } 94 | linkContainer.appendChild(linkLabel); 95 | linkContainer.appendChild(linkRow); 96 | mainContainer.appendChild(linkContainer); 97 | 98 | 99 | 100 | // match URL 101 | 102 | // split path example: 103 | // 104 | // ['', 'cart', 'cart', 'BaSe64sTuFf=='] 105 | // ['', 'hold-bin'] 106 | // ['', 'genre', 'techno', '6', 'top-100'] 107 | 108 | const pathMathches = wildcardPath => { 109 | const splitPath = window.location.pathname.split('/'); 110 | const splitWild = wildcardPath.split('/'); 111 | if (splitPath.length < splitWild.length) return false; 112 | for (const seg in splitWild) { 113 | if (splitWild[seg] !== '*' && splitWild[seg] !== splitPath[seg]) return false; 114 | } 115 | return true; 116 | } 117 | 118 | const getGenreNumberSeg = () => { 119 | const splitPath = window.location.pathname.split('/'); 120 | if (splitPath.length < 4 || splitPath[1] !== 'genre') return null; 121 | return splitPath[3]; 122 | } 123 | 124 | const urlIsTop100 = () => pathMathches('/genre/*/*/top-100'); 125 | const urlIsMainCart = () => pathMathches('/cart/cart'); 126 | const urlIsCustomCart = () => pathMathches('/cart/*') && !urlIsMainCart(); 127 | const urlIsHoldBin = () => pathMathches('/hold-bin'); 128 | const urlIsSearchPage = () => pathMathches('/search/tracks'); 129 | 130 | 131 | 132 | // global track data object 133 | let tracks = {}; 134 | 135 | // parse JSON track data in page scripts 136 | const parseScriptTracks = (scriptElement, assignedVarName, ...nestedVarNames) => { 137 | const scriptText = scriptElement.text; 138 | const objStart = scriptText.indexOf('{', scriptText.indexOf(assignedVarName) + assignedVarName.length); 139 | const objEnd = scriptText.indexOf('};', objStart) + 1; 140 | let varData = JSON.parse(scriptText.slice(objStart, objEnd)); 141 | nestedVarNames.forEach(v => { 142 | varData = varData[v]; 143 | }) 144 | myLog(varData.length, assignedVarName, 'tracks found.'); 145 | varData.forEach(track => { 146 | if (!(track.id in tracks)) { 147 | if (track.bpm && track.key) { 148 | tracks[track.id] = { 149 | key: track.key, 150 | bpm: track.bpm, 151 | camelot: beatportKeyToCamelotKey[track.key] 152 | }; 153 | } 154 | myLog(track.id, 'name:', track.name, 'mix:', track.mix, 'bpm:', track.bpm, 'key:', track.key); 155 | } 156 | }); 157 | scriptElement.dataset.parsed = 'parsed'; // flag it so we don't keep reloading the same data 158 | } 159 | 160 | 161 | 162 | // global key selection vars 163 | let selectedKey = null; 164 | let matchingKeys = []; 165 | 166 | // Selects a key basted on {keyNum, minor} object and stores matching keys in matchingKeys 167 | const selectKey = datasetObj => { 168 | myLog('selectKey()', datasetObj); 169 | 170 | // reset all buttons to only the default class 171 | for (const btn of keySelectButtons) { 172 | btn.className = 'key-col'; 173 | } 174 | 175 | if (datasetObj) { 176 | selectedKey = keyStr(datasetObj); 177 | const keyNum = parseInt(datasetObj.keyNum); 178 | const minor = datasetObj.minor.toString() === 'true'; 179 | 180 | // put the selection in storage 181 | window.localStorage.setItem('camelotdj.keyNum', keyNum); 182 | window.localStorage.setItem('camelotdj.minor', minor); 183 | 184 | // prettier-ignore 185 | matchingKeys = [selectedKey].concat([ 186 | { add: 0, flip: true }, // 8A -> 8B -- same key, flipped major/minor 187 | { add: 11, flip: false }, // 8A -> 7A -- key -1 188 | { add: 1, flip: false }, // 8A -> 9A -- key +1 189 | { add: 9, flip: true }, // 8A -> 5B -- key -3, flipped major/minor 190 | { add: 3, flip: true } // 8A -> 11B -- key +3, flipped major/minor 191 | ].map(x => keyStr({ keyNum: (keyNum + x.add - 1) % 12 + 1, minor: x.flip ? !minor : minor }))); 192 | 193 | // apply the correct classes to the buttons 194 | for (const btn of keySelectButtons) { 195 | const btnKeyStr = keyStr(btn.dataset); 196 | if (btnKeyStr === selectedKey) { 197 | btn.classList.add('selected'); 198 | } else if (matchingKeys.includes(btnKeyStr)) { 199 | btn.classList.add('included'); 200 | } 201 | } 202 | } else { 203 | selectedKey = null; 204 | 205 | // clear stored key 206 | window.localStorage.removeItem('camelotdj.keyNum'); 207 | window.localStorage.removeItem('camelotdj.minor'); 208 | } 209 | }; 210 | 211 | 212 | 213 | const update = () => { 214 | if (urlIsTop100()) myLog('URL is a Top 100 page.'); 215 | if (urlIsMainCart()) myLog('URL is the Main Cart.'); 216 | if (urlIsCustomCart()) myLog('URL is a custom cart.'); 217 | if (urlIsHoldBin()) myLog('URL is the Hold Bin.'); 218 | if (urlIsSearchPage()) myLog('URL is a search result track list.'); 219 | 220 | // Set/clear selection class on the top-100 genre link bar 221 | for (gNum of Object.keys(genreLinks)) { 222 | if (urlIsTop100() && getGenreNumberSeg() === gNum) { 223 | genreLinks[gNum].className = 'selected'; 224 | } else { 225 | genreLinks[gNum].className = ''; 226 | } 227 | } 228 | 229 | // We only filter stuff when we're on a top-100 / hold-bin / custom cart 230 | const keySelectorActive = (urlIsTop100() || urlIsHoldBin() || urlIsCustomCart() || urlIsSearchPage()); 231 | const filtering = keySelectorActive && selectedKey; 232 | 233 | if (keySelectorActive) { 234 | keySelectContainer.style.display = ''; 235 | if (filtering) { 236 | myLog('filtering', matchingKeys); 237 | } 238 | } else { 239 | // hide the key selector thingy if we're not filtering 240 | keySelectContainer.style.display = 'none'; 241 | } 242 | 243 | // track list headers 244 | Array.from(document.querySelectorAll('.bucket-track-header-col.buk-track-labels')).forEach(labelColumnHeader => { 245 | labelColumnHeader.innerHTML = '
BPM
KEY
'; 246 | }); 247 | 248 | // Top 100s, carts and hold-bin 249 | document.querySelectorAll('ul.bucket-items').forEach(trackList => { 250 | // This is goofy because the hold-bin rows aren't as nested as the Top 100 rows. 251 | Array.from(trackList.getElementsByClassName('track')).forEach(trackRow => { 252 | const trackLabel = trackRow.getElementsByClassName('buk-track-labels')[0]; 253 | if (trackLabel) { 254 | const trackId = trackRow.dataset.ecId || trackRow.getElementsByClassName('track-play')[0].dataset.id; 255 | 256 | if (tracks[trackId]) { 257 | const { bpm, camelot } = tracks[trackId]; 258 | 259 | if (filtering && matchingKeys.indexOf(camelot) == -1) { 260 | // hide rows that are filtered out 261 | trackRow.style.display = 'none'; 262 | } else { 263 | trackRow.style.display = ''; 264 | trackLabel.innerHTML = `
${bpm}
${camelot}
`; 265 | } 266 | } else { 267 | trackLabel.style.whiteSpace = 'noWrap'; 268 | trackLabel.style.color = '#8c8c8c'; 269 | trackLabel.innerText = 'no track data'; 270 | } 271 | } 272 | }); 273 | 274 | // tag that we've labelled the list so that we can see when it gets reloaded 275 | trackList.dataset.labeled = 'labeled'; 276 | }); 277 | 278 | // Top 10s 279 | Array.from(document.getElementsByClassName('top-ten-track-label')).forEach(topTenLabel => { 280 | const topTenRow = topTenLabel.parentNode.parentNode; 281 | const trackId = topTenRow.dataset.ecId; 282 | const { bpm, camelot } = tracks[trackId]; 283 | topTenLabel.innerHTML = `
${bpm}
${camelot}
`; 284 | }); 285 | }; 286 | 287 | 288 | 289 | const checkForUpdate = () => { 290 | Array.from(document.getElementsByClassName('bucket-items')).forEach(trackListElement => { 291 | if (!trackListElement.dataset.labeled && trackListElement.firstElementChild.classList.contains('track')) { 292 | myLog('New track list found.'); 293 | const trackScript = document.getElementById('data-objects'); 294 | if (trackScript) { 295 | if (!trackScript.dataset.parsed) { 296 | parseScriptTracks(trackScript, 'window.Playables', 'tracks') 297 | } 298 | const nES = trackScript.nextElementSibling; 299 | const cartScript = nES !== null && nES.id === '' && nES.text.indexOf('window.localCart') !== -1 && nES; 300 | if (cartScript && !cartScript.dataset.parsed) { 301 | parseScriptTracks(cartScript, 'window.localCart', 'items'); 302 | } 303 | update(trackListElement); 304 | } else { 305 | myLog('Waiting for track data script.'); 306 | } 307 | } 308 | }); 309 | }; 310 | 311 | const loadHandler = e => { 312 | // add UI go page 313 | document.getElementsByClassName('header-bg-wrap')[0].appendChild(mainContainer); 314 | 315 | // load key selection from local storage 316 | const keyNum = window.localStorage.getItem('camelotdj.keyNum'); 317 | const minor = window.localStorage.getItem('camelotdj.minor'); 318 | if (keyNum && minor) { 319 | selectKey({ keyNum, minor }); 320 | } 321 | 322 | // always polling for unlabeled track lists 323 | updateInterval = setInterval(checkForUpdate, POLLING_INTERVAL); 324 | }; 325 | document.addEventListener('DOMContentLoaded', loadHandler); 326 | 327 | const clickHandler = e => { 328 | const { classList, dataset } = e.target; 329 | if (classList.contains('key-col')) { 330 | if (classList.contains('selected')) { 331 | myLog('clickHandler() un-selected', dataset); 332 | selectKey(null); 333 | } else { 334 | myLog('clickHandler() selected', dataset); 335 | selectKey(dataset); 336 | } 337 | update(); 338 | } 339 | }; 340 | document.addEventListener('click', clickHandler); 341 | --------------------------------------------------------------------------------