├── .gitignore ├── Gemfile ├── Gemfile.lock ├── Procfile ├── README.md ├── bcradio.rb ├── css └── bcradio.css ├── favicon.ico ├── images ├── delete.png ├── external-link.png ├── loading.gif ├── next-outline.png ├── playlist.png ├── prev-outline.png ├── published.png ├── responsive-demo.jpg ├── scope-tiny.gif └── unpublished.png ├── index.html └── js ├── bcradio.1.js └── jquery-3.5.1.min.js /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | dev/ 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'http' 4 | gem 'pg' 5 | 6 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.7.0) 5 | public_suffix (>= 2.0.2, < 5.0) 6 | domain_name (0.5.20190701) 7 | unf (>= 0.0.5, < 1.0.0) 8 | ffi (1.15.1-x64-mingw32) 9 | ffi-compiler (1.0.1) 10 | ffi (>= 1.0.0) 11 | rake 12 | http (5.0.0) 13 | addressable (~> 2.3) 14 | http-cookie (~> 1.0) 15 | http-form_data (~> 2.2) 16 | llhttp-ffi (~> 0.0.1) 17 | http-cookie (1.0.3) 18 | domain_name (~> 0.5) 19 | http-form_data (2.3.0) 20 | llhttp-ffi (0.0.1) 21 | ffi-compiler (~> 1.0) 22 | rake (~> 13.0) 23 | pg (1.2.3-x64-mingw32) 24 | public_suffix (4.0.6) 25 | rake (13.0.3) 26 | unf (0.1.4) 27 | unf_ext 28 | unf_ext (0.0.7.7-x64-mingw32) 29 | 30 | PLATFORMS 31 | x64-mingw32 32 | 33 | DEPENDENCIES 34 | http 35 | pg 36 | 37 | BUNDLED WITH 38 | 2.2.18 39 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec ruby bcradio.rb $PORT true 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | _BCRadio is a web app to shuffle-play your Bandcamp collection and share playlists._ 2 | 3 | * [Check it out here](https://bcradio.muskratworks.com) 4 | 5 | ## Usage 6 | 7 | To shuffle-play your Bandcamp collection fill in your info: 8 | * Bandcamp username (this can be found in Settings > Fan > Username, and is also visible in your fan URL) 9 | * Number of additional (beyond the first 20) recent purchases to load 10 | * Optional: copy/paste your [identity cookie](#understanding-identity-cookies) 11 | * Press `Enter` and wait for your library to load. 12 | 13 | ...Or just click/tap one of the playlists created by other BCRadio users. 14 | 15 | Tip: If you're using a desktop computer, click the Album Art or Mini Player layouts and resize it to your liking. Try making the window full-screen (F11 in Windows) and tapping the album art to showcase it. 16 | 17 | ![](images/responsive-demo.jpg) 18 | 19 | ### Controls 20 | 21 | * Press icons to Pause, Play, and go to Previous or Next tracks 22 | * The 'X' icon will mark an album to be skipped permanently on this browser. (This does not remove the album from your Bandcamp collection.) 23 | * You can un-skip the album by selecting a track in the track list and clicking 'X' again 24 | * The up-arrow icon will mark an album to be added to a [Playlist](#playlists) 25 | * Click a track in the track list to skip directly to that track 26 | * The track list may be sorted randomly (Shuffle) or alphabetically 27 | * Click the track name to open its Bandcamp page in a new tab 28 | * Click/tap the album art to toggle stretching it to fill the window... 29 | * But clicking/tapping on the lower third of the album art image replicates Previous/Pause/Next controls 30 | 31 | ### Playlists 32 | 33 | You can create and share playlists made up of albums in your collection: 34 | 35 | * Start a BCRadio session. Use the [identity cookie](#understanding-identity-cookies) feature if you plan on pulishing the playlist. 36 | * Click the green up-arrow icon to add the current album to the playlist. 37 | * A second click will remove the album from the playlist. 38 | * Repeat with as many albums as you like. 39 | * Click the external-link icon at the top of the collection list to open the playlist in a new BCRadio session in another tab. 40 | * In the new tab click the cloud icon next to the playlist name to publish your playlist to the [BCRadio website](https://bcradio.muskratworks.com). 41 | * The cloud icon will not appear if you have not provided your [identity cookie](#understanding-identity-cookies). In this case you can still save/share the playlist by copying the URL from the address bar. 42 | * You are limited to 8 published playlists. 43 | * To unpublish an existing playlist, click its cloud icon again. 44 | 45 | People can listen to published playlists when they arrive at the [BCRadio website](https://bcradio.muskratworks.com): 46 | 47 | * Only a single "featured track" of each album will be available to other people listening to your playlist 48 | * Playlists do not retain their sequencing and are presented in Shuffled or Alphabetic order 49 | 50 | This feature is intended to help raise awareness of the great music available on Bandcamp. Please encourage 51 | your friends to get Bandcamp accounts and help support musicians! 52 | 53 | ## Understanding Identity Cookies 54 | 55 | BCRadio cannot sign into your Bandcamp account directly. By default it only loads the publicly-available "featured" track for 56 | each album, and is limited to mp3-128k streaming resolution. By providing your Bandcamp identity cookie to BCRadio, you enable 57 | loading all your purchased tracks rather than just the "featured" tracks, and also enable mp3-V0 sound quality. 58 | 59 | You can find your identity cookie by logging into the Bandcamp website in another tab, enabling your browser's developer 60 | tools, and copying the value from `(Application >) Storage > Cookies > bandcamp.com > identity`. It's a long URL-encoded 61 | string like: 62 | ``` 63 | 7%09S%3Bk9rNU0kEm%2Fi3afa%2BTCB1%2BvkxHm5Jl9ULJrK7JjrMc%3D%09%7B%22id%22%3A1185531561%2A%22ex%22%3B0%6D 64 | ``` 65 | 66 | _Unfortunately many mobile devices do not support this feature_ 67 | 68 | ## Security 69 | 70 | BCRadio does not ask for your Bandcamp password and never stores 71 | your username or identity cookie. All communications are encrypted (https connection). 72 | 73 | The service currently runs on the 74 | San Francisco-based Heroku cloud platform, which is itself 75 | hosted securely on Amazon's EC2 cloud-computing platform. 76 | 77 | ## Limitations 78 | 79 | * This app is not affiliated with Bandcamp in any way 80 | * The code does not use a published API and may break in the future 81 | 82 | ------------- 83 | ## Development 84 | 85 | Install Postgres and create `bcradio` db 86 | ``` 87 | createdb bcradio 88 | psql bcradio 89 | create table playlists( 90 | username varchar(80), 91 | playlist_name varchar(80), 92 | history integer, 93 | url text, 94 | unique (username, playlist_name) 95 | ); 96 | ``` 97 | 98 | Edit the conf file to make sure the table gets `trust` authentication. Also set the environment variable `DATABASE_URL` to 99 | point to your local db. 100 | 101 | Install Ruby packages 102 | ``` 103 | bundle install 104 | ```` 105 | 106 | Start local server on default port 5678 107 | ``` 108 | ruby bcradio.rb 109 | ``` 110 | 111 | Navigate to `http://localhost:5678`. 112 | -------------------------------------------------------------------------------- /bcradio.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # bcradio.rb [PORT] [true|false] 4 | # Copyright 2021 Ralph Gonzalez 5 | 6 | require 'socket' 7 | require 'net/http' 8 | require 'cgi' 9 | require 'pg' 10 | 11 | $verbose = true 12 | port = ARGV.empty? ? 5678 : ARGV[0] 13 | heroku_redirect_http = ARGV.empty? ? false : ARGV[1] == 'true' 14 | server = TCPServer.new port 15 | $stdout.sync = true 16 | 17 | # Http response wrapper 18 | class Response 19 | attr_reader :code 20 | 21 | def initialize(code:, data: '') 22 | if code == 301 23 | @response = "HTTP/1.1 301 Moved Permanently\r\n#{data}\r\n\r\n" 24 | elsif data 25 | @response = "HTTP/1.1 #{code}\r\nContent-Length: #{data.size}\r\n\r\n#{data}\r\n" 26 | else 27 | @response = "HTTP/1.1 #{code}\r\n\r\n\r\n" 28 | end 29 | @code = code 30 | end 31 | 32 | def send(client) 33 | client.write(@response) 34 | end 35 | end 36 | 37 | # Main BcRadio server 38 | class BcRadio 39 | attr_reader :initialized 40 | 41 | def initialize session 42 | @session = session 43 | @query = nil 44 | @method = nil 45 | @uri = nil 46 | @body = nil 47 | @response_data = nil 48 | @params = {} 49 | @headers = {} 50 | get_uri session 51 | return if @uri.nil? 52 | 53 | parse_headers session 54 | get_body session if @method == 'POST' 55 | @initialized = true 56 | end 57 | 58 | def validate_https_request 59 | !@headers['host'].include?('bcradio.muskratworks.com') || @headers['x-forwarded-proto'] != 'http' 60 | end 61 | 62 | def process_request 63 | case @method 64 | when 'GET' 65 | process_get_request 66 | when 'POST' 67 | process_post_request 68 | when 'DELETE' 69 | process_delete_request 70 | end 71 | end 72 | 73 | def process_get_request 74 | parse_uri 75 | puts "==== Processing GET request #{@query} at #{Time.now}" if $verbose 76 | case @query 77 | when %r{^userdata/(.+)} 78 | @response_data = handle_user_data_request Regexp.last_match(1) 79 | when 'moredata' 80 | @response_data = handle_more_data_request 81 | when 'playlists' 82 | @response_data = handle_playlists_request nil 83 | when %r{^playlists/(.+)} 84 | @response_data = handle_playlists_request Regexp.last_match(1) 85 | when /.+/ 86 | @response_data = handle_file_request 87 | end 88 | end 89 | 90 | def process_post_request 91 | parse_uri 92 | puts "==== Processing POST request #{@query} at #{Time.now}" if $verbose 93 | case @query 94 | when %r{^publish/(.+)/(.+)/(.+)} 95 | handle_toggle_publish_request Regexp.last_match(1), CGI.unescape(Regexp.last_match(2)), Regexp.last_match(3), @body.strip 96 | end 97 | end 98 | 99 | def process_delete_request 100 | parse_uri 101 | puts "==== Processing DELETE request #{@query} at #{Time.now}" if $verbose 102 | case @query 103 | when %r{^publish/(.+)/(.+)} 104 | handle_toggle_publish_request Regexp.last_match(1), CGI.unescape(Regexp.last_match(2)), 0, nil 105 | end 106 | end 107 | 108 | def send_response 109 | return unless @response_data || @method != 'GET' 110 | 111 | puts "==== Sending response at #{Time.now}" if $verbose 112 | response = Response.new(code: 200, data: @response_data) 113 | response.send(@session) 114 | end 115 | 116 | def send_redirect 117 | puts "==== Sending 301 redirect at #{Time.now}" if $verbose 118 | data = "Location: https://#{@headers['host']}#{@uri}" 119 | response = Response.new(code: 301, data: data) 120 | response.send(@session) 121 | end 122 | 123 | def close 124 | @session.close 125 | end 126 | 127 | ############################################################################## 128 | private 129 | 130 | def get_uri session 131 | request = session.gets 132 | return if request.nil? 133 | 134 | @method, @uri = request.strip.split(/\s/) 135 | end 136 | 137 | def parse_headers session 138 | while (line = session.gets) 139 | line = line.strip.downcase 140 | break if line.empty? 141 | 142 | key, val = line.split(/:\s*/) 143 | next if key.nil? 144 | 145 | @headers[key.downcase] = val 146 | end 147 | end 148 | 149 | def get_body session 150 | nchars = @headers['content-length'].to_i 151 | @body = session.read(nchars) 152 | end 153 | 154 | def parse_uri 155 | uri_split, param_string = @uri.split('?') 156 | @params = param_string.nil? ? {} : CGI.parse(param_string) 157 | _, @query = uri_split.split('/', 2) 158 | @query = 'index.html' if @query.nil? || @query.empty? 159 | end 160 | 161 | def handle_file_request 162 | puts "==== Read file #{@query} at #{Time.now}" if $verbose 163 | File.binread(@query) 164 | end 165 | 166 | def handle_user_data_request user_name 167 | puts "==== User data request for #{user_name} at #{Time.now}" 168 | uri = URI("https://bandcamp.com/#{user_name}") 169 | Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| 170 | bc_request = Net::HTTP::Get.new(uri.request_uri) 171 | if @params.key?('identity-cookie') 172 | bc_request['Cookie'] = "identity=#{CGI.escape(@params['identity-cookie'].first)}" 173 | end 174 | http.request(bc_request).body 175 | end 176 | end 177 | 178 | def handle_more_data_request 179 | puts "==== More data request at #{Time.now}" if $verbose 180 | uri = URI('https://bandcamp.com/api/fancollection/1/collection_items') 181 | Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| 182 | bc_request = Net::HTTP::Post.new(uri.request_uri) 183 | if @params.key?('identity-cookie') 184 | bc_request['Cookie'] = "identity=#{CGI.escape(@params['identity-cookie'].first)}" 185 | end 186 | bc_request['Content-Type'] = 'application/json' 187 | bc_request.body = "{\"fan_id\":#{@params['fan-id'].first},"\ 188 | "\"older_than_token\":\"#{@params['older-than-token'].first}\","\ 189 | "\"count\":#{@params['count'].first.to_i}}" 190 | http.request(bc_request).body 191 | end 192 | end 193 | 194 | def handle_playlists_request user_name 195 | puts "==== Playlists request for #{user_name || 'all users'} at #{Time.now}" if $verbose 196 | result = '' 197 | begin 198 | con = PG.connect(ENV['DATABASE_URL']) 199 | query = 'select * from playlists' 200 | query += " where username='#{con.escape_string(user_name)}'" if user_name 201 | query += ' order by username, playlist_name' 202 | rs = con.exec query 203 | results = [] 204 | rs.each do |row| 205 | results.append("#{row['username']}|#{row['playlist_name']}|#{row['history']}|#{row['url']}") 206 | end 207 | result = results.join("\r\n") 208 | ensure 209 | con&.close 210 | end 211 | result 212 | end 213 | 214 | def handle_toggle_publish_request user_name, playlist_name, history, url 215 | puts "==== Handle #{url ? 'publish' : 'unpublish'} request for #{user_name} playlist #{playlist_name} at #{Time.now}" if $verbose 216 | begin 217 | con = PG.connect(ENV['DATABASE_URL']) 218 | if url 219 | query = "insert into playlists (username,playlist_name,history,url) values ('#{con.escape_string(user_name)}','#{con.escape_string(playlist_name)}',#{history},'#{con.escape_string(url)}')" 220 | else 221 | query = "delete from playlists where username='#{con.escape_string(user_name)}' and playlist_name='#{con.escape_string(playlist_name)}'" 222 | end 223 | con.exec query 224 | ensure 225 | con&.close 226 | end 227 | end 228 | end 229 | 230 | puts '================================================================================' 231 | puts "==== Starting server at #{Time.now}" 232 | while (session = server.accept) 233 | begin 234 | bc_radio = BcRadio.new session 235 | next unless bc_radio.initialized 236 | 237 | if !heroku_redirect_http || bc_radio.validate_https_request 238 | bc_radio.process_request 239 | bc_radio.send_response 240 | else 241 | bc_radio.send_redirect 242 | end 243 | rescue => e # rubocop:disable Style/RescueStandardError 244 | puts "==== ERROR #{e.message} at #{Time.now}\r\n#{e.backtrace}" 245 | ensure 246 | bc_radio.close 247 | end 248 | end 249 | -------------------------------------------------------------------------------- /css/bcradio.css: -------------------------------------------------------------------------------- 1 | /************* Loading **************/ 2 | #loading { 3 | width: 100%; 4 | height: 100%; 5 | top: 0; 6 | left: 0; 7 | position: fixed; 8 | display: none; 9 | opacity: 1.0; 10 | background-color: white; 11 | z-index: 99; 12 | text-align: center; 13 | } 14 | #loading-image { 15 | position: absolute; 16 | top: 50%; 17 | left: 50%; 18 | transform: translate(-50%, -50%); 19 | transform: -webkit-translate(-50%, -50%); 20 | transform: -moz-translate(-50%, -50%); 21 | transform: -ms-translate(-50%, -50%); 22 | z-index: 100; 23 | } 24 | 25 | /************* Cover **************/ 26 | #cover { 27 | width: 100%; 28 | height: 100%; 29 | top: 0; 30 | left: 0; 31 | position: fixed; 32 | display: none; 33 | opacity: 1.0; 34 | background-color: white; 35 | z-index: 99; 36 | text-align: center; 37 | } 38 | #cover-image { 39 | position: absolute; 40 | top: 50%; 41 | left: 50%; 42 | transform: translate(-50%, -50%); 43 | transform: -webkit-translate(-50%, -50%); 44 | transform: -moz-translate(-50%, -50%); 45 | transform: -ms-translate(-50%, -50%); 46 | z-index: 100; 47 | width: 101vw; 48 | height: 101vh; 49 | object-fit: cover; 50 | } 51 | 52 | /************* Common **************/ 53 | html { 54 | -webkit-text-size-adjust: none; 55 | } 56 | body { 57 | font-family: arial, sans-serif; 58 | color: whitesmoke; 59 | background-color: grey; 60 | margin: 0 0 0 0; 61 | } 62 | img { 63 | border-width: 0; 64 | } 65 | audio { 66 | flex-grow: 1; 67 | height: 34px; 68 | width: max(50px,20vw); 69 | align-self: center; 70 | } 71 | a { 72 | text-decoration: none; 73 | color: #BBFFFF; 74 | } 75 | p { 76 | font-size: small; 77 | margin-bottom: 5px; 78 | } 79 | ul { 80 | margin-top: 0px; 81 | } 82 | input[type=text] { 83 | width: 110px; 84 | } 85 | input[type=number] { 86 | width: 110px; 87 | } 88 | :focus { 89 | outline: 0 !important; 90 | } 91 | .text-label { 92 | display: inline-block; 93 | width: 210px; 94 | text-align: left; 95 | margin-bottom: 8px; 96 | } 97 | .playedTrack { 98 | background-color: #b0b0b0; 99 | } 100 | .skippedTrack { 101 | background-color: #d0c050; 102 | } 103 | .prev-next { 104 | align-self: center; 105 | } 106 | .tiny-icon { 107 | margin-bottom: -2px; 108 | } 109 | .disabled-icon { 110 | opacity: 0.4; 111 | } 112 | #params { 113 | padding: 10px; 114 | } 115 | #params-form { 116 | margin: 30 0 0 0; 117 | } 118 | #playlists { 119 | margin: 0 0 0 0; 120 | } 121 | #window-type { 122 | margin: 30 0 10 0; 123 | } 124 | #submit-button { 125 | margin-top: 10px; 126 | background-color: #BBFFFF; 127 | border-color: #BBFFFF; 128 | width: 320px; 129 | height: 30px; 130 | font-size: 16; 131 | } 132 | #title1 { 133 | color: #BBFFFF; 134 | font-family: Georgia; 135 | font-size: 22; 136 | margin: 0; 137 | } 138 | #title2 { 139 | color: #BBFFFF; 140 | margin: 30 0 6 0; 141 | } 142 | #help1 { 143 | font-size: small; 144 | margin: 3 0 0 0; 145 | } 146 | #player-container { 147 | height: 100%; 148 | display: grid; 149 | grid-template-columns: auto; 150 | grid-template-rows: auto; 151 | grid-template-areas: 152 | "player"; 153 | } 154 | #player { 155 | height: 100%; 156 | grid-area: "player"; 157 | display: grid; 158 | grid-template-columns: auto; 159 | grid-template-rows: min-content 1fr; 160 | grid-template-areas: 161 | "art" 162 | "notArt"; 163 | } 164 | #art-container { 165 | outline: 0; 166 | border: none; 167 | -moz-outline-style: none; 168 | outline-style: none; 169 | grid-area: art; 170 | } 171 | #album-art { 172 | height: 100vw; 173 | width: 100vw; 174 | object-fit: cover; 175 | } 176 | #not-art { 177 | height: 100%; 178 | width: 100%; 179 | box-sizing: border-box; 180 | padding: 6 6 8 6; 181 | background-color: grey; 182 | grid-area: notArt; 183 | display: grid; 184 | grid-template-columns: auto; 185 | grid-template-rows: min-content min-content min-content 1fr min-content; 186 | grid-template-areas: 187 | "r1" 188 | "r2" 189 | "r3" 190 | "r4" 191 | "r5"; 192 | } 193 | #audio-controls { 194 | margin-top: 4px; 195 | margin-bottom: 4px; 196 | display: flex; 197 | grid-area: r1; 198 | } 199 | #song-title { 200 | font-size: 24; 201 | white-space: nowrap; 202 | overflow: hidden; 203 | text-overflow: ellipsis; 204 | display: inline-block; 205 | outline: 0; 206 | border: none; 207 | -moz-outline-style: none; 208 | outline-style: none; 209 | grid-area: r2; 210 | width: 100%; 211 | text-align: center; 212 | } 213 | #list-title { 214 | width: 100%; 215 | font-size: small; 216 | margin-bottom: 2px; 217 | margin-top: 15px; 218 | grid-area: r3; 219 | display: grid; 220 | grid-template-columns: 1fr auto; 221 | grid-template-rows: auto; 222 | grid-template-areas: 223 | "collector sorts"; 224 | } 225 | #collection-title { 226 | grid-area: collector; 227 | white-space: nowrap; 228 | overflow: hidden; 229 | text-overflow: ellipsis; 230 | display: inline-block; 231 | } 232 | #sort-controls { 233 | grid-area: sorts; 234 | justify-self: end; 235 | margin-top: -4px; 236 | } 237 | #collection-list { 238 | width: 100%; 239 | height: 100%; 240 | background-color: #d0e0f0; 241 | grid-area: r4; 242 | } 243 | #help2 { 244 | font-size: small; 245 | margin: 4 0 0 0; 246 | grid-area: r5; 247 | } 248 | 249 | /*************** Skinny ****************/ 250 | @media (max-aspect-ratio: 15/20) and (max-width: 230px) { 251 | #collection-title { 252 | display: none; 253 | } 254 | } 255 | 256 | /*************** Short ****************/ 257 | @media (min-aspect-ratio: 15/20) and (max-aspect-ratio: 9/10) { 258 | #list-title { 259 | display: none; 260 | } 261 | #collection-list { 262 | display: none; 263 | } 264 | #help2 { 265 | display: none; 266 | } 267 | } 268 | 269 | /*************** Square ****************/ 270 | @media (min-aspect-ratio: 9/10) and (max-aspect-ratio: 11/10) { 271 | body { 272 | background-color: black; 273 | } 274 | #player-container { 275 | height: auto; 276 | display: grid; 277 | grid-template-columns: 1fr auto 1fr; 278 | grid-template-rows: auto; 279 | grid-template-areas: "left player right"; 280 | } 281 | #player { 282 | height: auto; 283 | grid-area: player; 284 | display: grid; 285 | grid-template-columns: auto; 286 | grid-template-rows: auto; 287 | grid-template-areas: "art"; 288 | } 289 | #player #not-art { 290 | display: none; 291 | } 292 | #player:hover #not-art { 293 | display: inline; 294 | } 295 | #art-container { 296 | grid-area: art; 297 | } 298 | #album-art { 299 | max-width: 100%; 300 | width: 100vh; 301 | max-height: 100vh; 302 | } 303 | #not-art { 304 | height: auto; 305 | grid-area: art; 306 | align-self: end; 307 | max-width: 90vw; 308 | justify-self: center; 309 | opacity: 0.6; 310 | padding: 14px 12px 0px 12px; 311 | margin-bottom: 30px; 312 | display: auto; 313 | width: auto; 314 | } 315 | #song-title { 316 | margin-top: 8px; 317 | margin-bottom: 12px; 318 | } 319 | #list-title { 320 | display: none; 321 | } 322 | #collection-list { 323 | display: none; 324 | } 325 | #help2 { 326 | display: none; 327 | } 328 | } 329 | 330 | /*************** Landscape ****************/ 331 | @media (min-aspect-ratio: 11/10) { 332 | #player { 333 | display: grid; 334 | grid-template-columns: minmax(0, max-content) minmax(450px, 1fr); 335 | grid-template-rows: auto; 336 | grid-template-areas: "art controls"; 337 | } 338 | #art-container { 339 | align-self: normal; 340 | grid-area: art; 341 | display: flex; 342 | flex-direction: column; 343 | } 344 | #album-art { 345 | align-self: self-start; 346 | max-width: 100%; 347 | width: 100vh; 348 | max-height: 100vh; 349 | } 350 | #not-art { 351 | min-width: 450px; 352 | width: 100%; 353 | grid-area: controls; 354 | } 355 | } 356 | 357 | /*************** Mini player ****************/ 358 | @media (min-aspect-ratio: 11/10) and (max-height: 220px) { 359 | #player { 360 | grid-template-columns: minmax(0, max-content) minmax(280px, 1fr); 361 | } 362 | #not-art { 363 | min-width: 280px; 364 | } 365 | #list-title { 366 | display: none; 367 | } 368 | #collection-list { 369 | display: none; 370 | } 371 | #help2 { 372 | display: none; 373 | } 374 | } 375 | 376 | /*************** No art ****************/ 377 | @media (min-aspect-ratio: 11/10) and (max-width: 530px) and (min-height: 220px) { 378 | #art-container { 379 | display: none; 380 | } 381 | } 382 | 383 | /*************** Mini no art ****************/ 384 | @media (min-aspect-ratio: 11/10) and (max-width: 410px) and (max-height: 220px) { 385 | #art-container { 386 | display: none; 387 | } 388 | } 389 | 390 | /*************** Mobile ****************/ 391 | @media (pointer: coarse) { 392 | #window-type { 393 | display: none; 394 | } 395 | #collection-list { 396 | height: max-content; 397 | } 398 | } 399 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ralphgonz/bcradio/cc44bd9780e9833656122a00075317fd4b642994/favicon.ico -------------------------------------------------------------------------------- /images/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ralphgonz/bcradio/cc44bd9780e9833656122a00075317fd4b642994/images/delete.png -------------------------------------------------------------------------------- /images/external-link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ralphgonz/bcradio/cc44bd9780e9833656122a00075317fd4b642994/images/external-link.png -------------------------------------------------------------------------------- /images/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ralphgonz/bcradio/cc44bd9780e9833656122a00075317fd4b642994/images/loading.gif -------------------------------------------------------------------------------- /images/next-outline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ralphgonz/bcradio/cc44bd9780e9833656122a00075317fd4b642994/images/next-outline.png -------------------------------------------------------------------------------- /images/playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ralphgonz/bcradio/cc44bd9780e9833656122a00075317fd4b642994/images/playlist.png -------------------------------------------------------------------------------- /images/prev-outline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ralphgonz/bcradio/cc44bd9780e9833656122a00075317fd4b642994/images/prev-outline.png -------------------------------------------------------------------------------- /images/published.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ralphgonz/bcradio/cc44bd9780e9833656122a00075317fd4b642994/images/published.png -------------------------------------------------------------------------------- /images/responsive-demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ralphgonz/bcradio/cc44bd9780e9833656122a00075317fd4b642994/images/responsive-demo.jpg -------------------------------------------------------------------------------- /images/scope-tiny.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ralphgonz/bcradio/cc44bd9780e9833656122a00075317fd4b642994/images/scope-tiny.gif -------------------------------------------------------------------------------- /images/unpublished.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ralphgonz/bcradio/cc44bd9780e9833656122a00075317fd4b642994/images/unpublished.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BC Radio 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | 19 | 20 |
21 | Loading... 22 |
23 | 24 |
25 | 26 |
27 | 28 |
29 |

Bandcamp Collection Player

30 |

Help & source

31 |
32 |

33 |

34 |

35 |
36 |
37 |
38 | 39 | 40 | 42 | 43 | 44 | 45 | 46 |
47 |

Playlists

48 |
49 |
50 |
51 | 52 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /js/bcradio.1.js: -------------------------------------------------------------------------------- 1 | // bcradio: Play your bandcamp collection 2 | // Copyright © 2021 Ralph Gonzalez 3 | 4 | var bcradio = (function() { 5 | 6 | var userName; 7 | var numberToLoad; 8 | var identityCookie; 9 | var playlistFilterItems; 10 | var playlistName; 11 | 12 | var trackList; 13 | var itemInfos; 14 | var skipItems; 15 | var playlistItems; 16 | var playlists; 17 | var cookied; 18 | 19 | var albumArtElt; 20 | var currentSongElt; 21 | var collectionTitleElt; 22 | var songTitleElt; 23 | var paramsElt; 24 | var playerElt; 25 | var thumbnailElt; 26 | var artContainerElt; 27 | var collectionListElt; 28 | var coverElt; 29 | var coverImageElt; 30 | var openPlaylistElt; 31 | var publishPlaylistElt; 32 | var unpublishPlaylistElt; 33 | var playlistsElt; 34 | 35 | ///////////////////////////////// public methods ///////////////////////////////////// 36 | var pub = {}; 37 | 38 | pub.init = function() { 39 | albumArtElt = $('#album-art'); 40 | currentSongElt = $('#current-song'); 41 | collectionTitleElt = $('#collection-title'); 42 | songTitleElt = $('#song-title'); 43 | paramsElt = $('#params'); 44 | playerElt = $('#player-container'); 45 | paramsFormElt = $('#params-form'); 46 | thumbnailElt = $('#thumbnail'); 47 | artContainerElt = $('#art-container'); 48 | collectionListElt = $("#collection-list"); 49 | coverElt = $("#cover"); 50 | coverImageElt = $("#cover-image"); 51 | openPlaylistElt = $("#open-playlist"); 52 | publishPlaylistElt = $("#publish-playlist"); 53 | unpublishPlaylistElt = $("#unpublish-playlist"); 54 | playlistsElt = $("#playlists"); 55 | trackList = new TrackList(); 56 | itemInfos = {}; 57 | skipItems = loadSkipItems(); 58 | playlistItems = new Set(); 59 | playlistName = null; 60 | playlistFilterItems = null; 61 | playlists = null; 62 | cookied = null; 63 | 64 | collectionListElt.on('change', function(){ 65 | trackList.setCurrent($(this).val()); 66 | pub.next(); 67 | }); 68 | 69 | artContainerElt.on('click', function(e){ 70 | var offset = $(this).offset(); 71 | var x = (e.pageX - offset.left) / $(this).width(); 72 | var y = (e.pageY - offset.top) / $(this).height(); 73 | if (y > 0.67 && x < 0.33) { pub.prev(); } 74 | else if (y > 0.67 && x > 0.67) { pub.next(); } 75 | else if (y > 0.67) { pub.togglePausePlay(); } 76 | else { coverElt.show(); } 77 | }); 78 | 79 | coverElt.on('click', function(e){ 80 | var offset = $(this).offset(); 81 | var x = (e.pageX - offset.left) / $(this).width(); 82 | var y = (e.pageY - offset.top) / $(this).height(); 83 | if (y > 0.67 && x < 0.33) { pub.prev(); } 84 | else if (y > 0.67 && x > 0.67) { pub.next(); } 85 | else if (y > 0.67) { pub.togglePausePlay(); } 86 | else { coverElt.hide(); } 87 | }); 88 | 89 | var searchParams = new URLSearchParams(window.location.search); 90 | if (searchParams.has('username')) { 91 | userName = searchParams.get('username'); 92 | numberToLoad = searchParams.get('history'); 93 | identityCookie = searchParams.get('identity'); 94 | if (searchParams.has('pl') && searchParams.get('pl')) { 95 | playlistFilterItems = new Set(searchParams.get('pl').split(',')); 96 | playlistName = searchParams.get('plname'); 97 | } 98 | pub.start(); 99 | return; 100 | } 101 | 102 | paramsFormElt.submit(function (evt){ 103 | evt.preventDefault(); 104 | 105 | userName = $('#user-name').val().trim(); 106 | numberToLoad = $('#history').val().trim(); 107 | identityCookie = maybeUriDecode($('#identity-cookie').val().trim()); 108 | playlistName = null; 109 | if (!userName) { 110 | reportBadUsername(); 111 | return; 112 | } 113 | 114 | openWindow(); 115 | }); 116 | 117 | requestPlaylists(); 118 | }; 119 | 120 | pub.start = function() { 121 | $('#loading').show(); 122 | var userNameRequest = `/userdata/${userName}`; 123 | if (identityCookie) { 124 | userNameRequest += `?identity-cookie=${maybeUriEncode(identityCookie)}`; 125 | } 126 | $.get(userNameRequest, loadInitialData) 127 | .fail(function() { 128 | reportBadUsername("HTTP GET request failed"); 129 | }); 130 | }; 131 | 132 | pub.togglePublishPlaylist = function() { 133 | if (isPublishedPlaylist()) { 134 | $.ajax({ 135 | url: `publish/${userName}/${playlistName}`, 136 | type: 'DELETE', 137 | success: requestUserPlaylists, 138 | error: function() { 139 | alert(`Failed attempting to unpublish playlist ${playlistName} for user ${userName}`); 140 | } 141 | }); 142 | } else { 143 | if (playlists.size >= 8) { 144 | alert("You have already published 8 playlists. Open a new tab and unpublish one of them, then refresh this tab to try again."); 145 | return; 146 | } 147 | $.post(`publish/${userName}/${playlistName}/${numberToLoad}`, Array.from(playlistFilterItems).join(","), requestUserPlaylists) 148 | .fail(function() { 149 | alert(`Failed attempting to publish playlist ${playlistName} for user ${userName}`); 150 | }); 151 | } 152 | }; 153 | 154 | pub.openPlaylist = function() { 155 | var promptString = cookied 156 | ? "Playlist name" 157 | : "You can create a playlist without providing an 'identity cookie', but you will not be able to publish it to the BCRadio login screen (see the help page: https://github.com/ralphgonz/bcradio#playlists).\n\nPlaylist name"; 158 | var name = prompt(promptString, "Playlist"); 159 | if (!name) { 160 | return; 161 | } 162 | var playlist = Array.from(playlistItems).join(","); 163 | var win = window.open(getUrl(userName, numberToLoad, identityCookie, name, playlist), '_blank'); 164 | if (win) { 165 | win.focus(); 166 | } else { 167 | alert('Please allow popups for this website'); 168 | } 169 | }; 170 | 171 | pub.playPlaylist = function(usernameVal, playlistNameVal) { 172 | userName = usernameVal; 173 | playlistName = atob(playlistNameVal); 174 | var pl = playlists.get(`${usernameVal}|${playlistName}`); 175 | numberToLoad = pl['history']; 176 | var urlVal = pl['url']; 177 | identityCookie = maybeUriDecode($('#identity-cookie').val().trim()); 178 | openWindow(urlVal); 179 | } 180 | 181 | pub.resequence = function() { 182 | switch($('input[name=sequencing]:checked').val()) { 183 | case 'shuffle': 184 | trackList.sortShuffle(); 185 | break; 186 | case 'alphabetic': 187 | trackList.sortAlphabetic(); 188 | break; 189 | default: 190 | trackList.sortMostRecent(); 191 | } 192 | 193 | trackList.setCurrent(0); 194 | trackList.nextUnplayed(); 195 | populateCollectionList(); 196 | }; 197 | 198 | pub.togglePausePlay = function() { 199 | if (currentSongElt.prop('paused')) { 200 | currentSongElt.trigger('play'); 201 | } else { 202 | currentSongElt.trigger('pause'); 203 | } 204 | } 205 | 206 | pub.prev = function() { 207 | trackList.prev(); 208 | pub.next(); 209 | } 210 | 211 | pub.next = function() { 212 | trackList.clearCurrentCount(); 213 | currentSongElt.off(); 214 | currentSongElt.trigger('pause'); 215 | playNext(true); 216 | } 217 | 218 | pub.toggleDelete = function() { 219 | var skipItem = trackList.track(trackList.current - 1).itemId; 220 | 221 | if (skipItems.has(skipItem)) { 222 | trackList.skipMatchingItems(skipItem, false); 223 | skipItems.delete(skipItem); 224 | saveSkipItems(skipItems); 225 | return; 226 | } 227 | 228 | var artist = trackList.track(trackList.current - 1).artist; 229 | if (!confirm(`Permanently skip this album by ${artist} on this browser?`)) { 230 | return; 231 | } 232 | trackList.skipMatchingItems(skipItem, true); 233 | skipItems.add(skipItem); 234 | saveSkipItems(skipItems); 235 | currentSongElt.off(); 236 | currentSongElt.trigger('pause'); 237 | playNext(true); 238 | } 239 | 240 | pub.togglePlaylist = function() { 241 | var playlistItem = trackList.track(trackList.current - 1).itemId; 242 | 243 | if (playlistItems.has(playlistItem)) { 244 | playlistItems.delete(playlistItem); 245 | trackList.playlistMatchingItems(playlistItem, false); 246 | } else { 247 | playlistItems.add(playlistItem); 248 | trackList.playlistMatchingItems(playlistItem, true); 249 | } 250 | syncOpenPlaylistButton(); 251 | } 252 | 253 | ///////////////////////////////// private ///////////////////////////////////// 254 | 255 | var openWindow = function(playlistUrlVal) { 256 | var width; 257 | var height; 258 | switch($('input[name=windowType]:checked').val()) { 259 | case 'vertical': 260 | width = 200; 261 | height = 520; 262 | break; 263 | case 'album': 264 | width = 800; 265 | height = 856; 266 | break; 267 | case 'mini': 268 | width = 460; 269 | height = 165; 270 | break; 271 | default: // same tab 272 | if (playlistName) { 273 | window.open(getUrl(userName, numberToLoad, identityCookie, playlistName, playlistUrlVal), "_self"); 274 | } else { 275 | pub.start(); 276 | } 277 | return; 278 | } 279 | 280 | var popup = window.open(getUrl(userName, numberToLoad, identityCookie, playlistName, playlistUrlVal), 281 | 'bcradio', 282 | `menubar=no,toolbar=no,location=no,status=no,left=100,top=100,width=${width},height=${height}`); 283 | popup.resizeTo(width, height); 284 | } 285 | 286 | var getUrl = function(usernameVal, historyVal, identityVal, playlistNameVal, playlistVal) { 287 | var trimmedLocation = document.location.href.split('?')[0].replace("#", ""); 288 | var result = `${trimmedLocation}?username=${usernameVal}&history=${historyVal}&identity=${maybeUriEncode(identityVal)}`; 289 | if (playlistNameVal) { 290 | result += `&plname=${maybeUriEncode(playlistNameVal)}&pl=${playlistVal}`; 291 | } 292 | return result; 293 | } 294 | 295 | var isPublishedPlaylist = function() { 296 | var key = `${userName}|${playlistName}`; 297 | return (playlists && playlists.has(key)); 298 | } 299 | 300 | var loadSkipItems = function() { 301 | var s = localStorage.getItem("skipItems"); 302 | if (!s) { 303 | return new Set(); 304 | } 305 | return new Set(s.split("|")); 306 | } 307 | 308 | var saveSkipItems = function(sSet) { 309 | var s = Array.from(sSet).join("|"); 310 | localStorage.setItem("skipItems", s); 311 | } 312 | 313 | var populateCollectionList = function() { 314 | collectionListElt.empty(); 315 | for (var i=0 ; i${trackList.track(i).artist}: ${trackList.track(i).title}\n`); 317 | syncIsPlayed(i); 318 | syncIsPlaylist(i); 319 | } 320 | syncOpenPlaylistButton(); 321 | requestUserPlaylists(); 322 | } 323 | 324 | var syncIsPlayed = function(i) { 325 | if (trackList.track(i).isSkipped) { 326 | $(`#collection-list option[value=${i}]`).removeClass("playedTrack"); 327 | $(`#collection-list option[value=${i}]`).addClass("skippedTrack"); 328 | } else if (trackList.track(i).isPlayed) { 329 | $(`#collection-list option[value=${i}]`).removeClass("skippedTrack"); 330 | $(`#collection-list option[value=${i}]`).addClass("playedTrack"); 331 | } else { 332 | $(`#collection-list option[value=${i}]`).removeClass("skippedTrack"); 333 | $(`#collection-list option[value=${i}]`).removeClass("playedTrack"); 334 | } 335 | } 336 | 337 | var syncIsPlaylist = function(i) { 338 | var arrowText = '\u2705 '; 339 | var t = $(`#collection-list option[value=${i}]`).text().replace(arrowText, ''); 340 | if (trackList.track(i).isPlaylist) { 341 | t = arrowText + t; 342 | } 343 | $(`#collection-list option[value=${i}]`).text(t); 344 | } 345 | 346 | var syncOpenPlaylistButton = function() { 347 | for (var i=0 ; i${usernameVal}

\n\n"; 383 | } 384 | playlistsElt.html(htmlString); 385 | } 386 | 387 | var requestUserPlaylists = function() { 388 | if (!playlistName || cookied === null || !cookied) { return; } 389 | 390 | var request = `playlists/${userName}`; 391 | $.get(request, loadUserPlaylists) 392 | .fail(function() { 393 | alert(`Failed getting existing playlists for user ${userName}`); 394 | }); 395 | } 396 | 397 | var loadUserPlaylists = function(data) { 398 | parsePlaylists(data); 399 | syncPublishPlaylists(); 400 | } 401 | 402 | var parsePlaylists = function(data) { 403 | playlists = new Map(); 404 | data.split('\r\n').forEach(function(row){ 405 | var [usernameVal, playlistNameVal, historyVal, urlVal] = row.split('|'); 406 | playlists.set(`${usernameVal}|${playlistNameVal}`, { url: urlVal, history: historyVal} ); 407 | }); 408 | } 409 | 410 | var syncPublishPlaylists = function() { 411 | if (isPublishedPlaylist()) { 412 | unpublishPlaylistElt.show(); 413 | publishPlaylistElt.hide(); 414 | } else { 415 | publishPlaylistElt.show(); 416 | unpublishPlaylistElt.hide(); 417 | } 418 | } 419 | 420 | var maybeUriDecode = function(s) { 421 | if (!s) { return s; } 422 | try { 423 | return decodeURIComponent(s); 424 | } catch (err) { 425 | return s; 426 | } 427 | } 428 | 429 | var maybeUriEncode = function(s) { 430 | if (!s) { return s; } 431 | try { 432 | return encodeURIComponent(s); 433 | } catch (err) { 434 | return s; 435 | } 436 | } 437 | 438 | var reportBadUsername = function(error) { 439 | $('#loading').hide(); 440 | alert(`No data found for username ${userName} (${error})`) 441 | } 442 | 443 | var loadInitialData = function(data) { 444 | var dataBlobJson; 445 | var fanName; 446 | var error; 447 | try { 448 | var htmlData = $('').append($.parseHTML(data)); 449 | var dataBlob = htmlData.find("#pagedata").attr("data-blob"); 450 | dataBlobJson = JSON.parse(dataBlob); 451 | 452 | // Load initial page of tracks 453 | fanName = dataBlobJson.fan_data.name; 454 | var cookieStatus = ""; 455 | if (identityCookie) { 456 | if (dataBlobJson.identities.fan) { 457 | cookieStatus = " (cookied)"; 458 | cookied = true; 459 | } else { 460 | cookieStatus = " (invalid cookie)"; 461 | cookied = false; 462 | } 463 | } 464 | var collectionName = playlistName || `${fanName}'s collection`; 465 | collectionTitleElt.text(`${collectionName}${cookieStatus}`); 466 | extractInfos(Object.values(dataBlobJson.item_cache.collection)); // (a or t) -> { album_id, featured_track, item_art_id, item_id} 467 | extractTracks(dataBlobJson.tracklists.collection); 468 | } catch (err) { 469 | error = err.message; 470 | } finally { 471 | if (!dataBlobJson || !fanName) { 472 | reportBadUsername(error); 473 | return; 474 | } 475 | } 476 | 477 | // Query for remaining numberToLoad tracks 478 | var fanId = dataBlobJson.fan_data.fan_id; 479 | var lastToken = dataBlobJson.collection_data.last_token; 480 | var moreDataRequest = `moredata?fan-id=${fanId}&older-than-token=${lastToken}&count=${numberToLoad}`; 481 | if (identityCookie) { 482 | moreDataRequest = `${moreDataRequest}&identity-cookie=${maybeUriEncode(identityCookie)}`; 483 | } 484 | $.get(moreDataRequest, loadMoreData) 485 | .fail(function() { 486 | $('#loading').hide(); 487 | alert(`Failed attempting to retrieve ${numberToLoad} additional elements`); 488 | }); 489 | } 490 | 491 | var loadMoreData = function(data) { 492 | var result = JSON.parse(data); 493 | extractInfos(result.items); // [{ album_id, featured_track, item_art_id, item_id}, ...] 494 | extractTracks(result.tracklists); 495 | startPlaying(); 496 | } 497 | 498 | var startPlaying = function() { 499 | paramsElt.hide(); 500 | playerElt.show(); 501 | pub.resequence(); 502 | $('#loading').hide(); 503 | playNext(true); 504 | } 505 | 506 | var extractInfos = function(items) { 507 | for (const info of items) { 508 | if (info["item_id"]) { 509 | itemInfos[info["item_id"]] = new ItemInfo(info["item_art_id"], info["item_url"]); 510 | } 511 | } 512 | } 513 | 514 | var extractTracks = function(collection) { 515 | for (const [album, songs] of Object.entries(collection)) { 516 | songs.forEach(function(track) { 517 | var file = track.file["mp3-v0"] || track.file["mp3-128"]; 518 | var itemId = album.substring(1); 519 | if (!playlistFilterItems || playlistFilterItems.has(itemId)) { 520 | trackList.addTrack(track.artist, track.title, file, itemInfos[itemId].artId, itemId, itemInfos[itemId].itemUrl); 521 | } 522 | }); 523 | } 524 | } 525 | 526 | var setMediaSession = function(track) { 527 | navigator.mediaSession.metadata = new MediaMetadata({ 528 | title: track.title, 529 | artist: track.artist, 530 | artwork: [ 531 | { src: track.largeAlbumArt(), type: 'image/jpg' }, 532 | ] 533 | }); 534 | navigator.mediaSession.setActionHandler('previoustrack', pub.prev); 535 | navigator.mediaSession.setActionHandler('nexttrack', pub.next); 536 | } 537 | 538 | var setTitles = function(track) { 539 | albumArtElt.attr('src', track.largeAlbumArt()); 540 | coverImageElt.attr('src', albumArtElt.attr('src')); 541 | songTitleElt.text(`${track.artist}: ${track.title}`) 542 | songTitleElt.attr('href', track.itemUrl); 543 | thumbnailElt.attr('href', track.smallAlbumArt()); 544 | document.title = `${track.title} (${track.artist})`; 545 | currentSongElt.attr('src', track.songUrl); 546 | if ('mediaSession' in navigator) { 547 | setMediaSession(track); 548 | } 549 | } 550 | 551 | // Doubles as entry point for manually-clicked song, without affecting sequence 552 | var playNext = function(startPlaying) { 553 | if (!trackList.nextUnplayed()) { 554 | pub.resequence(); 555 | } 556 | 557 | var i = trackList.advanceTrack(); 558 | syncIsPlayed(i); 559 | collectionListElt.val(i); 560 | setTitles(trackList.track(i)); 561 | 562 | currentSongElt.trigger('load'); 563 | if (startPlaying) { 564 | currentSongElt.trigger('play'); 565 | } 566 | currentSongElt.one('ended', function() { 567 | playNext(true); 568 | }); 569 | 570 | currentSongElt.one('error', function() { 571 | currentSongElt.trigger('pause'); 572 | currentSongElt.off('error'); 573 | alert('Failed to play song file:\n' + 574 | '* If you supplied an identity cookie please refresh your Bandcamp login in another tab and retry\n' + 575 | '* Some mobile browsers don\'t support the identity cookie feature'); 576 | }); 577 | } 578 | 579 | /////////////////// Class: Track 580 | function Track(artist, title, songUrl, artId, itemId, itemUrl, position, isSkipped) { 581 | this.artist = artist; 582 | this.title = title; 583 | this.songUrl = songUrl; 584 | this.isPlayed = isSkipped; 585 | this.isSkipped = isSkipped; 586 | this.isPlaylist = false; 587 | this.recent = position; 588 | this.artId = artId; 589 | this.itemUrl = itemUrl; 590 | this.itemId = itemId; 591 | } 592 | Track.prototype.smallAlbumArt = function() { 593 | if (this.artId) { 594 | return `https://f4.bcbits.com/img/a${this.artId}_3.jpg` 595 | } else { 596 | return ''; 597 | } 598 | } 599 | Track.prototype.largeAlbumArt = function() { 600 | if (this.artId) { 601 | return `https://f4.bcbits.com/img/a${this.artId}_10.jpg` 602 | } else { 603 | return ''; 604 | } 605 | } 606 | 607 | /////////////////// Class: TrackList 608 | function TrackList() { 609 | this.tracks = []; 610 | this.current = 0; 611 | } 612 | TrackList.prototype.setCurrent = function(i) { 613 | this.current = i; 614 | } 615 | TrackList.prototype.sortShuffle = function() { 616 | // Knuth/Fisher-Yates Shuffle 617 | var currentIndex = this.tracks.length, temporaryValue, randomIndex; 618 | while (0 !== currentIndex) { 619 | randomIndex = Math.floor(Math.random() * currentIndex); 620 | currentIndex -= 1; 621 | temporaryValue = this.tracks[currentIndex]; 622 | this.tracks[currentIndex] = this.tracks[randomIndex]; 623 | this.tracks[randomIndex] = temporaryValue; 624 | } 625 | } 626 | TrackList.prototype.sortAlphabetic = function() { 627 | this.tracks.sort((a, b) => 628 | a.artist.localeCompare(b.artist, 'en', {'sensitivity': 'base'}) 629 | || a.title.localeCompare(b.title, 'en', {'sensitivity': 'base'}) 630 | ) 631 | } 632 | TrackList.prototype.sortMostRecent = function() { 633 | this.tracks.sort((a, b) => 634 | a.recent > b.recent ? 1 : (a.recent < b.recent ? -1 : 0) 635 | ) 636 | } 637 | TrackList.prototype.clearCounts = function() { 638 | for (var i=0 ; i= this.tracks.length) { 673 | this.clearCounts(); 674 | return false; 675 | } 676 | return true; 677 | } 678 | TrackList.prototype.advanceTrack = function() { 679 | this.tracks[this.current].isPlayed = true; 680 | return this.current++; 681 | } 682 | TrackList.prototype.addTrack = function(artist, title, file, artId, itemId, itemUrl) { 683 | this.tracks.push(new Track(artist, title, file, artId, itemId, itemUrl, this.length, skipItems.has(itemId))); 684 | } 685 | TrackList.prototype.length = function() { 686 | return this.tracks.length; 687 | } 688 | TrackList.prototype.track = function(i) { 689 | return this.tracks[i]; 690 | } 691 | 692 | /////////////////// Class: ItemInfo 693 | function ItemInfo(artId, itemUrl) { 694 | this.artId = artId; 695 | this.itemUrl = itemUrl; 696 | } 697 | 698 | return pub; 699 | }()); 700 | -------------------------------------------------------------------------------- /js/jquery-3.5.1.min.js: -------------------------------------------------------------------------------- 1 | /*! jQuery v3.5.1 | (c) JS Foundation and other contributors | jquery.org/license */ 2 | !function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.5.1",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function D(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||j,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,j=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function qe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function Le(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function He(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Oe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):("number"==typeof f.top&&(f.top+="px"),"number"==typeof f.left&&(f.left+="px"),c.css(f))}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=$e(y.pixelPosition,function(e,t){if(t)return t=Be(e,n),Me.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0