├── profile.json ├── SpaceMono-Regular.ttf ├── .datignore ├── dat.json ├── index.html ├── playlists └── playlist.json ├── README.md ├── package.json ├── tutorial.md ├── .gitignore ├── links └── style.css └── app.js /profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "listener", 3 | "following": [] 4 | } -------------------------------------------------------------------------------- /SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cblgh/datradio/HEAD/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /.datignore: -------------------------------------------------------------------------------- 1 | *.sw[op] 2 | .DS* 3 | .idea 4 | *.spec.js 5 | jest.config.js 6 | __snapshots__ 7 | node_modules -------------------------------------------------------------------------------- /dat.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "dat://47fe02a7bc5022f755d2421a2f7b9af441286ee4120b1a8186de4411b9c68f1b/", 3 | "title": "datradio", 4 | "fallback_page": "index.html", 5 | "description": "it's time" 6 | } 7 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | datradio 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /playlists/playlist.json: -------------------------------------------------------------------------------- 1 | { 2 | "archives": [ 3 | "dat://7b8c5b60a64282ece25649363dc33d26d051d6265a993de70d5355cc1af7b95a" 4 | ], 5 | "tracks": [ 6 | "dat://7b8c5b60a64282ece25649363dc33d26d051d6265a993de70d5355cc1af7b95a/weekly-beats-week-15-italo-easter-cblgh-2018.mp3", 7 | "dat://7b8c5b60a64282ece25649363dc33d26d051d6265a993de70d5355cc1af7b95a/weekly-beats-week-14-cblgh-sunless-society.mp3" 8 | ], 9 | "removed": [], 10 | "description": "the latest weekly beats", 11 | "profile": { 12 | "bg": "black", 13 | "color": "#f2f2f2", 14 | "archive": "dat://7b8c5b60a64282ece25649363dc33d26d051d6265a993de70d5355cc1af7b95a" 15 | } 16 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # datradio 2 | here we go again 3 | 4 | ![](https://pbs.twimg.com/media/DZe9YxVW4AgMHbj.jpg:large) 5 | 6 | 7 | # Setup 8 | * Clone the repo 9 | * Navigate to the folder 10 | * Run `npm install && npm run build` 11 | * Create a new site in Beaker and import the folder containing the cloned project 12 | * Make sure to publish the revisions for the site in Beaker, using their workspace tools (accessible via Library) 13 | * Check out the [tutorial](https://github.com/cblgh/datradio/blob/master/tutorial.md) for more instructions and specifics 14 | 15 | 16 | note: this is extremely wip and lacks any kind of onboarding atm 17 | 18 | but hey see the [demo](https://www.youtube.com/watch?v=-0cgl6okmUs&feature=youtu.be&t=1670) from a talk i held, or venture into the [code](https://github.com/cblgh/datradio/blob/master/app.js) if you're brave 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datradio", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "app.js", 6 | "dependencies": { 7 | "choo": "^6.7.0", 8 | "choo-devtools": "^2.3.3", 9 | "nanocomponent": "^6.5.1" 10 | }, 11 | "devDependencies": { 12 | "browser-pack-flat": "^3.0.3", 13 | "browserify": "^14.4.0", 14 | "browserify-nodent": "^1.0.22", 15 | "common-shakeify": "^0.4.4", 16 | "css-extract": "^1.2.0", 17 | "sheetify": "^7.0.0", 18 | "sheetify-cssnext": "^1.0.7", 19 | "uglifyify": "^4.0.4", 20 | "watchify": "^3.9.0" 21 | }, 22 | "scripts": { 23 | "build": "date && browserify -t browserify-nodent -t [ sheetify -u sheetify-cssnext ] -p [ css-extract -o bundle.css ] -p common-shakeify -p browser-pack-flat/plugin app.js -g uglifyify > bundle.js" 24 | }, 25 | "keywords": [ 26 | "dat", 27 | "datradio", 28 | "beaker", 29 | "merveilles", 30 | "piratradio" 31 | ], 32 | "author": "cblgh", 33 | "license": "MIT" 34 | } 35 | -------------------------------------------------------------------------------- /tutorial.md: -------------------------------------------------------------------------------- 1 | # Get started 2 | * Install [Beaker Browser](https://beakerbrowser.com/install/). You will need Beaker at least pre-release of 0.8 3 | * Visit dat://31efd7c43603b57d18d0dcc4e2a32bf5cae08ab5930071e4da3513dbc4c60f5f/ and make an **editable copy** 4 | *(psst. click on the **three dots** in the URL bar of Beaker to create an editable copy)* 5 | * **Create dat archives and fill them with music.** (You create the archives with Beaker, outside of datradio). 6 | Paste the url of the archive into datradio's terminal, titled *i love tracks*. 7 | The tracks have now been added, and the archive is now monitored for future changes. 8 | * If you create an info.txt and place it beside your tracks, that will be used to provide extra info about 9 | the tracks in that dat archive. This info is viewable by clicking **INFO** beside any track in a playlist. 10 | * You can easily add and remove tracks using something like kodedninja's ntain: 11 | Create an editable copy of dat://ntain-kodedninja.hashbase.io/ in beaker and visit 12 | https://github.com/kodedninja/ntain for more info. 13 | Note: if you use ntain, you will have to add the dat archive as follows: dat://7331..1337**/files** (because that is where ntain puts its files). 14 | * Check out the commands in the right sidebar 15 | * To ensure the tracks in your playlist are available offline, you will currently have to manually seed 16 | them in Beaker. Future versions of Beaker will probably add an API that allows datradio to seed them 17 | automatically for you, but that is not the case right now. 18 | So visit the archives in the left sidebar under *archives in playlist* and make sure they are seeded if 19 | you want to listen offline. 20 | *(psst. click on the **weird triangle-with-dots** thing in the url bar to **make sure things are 21 | seeded**)* 22 | 23 | 24 | 25 | Let [cblgh](https://twitter.com/cblgh) know about any issues 26 | Report issues or add patches at https://github.com/cblgh/datradio 27 | 28 | <3 p2p 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /playlists/ 2 | ################# 3 | ## cblgh town 4 | ################# 5 | *.db 6 | ################# 7 | ## Pirate ignore 8 | ################# 9 | node_modules/ 10 | *.css 11 | *.vim 12 | *bundle*.js 13 | *.bat 14 | 15 | ################# 16 | ## Eclipse 17 | ################# 18 | 19 | *.pydevproject 20 | .project 21 | .metadata 22 | bin/ 23 | tmp/ 24 | *.tmp 25 | *.bak 26 | *.swp 27 | *~.nib 28 | local.properties 29 | .classpath 30 | .settings/ 31 | .loadpath 32 | 33 | # External tool builders 34 | .externalToolBuilders/ 35 | 36 | # Locally stored "Eclipse launch configurations" 37 | *.launch 38 | 39 | # CDT-specific 40 | .cproject 41 | 42 | # PDT-specific 43 | .buildpath 44 | 45 | 46 | ################# 47 | ## Visual Studio 48 | ################# 49 | 50 | ## Ignore Visual Studio temporary files, build results, and 51 | ## files generated by popular Visual Studio add-ons. 52 | 53 | # User-specific files 54 | *.suo 55 | *.user 56 | *.sln.docstates 57 | 58 | # Build results 59 | 60 | [Dd]ebug/ 61 | [Rr]elease/ 62 | x64/ 63 | build/ 64 | [Bb]in/ 65 | [Oo]bj/ 66 | 67 | # MSTest test Results 68 | [Tt]est[Rr]esult*/ 69 | [Bb]uild[Ll]og.* 70 | 71 | *_i.c 72 | *_p.c 73 | *.ilk 74 | *.meta 75 | *.obj 76 | *.pch 77 | *.pdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *.log 88 | *.vspscc 89 | *.vssscc 90 | .builds 91 | *.pidb 92 | *.log 93 | *.scc 94 | 95 | # Visual C++ cache files 96 | ipch/ 97 | *.aps 98 | *.ncb 99 | *.opensdf 100 | *.sdf 101 | *.cachefile 102 | 103 | # Visual Studio profiler 104 | *.psess 105 | *.vsp 106 | *.vspx 107 | 108 | # Guidance Automation Toolkit 109 | *.gpState 110 | 111 | # ReSharper is a .NET coding add-in 112 | _ReSharper*/ 113 | *.[Rr]e[Ss]harper 114 | 115 | # TeamCity is a build add-in 116 | _TeamCity* 117 | 118 | # DotCover is a Code Coverage Tool 119 | *.dotCover 120 | 121 | # NCrunch 122 | *.ncrunch* 123 | .*crunch*.local.xml 124 | 125 | # Installshield output folder 126 | [Ee]xpress/ 127 | 128 | # DocProject is a documentation generator add-in 129 | DocProject/buildhelp/ 130 | DocProject/Help/*.HxT 131 | DocProject/Help/*.HxC 132 | DocProject/Help/*.hhc 133 | DocProject/Help/*.hhk 134 | DocProject/Help/*.hhp 135 | DocProject/Help/Html2 136 | DocProject/Help/html 137 | 138 | # Click-Once directory 139 | publish/ 140 | 141 | # Publish Web Output 142 | *.Publish.xml 143 | *.pubxml 144 | 145 | # NuGet Packages Directory 146 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 147 | #packages/ 148 | 149 | # Windows Azure Build Output 150 | csx 151 | *.build.csdef 152 | 153 | # Windows Store app package directory 154 | AppPackages/ 155 | 156 | # Others 157 | sql/ 158 | *.Cache 159 | ClientBin/ 160 | [Ss]tyle[Cc]op.* 161 | ~$* 162 | *~ 163 | *.dbmdl 164 | *.[Pp]ublish.xml 165 | *.pfx 166 | *.publishsettings 167 | 168 | # RIA/Silverlight projects 169 | Generated_Code/ 170 | 171 | # Backup & report files from converting an old project file to a newer 172 | # Visual Studio version. Backup files are not needed, because we have git ;-) 173 | _UpgradeReport_Files/ 174 | Backup*/ 175 | UpgradeLog*.XML 176 | UpgradeLog*.htm 177 | 178 | # SQL Server files 179 | App_Data/*.mdf 180 | App_Data/*.ldf 181 | 182 | ############# 183 | ## Windows detritus 184 | ############# 185 | 186 | # Windows image file caches 187 | Thumbs.db 188 | ehthumbs.db 189 | 190 | # Folder config file 191 | Desktop.ini 192 | 193 | # Recycle Bin used on file shares 194 | $RECYCLE.BIN/ 195 | 196 | # Mac crap 197 | .DS_Store 198 | 199 | 200 | ############# 201 | ## Python 202 | ############# 203 | 204 | *.py[co] 205 | 206 | # Packages 207 | *.egg 208 | *.egg-info 209 | dist/ 210 | build/ 211 | eggs/ 212 | parts/ 213 | var/ 214 | sdist/ 215 | develop-eggs/ 216 | .installed.cfg 217 | 218 | # Installer logs 219 | pip-log.txt 220 | 221 | # Unit test / coverage reports 222 | .coverage 223 | .tox 224 | 225 | #Translations 226 | *.mo 227 | 228 | #Mr Developer 229 | .mr.developer.cfg 230 | -------------------------------------------------------------------------------- /links/style.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "SpaceMono"; 3 | src: url("/SpaceMono-Regular.ttf"); 4 | } 5 | 6 | body { 7 | background-color: black; 8 | color: #f2f2f2; 9 | font-family: "SpaceMono", Arial; 10 | margin-top: 30px; 11 | } 12 | 13 | @keyframes fade { 14 | 0% {opacity: 1;} 15 | 100% {opacity: 0.5;} 16 | } 17 | 18 | .drag-fade { 19 | animation-name: fade; 20 | animation-duration: 0.4s; 21 | animation-fill-mode: forwards; 22 | } 23 | 24 | li { 25 | list-style: none; 26 | } 27 | 28 | /* MODAL STUFF */ 29 | #info-modal { 30 | grid-area: modal; 31 | display: grid; 32 | background: inherit; 33 | width: 500px; 34 | /* height: 500px; */ 35 | 36 | grid-template-rows: 0px 0px 40px 60px 1fr; 37 | 38 | grid-template-areas: 39 | "modal-close" 40 | "modal-header" 41 | "modal-title" 42 | "modal-archive" 43 | "modal-text" 44 | } 45 | 46 | #info-close { 47 | grid-area: modal-close; 48 | justify-self: right; 49 | /* text-align: right; */ 50 | cursor: pointer; 51 | } 52 | 53 | #info-title { 54 | grid-area: modal-title; 55 | font-size: 1.1em; 56 | margin-bottom: 15px; 57 | } 58 | 59 | 60 | #info-archive { 61 | grid-area: modal-archive; 62 | display: inline-block; 63 | } 64 | 65 | #info-text { 66 | grid-area: modal-text; 67 | } 68 | 69 | #info-text pre { 70 | margin: 0; 71 | } 72 | 73 | 74 | /* TRACK STUFF */ 75 | .track-title { 76 | display: inline-block; 77 | } 78 | 79 | .track-link { 80 | display: inline-block; 81 | margin-right: 5px; 82 | cursor: pointer; 83 | } 84 | 85 | .track-link:hover { 86 | opacity: 0.5 !important; 87 | } 88 | 89 | #tracks li:hover { 90 | cursor: pointer; 91 | opacity: 0.6 !important; 92 | transition: opacity 0.4; 93 | } 94 | 95 | a:visited { 96 | color: inherit; 97 | text-decoration: none; 98 | } 99 | 100 | a, #fork-url { 101 | opacity: 1; 102 | color: inherit; 103 | text-decoration: none; 104 | transition: opacity 0.4; 105 | } 106 | 107 | a:hover, #fork-url:hover { 108 | cursor: pointer; 109 | opacity: 0.6 !important; 110 | transition: opacity 0.4; 111 | border-bottom-style: solid; 112 | } 113 | 114 | .help-container, .hotkey-container { 115 | padding-bottom: 15px; 116 | font-size: 9pt; 117 | } 118 | 119 | .help-container:hover { 120 | cursor: pointer; 121 | opacity: 0.8; 122 | } 123 | 124 | .hotkey-container { 125 | padding-bottom: 0; 126 | } 127 | 128 | .help-cmd:before { 129 | content: "."; 130 | } 131 | 132 | .help-cmd, .help-value, .help-hotkey { 133 | display: inline-block; 134 | padding-right: 10px; 135 | } 136 | 137 | .help-value { 138 | font-style: italic; 139 | } 140 | 141 | .help-desc { 142 | padding-left: 10px; 143 | } 144 | 145 | #tracks { 146 | grid-area: tracks; 147 | padding: 0; 148 | } 149 | 150 | #playlists { 151 | grid-area: playlists; 152 | } 153 | 154 | #terminal { 155 | grid-area: terminal; 156 | font-size: 12pt; 157 | color: #c4c4c4; 158 | background-color: #1d1d1d; 159 | border: none; 160 | padding: 7px; 161 | outline: 0; 162 | } 163 | 164 | #title { 165 | grid-area: title; 166 | margin-bottom: 5px; 167 | } 168 | 169 | #player { 170 | grid-area: player; 171 | display: none; 172 | } 173 | 174 | #toggle { 175 | grid-area: toggle; 176 | } 177 | 178 | #time { 179 | grid-area: time; 180 | justify-self: end; 181 | } 182 | 183 | #commands { 184 | grid-area: commands; 185 | justify-self: end; 186 | } 187 | 188 | .playing:before { 189 | content: "► "; 190 | } 191 | 192 | .playing { 193 | margin-left: -20px; 194 | } 195 | 196 | .paused:before { 197 | content: "■ "; 198 | } 199 | 200 | .paused { 201 | margin-left: -15px; 202 | } 203 | 204 | #description { 205 | grid-area: description; 206 | } 207 | 208 | #fork-url { 209 | display: block; 210 | position: absolute; 211 | top: 10px; 212 | left: 45px; 213 | } 214 | 215 | #tutorial-url { 216 | display: block; 217 | position: absolute; 218 | top: 10px; 219 | right: 45px; 220 | text-align: right; 221 | } 222 | 223 | .center { 224 | display: grid; 225 | grid-area: center; 226 | grid-template-columns: 1fr; 227 | grid-template-rows: auto auto 35px 50px 1fr 1fr; 228 | grid-template-areas: 229 | "title" 230 | "description" 231 | "time" 232 | "terminal" 233 | "tracks" 234 | "modal" 235 | ; 236 | } 237 | 238 | #archives-container { 239 | grid-area: archives; 240 | margin-top: 0; 241 | } 242 | 243 | #grid-container { 244 | display: grid; 245 | /* grid-template-columns: repeat(4, 1fr); */ 246 | grid-template-columns: 325px 650px 1fr; 247 | grid-template-rows: auto; 248 | grid-template-areas: 249 | "playlists center commands" 250 | "playlists center commands" 251 | "playlists center commands" 252 | "archives center commands" 253 | "player center commands" 254 | ; 255 | } 256 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var html = require("choo/html") 2 | var devtools = require("choo-devtools") 3 | var Nanocomponent = require("nanocomponent") 4 | var choo = require("choo") 5 | var css = require("sheetify") 6 | 7 | css("./links/style.css") 8 | 9 | var remoteRoute = "/remote/:url/:playlist" 10 | 11 | var archive = new DatArchive(window.location.toString()) 12 | var title = "datradio" 13 | 14 | var app = choo() 15 | app.use(devtools()) 16 | app.use(init) 17 | app.use(inputHandler) 18 | app.route(remoteRoute, mainView) 19 | app.route("/:playlist", mainView) 20 | app.mount("body") 21 | 22 | // fix modulo for negative integers 23 | function mod(n, m) { 24 | return ((n % m) + m) % m 25 | } 26 | 27 | function format(durationStr) { 28 | durationStr = parseInt(durationStr) 29 | var min = pad(parseInt(durationStr / 60), 2) 30 | var sec = pad(parseInt(durationStr % 60), 2) 31 | return `${min}:${sec}` 32 | } 33 | 34 | function shuffle(inArr) { 35 | var output = [].concat(inArr) 36 | // fisher-yates shuffle 37 | for (var i = output.length-1; i > 1; i--) { 38 | // 0 <= j <= i 39 | var j = Math.floor(Math.random() * (i+1)) 40 | // do the swap 41 | var temp = output[i] 42 | output[i] = output[j] 43 | output[j] = temp 44 | } 45 | return output 46 | } 47 | 48 | class Counter extends Nanocomponent { 49 | constructor() { 50 | super() 51 | this.time = "--:--" 52 | this.duration = "--:--" 53 | } 54 | 55 | createElement(time, duration) { 56 | this.time = time 57 | this.duration = duration 58 | return html`
${format(this.time)}/${format(this.duration)}
` 59 | } 60 | 61 | update(time, duration) { 62 | console.log("nanocomponent update - time:", time) 63 | time = format(time) 64 | duration = format(duration) 65 | return time != this.time || duration != this.duration 66 | } 67 | } 68 | 69 | var hotkeySheet = { 70 | "toggle play/pause": { 71 | key: "spacebar", 72 | }, 73 | "next track": { 74 | key: "n", 75 | }, 76 | "previous track": { 77 | key: "p", 78 | }, 79 | "random track": { 80 | key: "r", 81 | }, 82 | "close info": { 83 | key: "x", 84 | } 85 | } 86 | 87 | var commands = { 88 | "bg": { 89 | value: "#1d1d1d", 90 | desc: "change the background colour", 91 | call: function(state, emit, value) { 92 | state.profile.bg = value 93 | } 94 | }, 95 | "color": { 96 | value: "pink", 97 | desc: "change the font colour", 98 | call: function(state, emit, value) { 99 | state.profile.color = value 100 | } 101 | }, 102 | "nick": { 103 | value: "", 104 | desc: "sets the name of your profile", 105 | call: function(state, emit, value) { 106 | state.user.name = value 107 | } 108 | }, 109 | "desc": { 110 | value: "", 111 | desc: "a description of this playlist", 112 | call: function(state, emit, value) { 113 | state.description = value 114 | save(state) 115 | emit.emit("render") 116 | } 117 | }, 118 | "create": { 119 | value: "playlist-name-no-spaces", 120 | desc: "create a playlist", 121 | call: function(state, emit, value) { 122 | value = value.replace(/\W*/g, "") 123 | state.playlists.push(value) 124 | window.location.hash = value 125 | reset(state) 126 | savePlaylist(state, value) 127 | .then(() => { 128 | save(state) 129 | emit.emit("render") 130 | }) 131 | } 132 | }, 133 | "delete-playlist": { 134 | value: "playlist-name", 135 | desc: "delete the playlist", 136 | call: function(state, emit, value) { 137 | deletePlaylist(value).then(() => { 138 | loadPlaylists().then((playlists) => { 139 | state.playlists = playlists 140 | // handle deleting the current playlist 141 | if (value === state.params.playlist) { 142 | window.location.hash = "playlist" 143 | } 144 | emit.emit("render") 145 | }) 146 | }) 147 | } 148 | }, 149 | "rename": { 150 | value: "new-playlist-name-no-spaces", 151 | desc: "rename the current playlist", 152 | call: function(state, emit, value) { 153 | if (value) { 154 | value = value.replace(/\W*/g, "") 155 | var oldPlaylist = state.params.playlist ? state.params.playlist : "playlist" 156 | state.playlists.splice(state.playlists.indexOf(oldPlaylist), 1) 157 | 158 | savePlaylist(state, value).then(() => { 159 | deletePlaylist(oldPlaylist).then(() => { 160 | loadPlaylists().then((playlists) => { 161 | state.playlists = playlists 162 | window.location.hash = value.replace(" ", "") 163 | emit.emit("render") 164 | }) 165 | }) 166 | }) 167 | } 168 | } 169 | }, 170 | "unsub": { 171 | value: "", 172 | desc: "unsub from current playlist", 173 | call: function(state, emit, value) { 174 | parts = window.location.pathname.split("/remote/") 175 | if (parts.length <= 1) { 176 | return 177 | } 178 | value = prefix(parts[1]) 179 | state.following.forEach((f, index) => { 180 | if (f.link === value) { 181 | state.following.splice(index, 1) 182 | return 183 | } 184 | }) 185 | } 186 | }, 187 | "sub": { 188 | value: "dat://1337...7331/#playlist-name", 189 | desc: "subscribe to a playlist", 190 | call: function(state, emit, value) { 191 | extractSub(value).then((info) => { 192 | state.following.push(info) 193 | emit.emit("render") 194 | save(state) 195 | }) 196 | } 197 | }, 198 | "del": { 199 | value: "track index", 200 | desc: "delete track from playlist", 201 | call: function(state, emit, value) { 202 | emit.emit("deleteTrack", parseInt(value)) 203 | } 204 | }, 205 | "del-archive": { 206 | value: "dat://1337...7331", 207 | desc: "delete all tracks from the archive", 208 | call: function(state, emit, value) { 209 | emit.emit("deleteArchive", value) 210 | } 211 | }, 212 | "mv": { 213 | value: "trackIndex newIndex", 214 | desc: "move a track in the current playlist", 215 | call: function(state, emit, value) { 216 | var [src, dst] = value.split(/\W+/g) 217 | emit.emit("moveTrack", src, dst) 218 | } 219 | }, 220 | "shuffle": { 221 | value: "on|off", 222 | desc: "turn on/off shuffle for playlist. [COMING SOON]", 223 | call: function(state, emit, value) { 224 | console.log(shuffle(state.tracks)) 225 | } 226 | } 227 | } 228 | 229 | async function loadTracks(playlist) { 230 | var tracks = playlist.tracks 231 | var fromArchives = [] 232 | return new Promise((resolve, reject) => { 233 | // TODO: refactor/clean this? 234 | if (playlist) { 235 | var promises = playlist.archives.map((address) => { 236 | return new Promise((res1, rej1) => { 237 | var a = new DatArchive(address) 238 | var path = address.substring(70) || "/" 239 | var files = await a.readdir(path) 240 | var archiveTracks = files.filter((i) => isTrack(i)).map((i) => prefix(address, i)) 241 | var newTracks = archiveTracks.filter((i) => { 242 | return playlist.removed.indexOf(i) < 0 && tracks.indexOf(i) < 0 243 | }) 244 | tracks = tracks.concat(newTracks) 245 | fromArchives = fromArchives.concat(archiveTracks) 246 | res1() 247 | }) 248 | }) 249 | await Promise.all(promises) 250 | // TODO: is this good? would we rather preserve tracks that have been added 251 | // and load them using version numbers? 252 | // filter out tracks that have been added to our playlist previously 253 | // but have been removed from the hosting archive (i.e. outside of datradio) 254 | tracks = tracks.filter((i) => fromArchives.indexOf(i) >= 0) 255 | resolve(tracks) 256 | } 257 | }) 258 | } 259 | 260 | async function deletePlaylist(name) { 261 | // don't delete the default playlist 262 | if (name === "playlist") { 263 | var emptyState = {archives: [], tracks: [], removed: [], description: "", profile: {bg: "black", color: "#f2f2f2"}} 264 | return savePlaylist(emptyState, "playlist") 265 | } 266 | await archive.unlink(`playlists/${name}.json`) 267 | } 268 | 269 | function createHelpSidebar() { 270 | var items = [] 271 | var hotkeyItems = [] 272 | for (var key in commands) { 273 | items.push({key: key, cmd: commands[key]}) 274 | } 275 | 276 | for (var key in hotkeySheet) { 277 | hotkeyItems.push({key: key, hotkey: hotkeySheet[key].key}) 278 | } 279 | 280 | function createHelpEl(p) { 281 | return html`
${p.key}
${p.cmd.value}
${p.cmd.desc}
` 282 | 283 | function fillTerminal() { 284 | var term = document.getElementById("terminal") 285 | term.value = `.${p.key} ${p.cmd.value}` 286 | term.focus() 287 | } 288 | } 289 | 290 | function createHotkeyEl(p) { 291 | return html`
${p.key} =
${p.hotkey}
` 292 | } 293 | 294 | return html`

commands
${items.map(createHelpEl)}
295 |
hotkeys
${hotkeyItems.map(createHotkeyEl)}
` 296 | } 297 | 298 | var counter = new Counter() 299 | function mainView(state, emit) { 300 | emit("DOMTitleChange", title + `/${state.user.name}`) 301 | var playlistName = state.params.playlist ? state.params.playlist : "playlist" 302 | return html` 303 | 304 | create your own radio 305 | how to use 306 |
307 |
    308 |

    archives in playlist

    309 | ${state.archives.map(createArchiveEl)} 310 |
311 |
    312 |

    ${state.user.name}'s playlists

    313 | ${state.playlists.map(createPlaylistEl)} 314 | ${state.following.map(createPlaylistSub)} 315 |
316 |
317 |

${title} (${playlistName})

318 |
${state.description}
319 | 320 |
    321 | ${state.tracks.map(createTrack)} 322 |
323 | ${counter.render(state.time, state.duration)} 324 | ${createInfoModal()} 325 |
326 | ${createHelpSidebar()} 327 | 330 |
331 | 332 | ` 333 | // ' 334 | 335 | function dragover(e) { 336 | e.preventDefault() 337 | var body = document.querySelectorAll('body')[0]; 338 | body.classList = "drag-fade" 339 | } 340 | 341 | function dragleave(e) { 342 | e.preventDefault() 343 | var body = document.querySelectorAll('body')[0]; 344 | body.classList = "" 345 | } 346 | 347 | function drop(e) { 348 | e.preventDefault() 349 | 350 | var body = document.querySelectorAll('body')[0]; 351 | body.classList = "" 352 | 353 | console.log(e.dataTransfer.files) 354 | var files = Array.prototype.filter.call(e.dataTransfer.files, 355 | ((i) => isTrack(i.name) || i.name === "info.txt")) 356 | // only allow tracks or info.txt to be added 357 | if (files.length === 0) { return } 358 | // var d = await DatArchive.selectArchive({ 359 | // title: 'Hello, world!', 360 | // buttonLabel: 'My new site' 361 | // }) 362 | var playlistName = state.params.playlist ? state.params.playlist : "playlist" 363 | if (!state.profile.archive) { 364 | var d = await DatArchive.create({ 365 | title: `[datradio tracks] ${state.user.name}/${playlistName}`, 366 | description: `datradio tracks for ${state.user.name}/${playlistName}` 367 | }) 368 | state.profile.archive = d.url 369 | // add the playlist-specific archive to our list of archives 370 | state.archives.push(d.url) 371 | } else { 372 | var d = new DatArchive(state.profile.archive) 373 | } 374 | 375 | var reader = new FileReader() 376 | async function next(i=0) { 377 | reader.readAsArrayBuffer(files[i]) 378 | 379 | reader.onload = async function (e) { 380 | await d.writeFile(`/${files[i].name}`, e.target.result) 381 | if (i + 1 < files.length) { 382 | next(i+1) 383 | } 384 | } 385 | } 386 | next() 387 | 388 | var tracks = files.filter((i) => i.name !== "info.txt").map((i) => `${d.url}/${i.name}`) 389 | state.tracks = state.tracks.concat(tracks) 390 | save(state) 391 | emit("render") 392 | } 393 | 394 | function createArchiveEl(arch) { 395 | return html`
  • ${shorten(arch)}
  • ` 396 | } 397 | 398 | function createTrack(track, index) { 399 | var parts = track.split("/") 400 | var title = parts[parts.length - 1].trim() 401 | return html`
  • 402 | 403 |
    404 | ${pad(index, 3)} ${title} 405 |
    406 |
  • ` 407 | 408 | function showInfo() { 409 | state.modalInfo = {track: track, title: title, index: index, info: "loading.."} 410 | state.showModal = true 411 | emit("render") 412 | 413 | var path = track.substring(70, track.lastIndexOf("/")) 414 | var d = new DatArchive(track) 415 | 416 | d.readFile(path + "/info.txt") 417 | .then((info) => { 418 | state.modalInfo.info = info 419 | emit("render") 420 | }) 421 | .catch((e) => { 422 | state.modalInfo.info = "info.txt missing from archive" 423 | emit("render") 424 | }) 425 | } 426 | 427 | // play the track when clicked on 428 | function play() { 429 | // current track clicked on 430 | if (state.trackIndex === index) { 431 | var player = document.getElementById("player") 432 | // lets resume the current track 433 | if (player.paused) { 434 | emit("resumeTrack") 435 | // pause the current track 436 | } else { 437 | emit("pauseTrack") 438 | } 439 | // we wanted to play a new track 440 | } else { 441 | emit("playTrack", index) 442 | } 443 | } 444 | } 445 | 446 | function createInfoModal() { 447 | var index = state.modalInfo.track.lastIndexOf("/") 448 | var archiveUrl = state.modalInfo.track.substring(0,70) 449 | 450 | if (state.showModal) { 451 | return html` 452 |
    453 |
    ${state.modalInfo.title}
    454 |
    455 |
    from archive:
    456 | ${archiveUrl} 457 |
    458 |
    459 |
    info.txt:
    460 |
    ${state.modalInfo.info}
    461 |
    462 |
    close
    463 |
    ` 464 | } 465 | return html`` 466 | 467 | function close() { 468 | state.showModal = false 469 | emit("render") 470 | } 471 | } 472 | 473 | function trackEnded(evt) { 474 | emit("nextTrack") 475 | } 476 | 477 | function hotkeys(e) { 478 | var term = document.getElementById("terminal") 479 | var player = document.getElementById("player") 480 | if (document.activeElement != term) { 481 | if (e.key === "n") { emit("nextTrack") } 482 | else if (e.key === "p") { emit("previousTrack") } 483 | else if (e.key === "r") { emit("randTrack") } 484 | else if (e.key === "r") { emit("randTrack") } 485 | else if (e.key === "x") { 486 | state.showModal = false 487 | emit("render") 488 | } else if (e.key === " ") { 489 | e.preventDefault() 490 | if (player.paused) emit("resumeTrack") 491 | else emit("pauseTrack") 492 | } 493 | } 494 | } 495 | 496 | function keydown(e) { 497 | if (e.key === "Enter") { 498 | emit("inputEvt", e.target.value) 499 | e.target.value = "" 500 | } 501 | } 502 | } 503 | 504 | function shorten(url) { 505 | return url.substring(0,15) + ".." + url.substring(66,70) 506 | } 507 | 508 | function createPlaylistEl(playlist) { 509 | return html`
  • ${playlist}
  • ` 510 | } 511 | 512 | function createPlaylistSub(sub) { 513 | var playlist = `${sub.name}/${sub.playlist}` 514 | return html`
  • + ${playlist}
  • ` 515 | } 516 | 517 | function reset(state) { 518 | state.time = 0 519 | state.duration = 0 520 | state.trackIndex = -1 521 | state.tracks = [] 522 | state.removed = [] 523 | state.archives = [] 524 | state.description = "" 525 | state.profile = {bg: "black", color: "#f2f2f2", archive: ""} 526 | } 527 | 528 | function loadPlaylists() { 529 | return new Promise((res, rej) => { 530 | archive.readdir("playlists").then((playlists) => { 531 | playlists = playlists.filter((i) => { return i.substring(i.length - 5) === ".json" }).map((p) => p.substring(0,p.length-5)) 532 | res(playlists) 533 | }).catch((e) => { 534 | console.error("loadPlaylists failed with", e) 535 | }) 536 | }) 537 | } 538 | 539 | function prefix(url, path) { 540 | if (path) { 541 | // append / 542 | if (url.substring(-1) != "/") { 543 | url += "/" 544 | } 545 | url += path 546 | } 547 | if (url.substring(0, 6) != "dat://") { 548 | return `dat://${url}` 549 | } 550 | return url 551 | } 552 | 553 | async function init(state, emitter) { 554 | reset(state) 555 | // TODO: 556 | // implement 557 | // state.isPlaying 558 | // 559 | // figure out why choo-based state playing bugs out; 560 | // is it due to the player being reloaded somehow?? 561 | // should the player component be a Nanocomponent?? 562 | 563 | state.playlists = [] 564 | state.modalInfo = {track: "", title: "", index: -1, info: "loading.."} 565 | state.showModal = false 566 | state.following = [] 567 | state.user = {} 568 | state.isOwner = false 569 | setInterval(function() { 570 | var player = document.getElementById("player") 571 | if (player) { 572 | state.time = player.currentTime 573 | state.duration = player.duration || 0 574 | } 575 | counter.render(state.time, state.duration) 576 | }, 1000) 577 | 578 | archive.getInfo().then((info) => { 579 | state.isOwner = info.isOwner 580 | emitter.emit("render") 581 | }) 582 | 583 | state.user = JSON.parse(await archive.readFile("profile.json")) 584 | state.following = await Promise.all(state.user.following.map((url) => extractSub(url))) 585 | state.playlists = await loadPlaylists() 586 | var initialPlaylist = window.location.hash ? `playlists/${window.location.hash.substring(1)}.json` : `playlists/playlist.json` 587 | 588 | archive.stat(initialPlaylist).then((info) => { 589 | loadPlaylist(archive, initialPlaylist) 590 | }).catch((err) => { 591 | window.location = "/#playlist" 592 | }) 593 | 594 | async function loadPlaylist(playlistArchive, path) { 595 | // try to load the user's playlist 596 | try { 597 | var playlist = JSON.parse(await playlistArchive.readFile(path)) 598 | state.profile = playlist.profile 599 | state.description = playlist.description 600 | state.archives = playlist.archives 601 | state.removed = playlist.removed 602 | // render once before loading the tracks 603 | // as loading them takes a noticeable time 604 | // (might be premature optimization oops :^) 605 | emitter.emit("render") 606 | state.tracks = await loadTracks(playlist) 607 | 608 | // TODO: use .watch() instead 609 | state.archives.forEach((arch) => { 610 | var trackArchive = new DatArchive(arch) 611 | var tracePath = arch.substring(70).replace(/\/$/, "") // remove trailing slash 612 | var patterns = ["wav", "ogg", "mp3"].map((fmt) => `${tracePath}/*.${fmt}`) 613 | // /**/ 614 | var evts = trackArchive.createFileActivityStream(patterns) 615 | evts.addEventListener("changed", ({path}) => { 616 | console.log(`update found for ${arch}: ${path}`) 617 | var trackPath = prefix(normalizeArchive(arch) + path) 618 | trackArchive.stat(path) 619 | .then((info) => { 620 | // track was either updated or added to playlist 621 | // check if track exists in playlist already 622 | if (state.tracks.indexOf(trackPath) < 0) { 623 | console.log("track was added to playlist, update state.tracks") 624 | state.tracks.push(trackPath) 625 | save(state) 626 | emitter.emit("render") 627 | } 628 | }) 629 | .catch((e) => { 630 | console.error(e) 631 | console.log("track was probably removed from the playlist") 632 | console.log("remove from state.tracks") 633 | state.tracks.splice(state.tracks.indexOf(trackPath), 1) 634 | save(state) 635 | emitter.emit("render") 636 | }) 637 | }) 638 | }) 639 | 640 | save(state) 641 | // render again after having loaded the tracks 642 | emitter.emit("render") 643 | } catch (e) { 644 | console.error("failed to read playlist's json; malformed json?") 645 | console.error(e) 646 | } 647 | } 648 | 649 | // load the playlist we clicked on 650 | emitter.on("navigate", function() { 651 | reset(state) 652 | var arch = archive 653 | var playlistName = state.params.playlist ? state.params.playlist : "playlist" 654 | if (state.route === remoteRoute) { 655 | arch = new DatArchive(state.params.url) 656 | } 657 | loadPlaylist(arch, `playlists/${playlistName}.json`) 658 | }) 659 | 660 | emitter.on("playTrack", function(index) { 661 | console.log("playTrack received index: " + index, typeof index) 662 | state.trackIndex = index 663 | playTrack(state.tracks[index], index) 664 | }) 665 | 666 | emitter.on("randTrack", function() { 667 | var index = Math.floor(Math.random() * state.tracks.length) 668 | emitter.emit("playTrack", index) 669 | }) 670 | 671 | emitter.on("resumeTrack", function() { 672 | var player = document.getElementById("player") 673 | removeClass("paused") 674 | addClass(state.trackIndex, "playing") 675 | player.play() 676 | }) 677 | 678 | emitter.on("pauseTrack", function() { 679 | var player = document.getElementById("player") 680 | removeClass("playing") 681 | addClass(state.trackIndex, "paused") 682 | player.pause() 683 | }) 684 | 685 | emitter.on("nextTrack", function() { 686 | // TODO: add logic for shuffle :) 687 | console.log("b4, track index is: " + state.trackIndex) 688 | state.trackIndex = mod((state.trackIndex + 1), state.tracks.length) 689 | console.log("after, track index is: " + state.trackIndex) 690 | playTrack(state.tracks[state.trackIndex], state.trackIndex) 691 | }) 692 | 693 | emitter.on("previousTrack", function() { 694 | // TODO: add logic for shuffle :) 695 | state.trackIndex = mod((state.trackIndex - 1), state.tracks.length) 696 | playTrack(state.tracks[state.trackIndex], state.trackIndex) 697 | }) 698 | 699 | emitter.on("moveTrack", function(srcIndex, dstIndex) { 700 | console.log(`move from ${srcIndex} to ${dstIndex}`) 701 | var track = state.tracks.splice(srcIndex, 1)[0] 702 | state.tracks.splice(dstIndex, 0, track) 703 | emitter.emit("render") 704 | }) 705 | 706 | emitter.on("deleteTrack", function(index) { 707 | var emitNextTrack = false 708 | state.trackIndex = parseInt(state.trackIndex) 709 | index = parseInt(index) 710 | var removedTrack = state.tracks.splice(index, 1)[0] 711 | state.removed.push(removedTrack) 712 | save(state) 713 | if (state.trackIndex >= index) { 714 | var emitNextTrack = (state.trackIndex === index && state.tracks.length > 0) 715 | state.trackIndex = state.trackIndex - 1 716 | // if current was deleted, play next 717 | if (emitNextTrack) { emitter.emit("nextTrack") } 718 | } 719 | }) 720 | 721 | emitter.on("deleteArchive", function(url) { 722 | url = prefix(normalizeArchive(url)) 723 | // var emitNextTrack = false 724 | // state.trackIndex = parseInt(state.trackIndex) 725 | // if (differentFromUrl(state.tracks[state.trackIndex]) { 726 | // if (state.trackIndex >= index) { 727 | // var emitNextTrack = (state.trackIndex === index && state.tracks.length > 0) 728 | // state.trackIndex = state.trackIndex - 1 729 | // // if current was deleted, play next 730 | // if (emitNextTrack) { emitter.emit("nextTrack") } 731 | // } 732 | // } 733 | state.archives = state.archives.filter(differentFromUrl) 734 | state.tracks = state.tracks.filter(differentFromUrl) 735 | save(state) 736 | 737 | function differentFromUrl(a) { 738 | return a.substr(0, url.length) !== url 739 | } 740 | }) 741 | 742 | function playTrack(track, index) { 743 | removeClass("playing") 744 | removeClass("paused") 745 | addClass(index, "playing") 746 | 747 | console.log(`playing ${track}`) 748 | var player = document.getElementById("player") 749 | player.src = track 750 | player.load() 751 | player.play() 752 | var duration = player.duration || 0 753 | counter.render(player.currentTime, duration) 754 | } 755 | } 756 | 757 | function addClass(index, cssClass) { 758 | console.log(`to track-${index} add ${cssClass}`) 759 | document.getElementById(`track-${index}`).classList.add(cssClass) 760 | } 761 | 762 | function removeClass(cssClass) { 763 | var items = document.getElementsByClassName(cssClass) 764 | for (var i = 0; i < items.length; i++) { 765 | var item = items[i] 766 | if (item) { 767 | item.classList.remove(cssClass) 768 | } 769 | } 770 | } 771 | 772 | async function save(state) { 773 | var playlistName = state.params.playlist ? state.params.playlist : "playlist" 774 | console.log(`saving ${state.tracks[state.tracks.length - 1]} to ${playlistName}.json`) 775 | savePlaylist(state, playlistName) 776 | archive.writeFile(`profile.json`, JSON.stringify( 777 | {name: state.user.name, following: state.following.map((o) => o.link)}, 778 | null, 2)) 779 | } 780 | 781 | async function extractSub(url) { 782 | return { 783 | source: url.substring(6, 64), 784 | playlist: extractPlaylist(url), 785 | name: await getProfileName(url), 786 | link: url 787 | } 788 | } 789 | 790 | async function getProfileName(datUrl) { 791 | var remote = new DatArchive(datUrl) 792 | var profile = JSON.parse(await remote.readFile("profile.json")) 793 | return profile.name 794 | } 795 | 796 | function extractPlaylist(input) { 797 | var playlistName = input.substring(71) 798 | if (playlistName.length === 0) { 799 | return "playlist" 800 | } 801 | return playlistName 802 | } 803 | 804 | var audioRegexp = new RegExp("\.[wav|ogg|mp3]$") 805 | function isTrack(msg) { 806 | return audioRegexp.test(msg) 807 | } 808 | 809 | function pad(num, size) { 810 | var s = num+""; 811 | while (s.length < size) s = "0" + s; 812 | return s; 813 | } 814 | 815 | // thx to 0xade & rotonde for this wonderful function <3 816 | function normalizeArchive(url) { 817 | if (!url) 818 | return null; 819 | 820 | // This is microoptimized heavily because it's called often. 821 | // "Make slow things fast" applies here, but not literally: 822 | // "Make medium-fast things being called very often even faster." 823 | 824 | if ( 825 | url.length > 6 && 826 | url[0] == 'd' && url[1] == 'a' && url[2] == 't' && url[3] == ':' 827 | ) 828 | // We check if length > 6 but remove 4. 829 | // The other 2 will be removed below. 830 | url = url.substring(4); 831 | 832 | if ( 833 | url.length > 2 && 834 | url[0] == '/' && url[1] == '/' 835 | ) 836 | url = url.substring(2); 837 | 838 | var index = url.indexOf("/"); 839 | url = index == -1 ? url : url.substring(0, index); 840 | 841 | url = url.toLowerCase().trim(); 842 | return url; 843 | } 844 | 845 | async function savePlaylist(state, name) { 846 | return archive.writeFile(`playlists/${name}.json`, JSON.stringify({ 847 | archives: state.archives, 848 | tracks: state.tracks, 849 | removed: state.removed, 850 | description: state.description, 851 | profile: state.profile}, null, 2)) 852 | } 853 | 854 | function inputHandler(state, emitter) { 855 | emitter.on("inputEvt", function (msg) { 856 | if (msg.length) { 857 | if (msg[0] === ".") { 858 | var sep = msg.indexOf(" ") 859 | var cmd = sep >= 0 ? msg.substr(1, sep-1).trim() : msg.substr(1) 860 | var val = sep >= 0 ? msg.substr(sep).trim() : "" 861 | handleCommand(cmd, val) 862 | } else { 863 | // assume it's a dat archive folder, and try to read its contents 864 | var url = normalizeArchive(msg) 865 | if (!url || url.length != 64) { 866 | return 867 | } 868 | var a = new DatArchive(msg) 869 | // length of dat:// + hash = 70 870 | var path = msg.substring(70) || "/" 871 | // disallow adding the same archive folder multiple times 872 | if (state.archives.indexOf(msg) >= 0) { 873 | return 874 | } 875 | state.archives.push(msg) 876 | a.readdir(path).then((dir) => { 877 | dir.filter((i) => isTrack(i)).forEach((i) => { 878 | var p = prefix(url, i) 879 | state.tracks.push(p) 880 | }) 881 | emitter.emit("render") 882 | save(state) 883 | }) 884 | } 885 | save(state) 886 | emitter.emit("render") 887 | } 888 | }) 889 | 890 | function handleCommand(command, value) { 891 | if (command in commands) { 892 | commands[command].call(state, emitter, value) 893 | emitter.emit("render") 894 | save(state) 895 | } 896 | } 897 | } 898 | --------------------------------------------------------------------------------