├── index.json ├── .gitignore ├── .babelrc ├── .datignore ├── index.js ├── index.html ├── lib ├── sass-to-css └── load-cassette.js ├── README.md ├── src └── get-sass.js ├── components ├── cover-art.js ├── history.js └── player.js ├── package.json └── app.js /index.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "boom-box" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | style.css 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.datignore: -------------------------------------------------------------------------------- 1 | .git 2 | .dat 3 | node_modules 4 | build 5 | *.log 6 | **/.DS_Store 7 | Thumbs.db 8 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import Vue from './lib/vue.js' 2 | import App from './app.js' 3 | 4 | window.addEventListener('DOMContentLoaded', () => { 5 | new Vue(App) // eslint-disable-line 6 | }) 7 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | boom box 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /lib/sass-to-css: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const stdin = process.openStdin() 4 | const { renderSync } = require('node-sass') 5 | 6 | var sass = '' 7 | 8 | stdin.on('data', (chunk) => { 9 | sass += chunk 10 | }) 11 | 12 | stdin.on('end', () => { 13 | const results = renderSync({ data: sass }) 14 | 15 | console.log(results.css.toString()) 16 | }) 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # boom-box 2 | 3 | ``` 4 | npm install 5 | npm run build // builds css from components 6 | ``` 7 | 8 | start a BeakerBrowser project pointing at this folder 9 | 10 | Check it out in beaker, add the URI of a DAT folder with music in it that you're already seeding. ENJOY 11 | 12 | **NOTE** you need to re-run `npm run build` any time you change the css. 13 | You can run `npm run dev` which auto builds the css every 3 seconds. Crude but effective 14 | 15 | -------------------------------------------------------------------------------- /src/get-sass.js: -------------------------------------------------------------------------------- 1 | import App from '../app.js' 2 | 3 | // this module has to be transpiled to be run by node (because of the es6 imports) 4 | 5 | const sass = getStyles(App) 6 | 7 | console.log(sass) 8 | 9 | function getStyles (node, results = '') { 10 | results += node.styles || '' 11 | 12 | if (node.components) { 13 | results += Object.values(node.components) 14 | .map(child => { 15 | return getStyles(child, results) 16 | }) 17 | .join('\n') 18 | } 19 | 20 | return results 21 | } 22 | -------------------------------------------------------------------------------- /components/cover-art.js: -------------------------------------------------------------------------------- 1 | const template = ` 2 |
3 | 4 |
5 | ` 6 | 7 | const styles = ` 8 | .CoverArt { 9 | img { 10 | max-width: 100% 11 | } 12 | } 13 | ` 14 | 15 | // TODO if multiple art, then could give option to scroll through them 16 | 17 | export default { 18 | props: { 19 | art: Array 20 | }, 21 | computed: { 22 | src () { 23 | if (!this.art.length) return '' 24 | if (!this.art[0].uri) return '' 25 | 26 | return this.art[0].uri 27 | } 28 | }, 29 | template, 30 | styles 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boom-box", 3 | "version": "0.0.1", 4 | "description": "play dats which have mp3s in them", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "run-p build:css", 8 | "build:css": "mkdir -p build && run-s build:get-sass build:run-sass", 9 | "build:get-sass": "browserify src/get-sass.js > build/get-sass.js ", 10 | "build:run-sass": "node build/get-sass.js | lib/sass-to-css > style.css", 11 | "dev": "while true; do npm run build:css && sleep 3; done" 12 | }, 13 | "browserify": { 14 | "transform": [ 15 | "babelify" 16 | ] 17 | }, 18 | "devDependencies": { 19 | "@babel/core": "^7.4.5", 20 | "@babel/preset-env": "^7.4.5", 21 | "babelify": "^10.0.0", 22 | "browserify": "^16.2.3", 23 | "node-sass": "^4.12.0", 24 | "npm-run-all": "^4.1.5" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/mixmix/boom-box.git" 29 | }, 30 | "keywords": [ 31 | "DAT", 32 | "music", 33 | "player" 34 | ], 35 | "author": "mixmix", 36 | "license": "AGPL-3.0", 37 | "bugs": { 38 | "url": "https://github.com/mixmix/boom-box/issues" 39 | }, 40 | "homepage": "https://github.com/mixmix/boom-box#readme" 41 | } 42 | -------------------------------------------------------------------------------- /components/history.js: -------------------------------------------------------------------------------- 1 | const template = ` 2 |
3 |
4 |
5 | {{tape.name}} 6 |
7 |
8 | ` 9 | 10 | const styles = ` 11 | .History { 12 | font-family: arial; 13 | 14 | > .title { 15 | font-size: .8rem; 16 | letter-spacing: 1px; 17 | border-bottom: 1px solid black; 18 | } 19 | 20 | > .tape { 21 | padding: 4px; 22 | cursor: pointer; 23 | 24 | &.selected { 25 | background: black; 26 | color: white; 27 | } 28 | } 29 | } 30 | ` 31 | 32 | export default { 33 | props: { 34 | uri: String, 35 | name: String 36 | }, 37 | data: () => ({ 38 | history: [] 39 | }), 40 | methods: { 41 | tapeClass (testTape) { 42 | return testTape.uri === this.uri ? 'selected' : '' 43 | } 44 | }, 45 | created () { 46 | this.history = JSON.parse(localStorage.boomBox || '[]') // eslint-disable-line 47 | .sort((a, b) => (b.name > a.name) ? -1 : +1) 48 | }, 49 | watch: { 50 | uri (next) { 51 | if (!next.length) return 52 | 53 | const newHistory = [ ...this.history ] 54 | .filter(tape => tape.uri !== next) 55 | 56 | newHistory.unshift({ uri: next, name: this.name }) 57 | newHistory.sort((a, b) => (b.name > a.name) ? -1 : +1) 58 | 59 | this.history = newHistory 60 | }, 61 | history (next) { 62 | localStorage.boomBox = JSON.stringify(this.history) // eslint-disable-line 63 | } 64 | }, 65 | template, 66 | styles 67 | } 68 | -------------------------------------------------------------------------------- /lib/load-cassette.js: -------------------------------------------------------------------------------- 1 | const musicExtensionRegexp = /\.(mp3|m4a|aac|ogg|wav)$/ 2 | const numberPrefixRegexp = /^\d+\s*/ 3 | const artExtensionRegexp = /\.(jpg|jpeg|png|gif)$/ 4 | 5 | export default function loadCassette (link, cb) { 6 | if (!link.endsWith('/')) link = link + '/' 7 | 8 | const archive = beaker.hyperdrive.drive(link) // eslint-disable-line 9 | 10 | archive.readdir('/') 11 | .then(dir => { 12 | const music = dir 13 | .filter(isMusic) 14 | .sort() 15 | .map(filename => { 16 | return { 17 | uri: join(link, filename), 18 | name: trackName(filename) 19 | } 20 | }) 21 | 22 | const art = dir 23 | .filter(isArt) 24 | .sort() 25 | .map(filename => { 26 | return { 27 | uri: join(link, filename) 28 | } 29 | }) 30 | 31 | archive.readFile('index.json') 32 | .then(str => { 33 | const { title } = JSON.parse(str) 34 | cb(null, { name: title, music, art }) 35 | }) 36 | .catch(err => { 37 | console.error(err) 38 | cb(null, { name: '???', music, art }) 39 | }) 40 | }) 41 | .catch(err => cb(err)) 42 | } 43 | 44 | function isMusic (filename) { 45 | return filename.match(musicExtensionRegexp) 46 | } 47 | 48 | function isArt (filename) { 49 | return filename.match(artExtensionRegexp) 50 | } 51 | 52 | function join (link, track) { 53 | return link + track 54 | } 55 | 56 | function trackName (filename) { 57 | return filename 58 | .replace(numberPrefixRegexp, '') 59 | .replace(musicExtensionRegexp, '') 60 | } 61 | -------------------------------------------------------------------------------- /components/player.js: -------------------------------------------------------------------------------- 1 | const template = ` 2 |
3 |
12 | ` 13 | 14 | const styles = ` 15 | .Player { 16 | > audio { 17 | width: 100%; 18 | filter: brightness(1.1); 19 | margin-bottom: 1rem; 20 | } 21 | 22 | > .tracks { 23 | > .track { 24 | cursor: pointer; 25 | 26 | display: grid; 27 | grid-template-columns: auto auto 1fr; 28 | grid-gap: 10px; 29 | 30 | &.selected { 31 | background: black; 32 | color: white; 33 | } 34 | 35 | > .num { 36 | font-size: .8rem; 37 | letter-spacing: 1px; 38 | padding: 3px 0 3px 13px; 39 | } 40 | > .divider { border-left: 1px solid black; } 41 | > .name { 42 | padding: 3px 0; 43 | } 44 | } 45 | } 46 | } 47 | ` 48 | 49 | export default { 50 | props: { 51 | music: Array 52 | }, 53 | data: () => ({ 54 | currentTrack: null 55 | }), 56 | methods: { 57 | selectTrack (i) { 58 | this.currentTrack = i 59 | }, 60 | nextTrack () { 61 | if (this.currentTrack === this.music.length - 1) this.currentTrack = null 62 | else this.currentTrack = this.currentTrack + 1 63 | }, 64 | trackClass (i) { 65 | return i === this.currentTrack ? 'selected' : '' 66 | } 67 | }, 68 | watch: { 69 | currentTrack (next, prev) { 70 | if (next === null) { 71 | this.$refs.player.pause() 72 | return 73 | } 74 | 75 | this.$refs.player.src = this.music[next].uri 76 | this.$refs.player.load() 77 | }, 78 | music (next) { 79 | this.currentTrack = 0 80 | this.$refs.player.src = this.music[0].uri 81 | this.$refs.player.load() 82 | } 83 | }, 84 | created () { 85 | this.currentTrack = 0 86 | }, 87 | template, 88 | styles 89 | } 90 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import loadCassette from './lib/load-cassette.js' 2 | import Player from './components/player.js' 3 | import CoverArt from './components/cover-art.js' 4 | import History from './components/history.js' 5 | 6 | const template = ` 7 |
8 |

{{name}}

9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 |
18 |
19 |
20 | ` 21 | 22 | // example tape: 23 | // hyper://ef69934eb101628180d7dfa72ef04df038b534c943be510b4e4bb8f2d7b5e6b5 24 | 25 | const App = { // eslint-disable-line 26 | el: '#app', 27 | data: { 28 | uri: '', 29 | name: 'boom-box', 30 | music: [], 31 | art: [] 32 | }, 33 | computed: { 34 | isPlayable () { 35 | return this.music.length > 0 36 | } 37 | }, 38 | methods: { 39 | processInput (ev) { 40 | var link = ev.target.value.trim() 41 | if (!isDriveURI(link)) return 42 | 43 | if (link === this.uri) return 44 | this.playCassette(link) 45 | }, 46 | playCassette (link) { 47 | loadCassette(link, (err, data) => { 48 | if (err) { 49 | console.error(err) 50 | // TODO ... display to user 51 | return 52 | } 53 | 54 | this.uri = link 55 | this.$refs.input.value = link 56 | this.name = data.name 57 | this.music = data.music 58 | this.art = data.art 59 | }) 60 | } 61 | }, 62 | template, 63 | components: { 64 | Player, 65 | CoverArt, 66 | History 67 | } 68 | } 69 | 70 | App.styles = ` 71 | .App { 72 | font-family: arial; 73 | padding: 2rem; 74 | 75 | >h1 { 76 | margin: 0; 77 | } 78 | 79 | >div { 80 | display: grid; 81 | grid-template-columns: 40rem 30rem; 82 | grid-gap: 4rem; 83 | justify-content: start; 84 | 85 | > .left { 86 | 87 | > .Player { 88 | margin-top: 1rem; 89 | } 90 | } 91 | 92 | > .right { 93 | max-width: 30rem; 94 | 95 | > input { 96 | width: 30rem; 97 | padding: 5px; 98 | margin-bottom: 3rem; 99 | } 100 | } 101 | } 102 | } 103 | ` 104 | 105 | export default App 106 | 107 | function isDriveURI (link) { 108 | return link.startsWith('hyper://') 109 | } 110 | --------------------------------------------------------------------------------