├── .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 | 
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 |

22 |
23 |
24 |
25 |
![]()
26 |
27 |
28 |
29 |
Bandcamp Collection Player
30 |
Help & source
31 |
37 |
38 |
39 |
40 |
42 |
43 |
44 |
45 |
46 |
47 |
Playlists
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |

59 |
60 |

61 |

62 |

63 |
64 |
65 |
66 |
67 |
79 |
80 |
81 |
Bandcamp Collection Player. Help & source
82 |
83 |
84 |
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\n";
374 | }
375 | if (usernameVal != previousUsername) {
376 | htmlString += `${usernameVal}
\n\n`;
377 | previousUsername = usernameVal;
378 | }
379 | htmlString += `- ${playlistNameVal}
\n`;
380 | };
381 | if (htmlString) {
382 | htmlString += "
\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 = $('