├── .gitignore ├── README.md ├── angular.json ├── browserslist ├── deploy.sh ├── e2e ├── protractor.conf.js ├── src │ ├── app.e2e-spec.ts │ └── app.po.ts └── tsconfig.json ├── ionic.config.json ├── karma.conf.js ├── package-deploy.json ├── package-lock.json ├── package.json ├── server.js ├── server └── config │ └── config-example.json ├── src ├── app │ ├── activity-indicator.service.spec.ts │ ├── activity-indicator.service.ts │ ├── add │ │ ├── add-routing.module.ts │ │ ├── add.module.ts │ │ ├── add.page.html │ │ ├── add.page.scss │ │ ├── add.page.spec.ts │ │ └── add.page.ts │ ├── app-routing.module.ts │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.module.ts │ ├── artist.ts │ ├── artwork.service.spec.ts │ ├── artwork.service.ts │ ├── edit │ │ ├── edit-routing.module.ts │ │ ├── edit.module.ts │ │ ├── edit.page.html │ │ ├── edit.page.scss │ │ ├── edit.page.spec.ts │ │ └── edit.page.ts │ ├── home │ │ ├── home-routing.module.ts │ │ ├── home.module.ts │ │ ├── home.page.html │ │ ├── home.page.scss │ │ ├── home.page.spec.ts │ │ └── home.page.ts │ ├── media.service.spec.ts │ ├── media.service.ts │ ├── media.ts │ ├── medialist │ │ ├── medialist-routing.module.ts │ │ ├── medialist.module.ts │ │ ├── medialist.page.html │ │ ├── medialist.page.scss │ │ ├── medialist.page.spec.ts │ │ └── medialist.page.ts │ ├── player.service.spec.ts │ ├── player.service.ts │ ├── player │ │ ├── player-routing.module.ts │ │ ├── player.module.ts │ │ ├── player.page.html │ │ ├── player.page.scss │ │ ├── player.page.spec.ts │ │ └── player.page.ts │ ├── sonos-api.ts │ ├── spotify-web-api.js │ ├── spotify.service.spec.ts │ ├── spotify.service.ts │ └── spotify.ts ├── assets │ ├── icon │ │ └── favicon.png │ ├── images │ │ └── nocover.png │ └── shapes.svg ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── global.scss ├── index.html ├── main.ts ├── polyfills.ts ├── test.ts ├── theme │ └── variables.scss └── zone-flags.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Specifies intentionally untracked files to ignore when using Git 2 | # http://git-scm.com/docs/gitignore 3 | 4 | *~ 5 | *.sw[mnpcod] 6 | .tmp 7 | *.tmp 8 | *.tmp.* 9 | *.sublime-project 10 | *.sublime-workspace 11 | .DS_Store 12 | Thumbs.db 13 | UserInterfaceState.xcuserstate 14 | $RECYCLE.BIN/ 15 | 16 | *.log 17 | log.txt 18 | npm-debug.log* 19 | 20 | /.idea 21 | /.ionic 22 | /.angular 23 | /.sass-cache 24 | /.sourcemaps 25 | /.versions 26 | /.vscode 27 | /coverage 28 | /dist 29 | /node_modules 30 | /platforms 31 | /plugins 32 | /www 33 | /deploy 34 | 35 | server/config/data.json 36 | config.json 37 | 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sonos-Kids-Controller 2 | 3 | ## Content 4 | [About Sonos-Kids-Controller](#about-sonos-kids-controller)\ 5 | [Dependencies](#dependencies)\ 6 | [Usage](#usage)\ 7 | [Configuration](#configuration)\ 8 | [Adding Content](#adding-content)\ 9 | [Autostart](#autostart)\ 10 | [Update](#update)\ 11 | [Hardware Player](#hardware-player)\ 12 | [Raspberry Pi optimization](#raspberry-pi-optimization)\ 13 | [Alternative Installation using Docker](#docker) 14 | 15 |
16 | 17 |
18 | 19 |
20 | 21 |
22 | 23 |
24 | 25 | ## About Sonos-Kids-Controller 26 | This software can be used to create a touch-based Sonos controller for your kids. 27 | 28 | It can also be used with Spotify Connect compatible devices instead of Sonos if you use [this software](https://github.com/amueller-tech/spotifycontroller) instead of the node-sonos-http-api discribed later in this document. 29 | 30 | *Sonos-Kids-Controller uses __TTS__ (text to speech) when you click on texts in the UI, so your kids don't have to aks you anymore about the name of a specific episode.* 31 | 32 | The recommended use case is in combination with Spotify Premium, as it's web API allows you to add albums using artist and album name instead of cryptic album-IDs. It's also possible to add multiple albums with a single search query (e.g. all albums from a sepcific artist). 33 | 34 | But you can also add albums from the local Sonos library (in case an album isn't available in your favorite streaming service), from Apple Music or from Amazon Music Unlimited by specifiying the corresponding album IDs. See the music services section about how to retrieve these IDs. 35 | 36 | The software consists of 2 parts: 37 | * The server component, running in an node express environment. Handles the album library and serves the client component to the browser 38 | * The client component, developed in Ionic/Angular, which can be opened in a browser 39 | 40 | ## Dependencies 41 | This software uses [node-sonos-http-api](https://github.com/Thyraz/node-sonos-http-api) to control your Sonos hardware. __So you need to have it running somewhere, for example on the same system as this software__.\ 42 | This doesn't have to be the Pi itself, but should be possible too (if it can handle everything performance-wise without any lags). 43 | 44 | ## Usage 45 | Ensure that you have Node.js and npm installed. 46 | Also install [node-sonos-http-api](https://github.com/Thyraz/node-sonos-http-api) as described in the readme of the software. 47 | If you plan on using Spotify, follow the instructions [here.](https://github.com/Thyraz/node-sonos-http-api#note-for-spotify-users) 48 | 49 | Then install this software from Github: 50 | ``` 51 | sudo npm install -g @ionic/cli 52 | 53 | wget https://github.com/Thyraz/Sonos-Kids-Controller/archive/master.zip 54 | 55 | unzip master.zip 56 | 57 | rm master.zip 58 | 59 | cd Sonos-Kids-Controller-master 60 | 61 | npm install 62 | 63 | ionic build --prod 64 | 65 | ``` 66 | Create the configuration file by making a copy of the included example: 67 | ``` 68 | cd server/config 69 | 70 | cp config-example.json config.json 71 | ``` 72 | Edit the config file as discribed in the chapter [configuration](#configuration) 73 | 74 | Then start the software like this: 75 | ``` 76 | npm start 77 | ``` 78 | 79 | After that open a browser window and navigate to 80 | ``` 81 | http://ip.of.the.server:8200 82 | ``` 83 | Now the user interface should appear 84 | 85 | ## Configuration 86 | ``` 87 | { 88 | "node-sonos-http-api": { 89 | "server": "127.0.0.1", 90 | "port": "5005", 91 | "rooms": [ 92 | "Livingroom", 93 | "Kitchen" 94 | ] 95 | }, 96 | "spotify": { 97 | "clientId": "your_id", 98 | "clientSecret": "your_secret" 99 | } 100 | } 101 | ``` 102 | Point the node-sonos-http-api section to the adress and the port where the service is running. 103 | The rooms are the Sonos room names that you want to be allowed as target. 104 | 105 | Room selection isn't implemented yet, so only the first room will be used at the moment. 106 | 107 | The spotify section is only needed when you want to use Spotify Premium as source. 108 | The id and the secret are the same values as entered in the node-sonos-http-api configuration as described [here.](https://github.com/Thyraz/node-sonos-http-api#note-for-spotify-users) 109 | 110 | ## Adding Content 111 | There's a hidden button in the root view on the right side of the top navigation bar. 112 | If you click there, you should see an overlay lighting up. 113 | Click this button quickly 10 times to open the library editor. 114 | 115 | Then click the "+" button on the top right to add a new entry. 116 | 117 | ### Local Sonos Library: 118 | * Enter artist name and album name exactly as it's displayed in the Sonos app. 119 | * Enter an artwork link for an artwork image (remember, you can open the UI on any browser, so you can use a desktop pc or an mobile phone to add items to the library and use copy and paste for artwork links.) 120 | A good source for album artworks is the iTunes Artwork Finder: https://bendodson.com/projects/itunes-artwork-finder/ 121 | 122 | ### Spotify Premium: 123 | * Enter artist and album name to add a single album 124 | * Add a query instead, to search for multiple albums 125 | * Album artwork will be automatically retreived from Spotify 126 | 127 | Examples for query strings: 128 | ``` 129 | artist:Max Kruse album:Urmel 130 | 131 | artist:Grüffelo 132 | 133 | artist:Benjamin Blümchen album:folge NOT gute-nacht 134 | 135 | artist:"Super Wings" 136 | ``` 137 | 138 | More details on Spotify web API search querys: 139 | https://developer.spotify.com/documentation/web-api/reference/search/search/#writing-a-query---guidelines 140 | 141 | ### Apple Music or Amazon Music Unlimited: 142 | * Enter artist name and album name as they should be displayed in the UI. 143 | * Enter the album ID which can be discovered as described here: https://github.com/jishi/node-sonos-http-api#spotify-apple-music-and-amazon-music-experimental 144 | * Enter an artwork link for an artwork image (remember, you can open the UI on any browser, so you can use a desktop pc or an mobile phone to add items to the library and use copy and paste for artwork links.) 145 | A good source for album artworks is the iTunes Artwork Finder: https://bendodson.com/projects/itunes-artwork-finder/ 146 | 147 | Pro Tip: 148 | As Amazon and Apple don't provide a full public API to search for content like Spotify does, adding AlbumIDs and artwork links through the UI might be time consuming and complicated. 149 | you can also edit the library by editing _server/config/data.json_ (created after you added the first content through the UI). 150 | The structure should be self-explaining. 151 | Just be sure to shutdown Sonos-Kids-Controller before editing the file, as the software might otherwise overwrite your changes with an in-memory copy of the data. 152 | Also a backup of the file might be a good idea, as the software might overwrite the file with an empty library on startup, when you have some syntax errors in your edits, preventing the data from loading. 153 | 154 | 155 | ## Autostart 156 | I use pm2 as process manager for node.js projects. 157 | So both services (node-sonos-http-api and Sonos-Kids-Controller) are startet on boot time in this case 158 | 159 | ``` 160 | sudo npm install pm2 -g 161 | ``` 162 | Then build a startup script for pm2 (don't run with sudo): 163 | ``` 164 | pm2 startup 165 | ``` 166 | after that pm2 should show you a command that has to be run as sudo to finish install the startup scripts. 167 | Copy and paste it into the terminal and execute it. 168 | 169 | then in the directory of node-sonos-http-api: 170 | ``` 171 | pm2 start server.js 172 | pm2 save 173 | ``` 174 | 175 | again in the directory of Sonos-Kids-Controller: 176 | ``` 177 | pm2 start server.js 178 | pm2 save 179 | ``` 180 | 181 | After a reboot, enter `pm2 list` in the terminal and you should see that the 2 services are running. 182 | 183 | ## Update 184 | Updating to a newer Sonos-Kids-Controller version works similar to the initial installation. 185 | Execute these commands, starting from the parent directory of you current _Sonos-Kids-Controller-master_ installation: 186 | ``` 187 | wget https://github.com/Thyraz/Sonos-Kids-Controller/archive/master.zip 188 | 189 | unzip master.zip 190 | 191 | rm master.zip 192 | 193 | cd Sonos-Kids-Controller-master 194 | 195 | npm install 196 | 197 | ionic build --prod 198 | 199 | ``` 200 | If you run into out of memory erros during the build process, try to set a memory limit manually: 201 | 202 | ``` 203 | export NODE_OPTIONS=--max-old-space-size=3072 204 | 205 | ionic build --prod 206 | ``` 207 | 208 | ## Hardware Player 209 | While you can simply run this software on any server supported by node.js and open it in the browser of your choice (as long as it isn't IE or Edge), the typical use case will be a small box powered by an Raspberry Pi and a capacitive touch screen. 210 | 211 | I recommend a 5" touch screen with a resolution of 800x480, as you otherwise might have to edit the layout of the software. 212 | 213 | ### Part List: 214 | 215 | Here's a list of what I bought for my player: 216 | * [Raspberry Pi 3b](https://www.amazon.de/gp/product/B01CD5VC92/) 217 | * [Micro SD card](#https://www.amazon.de/gp/product/B073JWXGNT/) 218 | * [Power Supply](https://www.amazon.de/gp/product/B07NW9NXGF/) 219 | * [Capacitive 5 inch touchscreen](https://www.amazon.de/gp/product/B07YCBWRQP/) 220 | * [Flat HDMI cable](https://www.amazon.de/gp/product/B07R9RXWM5/) 221 | * [Tilted micro USB cable](https://www.amazon.de/gp/product/B01N26RAL6/)\ 222 | (use a cutter knive to remove some of the isolation of the tilted connector to save some more height) 223 | * [Small kitchen storage box as case](https://www.amazon.de/gp/product/B0841PZZ2C/) (ATTENTION: you need the mid sized box: 15.8cm x 12cm) 224 | * The bottom part of a raspberry case like [this one](https://www.amazon.de/schwarz-Gehäuse-Raspberry-neueste-Kühlkörper/dp/B00ZHG7AP0/) where you can tighten the raspberry without the need to drill holes in the backside of the jukebox 225 | * Tesa Powerstrips to fix the raspberry inside the jukebox 226 | 227 | ### Front Cover Cutout: 228 | 229 | As first step, choose the position of the cutout for the touchscreen in the wooden front cover. 230 | Pay attention how far the HDMI and USB connectors stick out of the touch screen. 231 | Because of that you won't be able to center the touchscreen vertically in the front cover. 232 | 233 | After that I drilled 4 holes in each edge of the cutout. 234 | Then I used a fredsaw to remove the part between the holes. 235 | Keep the cutout a little bit smaller than the touchscreen, as working with a fredsaw might not be that precise. 236 | 237 | Now the hard part begins:\ 238 | Tweak the shape of the cutout with a rasp until the touchscreen fits in tightly. 239 | This might take a while and I recommend to hold the touchscreen and the cover in front of each other toward a light source from time to time, to see where you need to remove more of the wood. 240 | 241 | When the touchscreen fits into the cutout, use sand paper to smooth the surface. I started with grain size 80 and ended with 300. 242 | If you want an uniform look, use the finer grained sandpaper also on the front surface of the wood and apply some wood oil everywhere for a consistant looking finish. 243 | 244 | ### Assembly: 245 | Cut off the connector of the power supply, and drill a hole for the cable in the back of the case. 246 | Stick the cable through the hole and solder the connector back on. 247 | 248 | Assemble the Pi in the bottom part of the raspberry case and use the Powerstrips to attach it inside the box. 249 | 250 | If the touchscreen isn't sitting tight enough in the cutout, use __short__ screws to attach it to the front cover. 251 | Maybe predrill the holes, so the wood won't crack. 252 | 253 | Connect the cables, insert the microSD card and you're done. 254 | The wooden front cover should stick firmly if you press it onto the backside. I didn't need any screws to hold it in place. 255 | If you need to open it again, push a knive between the front cover and the backside and use it as lever. 256 | 257 | ### Kiosk Mode Installation 258 | 259 | Use raspbian light (currently Buster is the latest stable version as I write this readme) as we don't want to install the default desktop environment. 260 | 261 | a) because we don't need it\ 262 | b) because our Ionic/Angular app will stress the Pi enough, so we want to avoid too much running services and RAM usage. 263 | 264 | edit the config.txt in the boot partition and add the following lines at the end to configure the display: 265 | ``` 266 | max_usb_current = 1 267 | Hdmi_group = 2 268 | Hdmi_mode = 87 269 | Hdmi_cvt 800 480 60 6 0 0 0 270 | ``` 271 | After the initial boot process, start `raspi-config` and configure: 272 | * Localisation options and keyboard layout 273 | * Configure Wifi 274 | * Enable SSH if you wish to be able to log into the box remotely 275 | * Disable HDMI overscan in the advanced option 276 | * Change user password for pi 277 | * In _Boot Options_ Select 'Desktop/CLI' and then 'Console Autologin' for automatic login of the user pi 278 | 279 | Now reboot the Pi. If everything worked, you should be logged into the terminal session without having to enter you password at the end the boot process. 280 | 281 | Update all preinstalled packages: 282 | ``` 283 | sudo apt-get update 284 | sudo apt-get upgrade 285 | 286 | ``` 287 | Now we install Openbox as a lightweight window manager: 288 | ``` 289 | sudo apt-get install --no-install-recommends xserver-xorg x11-xserver-utils xinit openbox 290 | ``` 291 | And Chromium as a browser: 292 | ``` 293 | sudo apt-get install --no-install-recommends chromium-browser 294 | ``` 295 | 296 | You can now install node-sonos-http-api and Sonos-Kids-Controller on the Pi (as described in the previous chapters) if you don't want to run it on a different server. 297 | Depending on where the services are running edit the automatic startup of Openbox and Chromium in _/etc/xdg/openbox/autostart_ 298 | 299 | 300 | ``` 301 | # Disable screen saver / power management 302 | xset s off 303 | xset s noblank 304 | xset -dpms 305 | 306 | # Start Chromium 307 | sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' ~/.config/chromium/'Local State' 308 | sed -i 's/"exited_cleanly":false/"exited_cleanly":true/; s/"exit_type":"[^"]\+"/"exit_type":"Normal"/' ~/.config/chromium/Default/Preferences 309 | chromium-browser --disable-infobars --kiosk 'http://url-to-sonos-kids-controller:8200' 310 | ``` 311 | 312 | Now Chromium should display our web app when Openbox is started. 313 | The last thing to do is to start the X server automatically when the Pi is powered on. 314 | As we already have automatic login in the terminal session, 315 | we can use __.bash_profile__ for starting X. 316 | Append the following line to the file: 317 | ``` 318 | [[ -z $DISPLAY && $XDG_VTNR -eq 1 ]] && startx -- -nocursor 319 | ``` 320 | This starts X when the user logged into the first system terminal (which is the one autologin uses). 321 | 322 | Restart the pi to see if everything works. 323 | 324 | If you see a bubble in Chromium after some time, about Chromium not beeing up to date, use this workaround from [StackOverflow](https://stackoverflow.com/questions/58993181/disable-chromium-can-not-update-chromium-window-notification) and execute the following command: 325 | ``` 326 | sudo touch /etc/chromium-browser/customizations/01-disable-update-check;echo CHROMIUM_FLAGS=\"\$\{CHROMIUM_FLAGS\} --check-for-update-interval=31536000\" | sudo tee /etc/chromium-browser/customizations/01-disable-update-check 327 | ``` 328 | 329 | ## Raspberry Pi optimization 330 | 331 | You may perform the following changes to Rasperry Pi OS AT YOUR OWN RISK. 332 | 333 | #### Disable boot up messages 334 | 335 | According to the guide on the site https://florianmuller.com/polish-your-raspberry-pi-clean-boot-splash-screen-video-noconsole-zram you can disable the boot up messages by adding the following text to the file __/boot/cmdline.txt__ (no linefeed, no double space): 336 | 337 | ``` 338 | loglevel=0 plymouth.enable=0 vt.global_cursor_default=0 plymouth.ignore-serial-consoles splash fastboot noatime nodiratime noram 339 | ``` 340 | 341 | #### Log to ramdisk 342 | 343 | You may add the folling lines to the file __/etc/fstab__ to write temporary files and log files to ram. Attention: they get lost with every reboot. 344 | 345 | ``` 346 | tmpfs /tmp tmpfs defaults,noatime,nosuid,size=100m 0 0 347 | tmpfs /var/tmp tmpfs defaults,noatime,nosuid,size=25m 0 0 348 | tmpfs /var/log tmpfs defaults,noatime,nosuid,mode=0755,size=25m 0 0 349 | ``` 350 | 351 | This will speed up the boot up process and helps to improve the lifetime of the SD card. 352 | 353 | #### Fixed IP-Adress 354 | 355 | a fixed IP Adress will speed up the boot process by about 3-5 seconds. Add your details to __/etc/dhcpcd.conf__. For example: 356 | 357 | ``` 358 | interface wlan0 359 | static ip_address=192.168.0.4/24 360 | static routers=192.168.0.254 361 | static domain_name_servers=192.168.0.254 8.8.8.8 362 | ``` 363 | 364 | #### Overclocking CPU 365 | 366 | You may consider overclock your hardware by add the following lines to __/boot/config.txt. THIS MAY HARM YOUR HARDWARE! 367 | 368 | Example for Raspberry 3B: 369 | ``` 370 | # Overclock CPU 371 | arm_freq=1200 372 | over_voltage=4 373 | temp_limit=75 374 | core_freq=500 375 | 376 | # Overclock GPU 377 | h264_freq=333 378 | avoid_pwm_pll=1 379 | gpu_mem=320 380 | v3d_freq=500 381 | 382 | # Overclock RAM 383 | sdram_freq=588 384 | sdram_schmoo=0x02000020 385 | over_voltage_sdram_p=6 386 | over_voltage_sdram_i=4 387 | over_voltage_sdram_c=4 388 | ``` 389 | 390 | #### Overclocking sd card 391 | 392 | If you have a suitable SD Card you may consider overclock the SD card reader by add the following lines to __/boot/config.txt. THIS MAY HARM YOUR HARDWARE! 393 | 394 | ``` 395 | dtparam=sd_overclock=100 396 | ``` 397 | 398 | ## Docker 399 | There is now also an easy way to setup this software using Docker. 400 | This avoids the compilation on small hardware. 401 | 402 | The image is maintained by [stepman0](https://github.com/stepman0)\ 403 | Get it [here](https://github.com/stepman0/docker-sonos-kids-controller). 404 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "defaultProject": "app", 5 | "newProjectRoot": "projects", 6 | "projects": { 7 | "app": { 8 | "root": "", 9 | "sourceRoot": "src", 10 | "projectType": "application", 11 | "prefix": "app", 12 | "schematics": {}, 13 | "architect": { 14 | "build": { 15 | "builder": "@angular-devkit/build-angular:browser", 16 | "options": { 17 | "outputPath": "www", 18 | "index": "src/index.html", 19 | "main": "src/main.ts", 20 | "polyfills": "src/polyfills.ts", 21 | "tsConfig": "tsconfig.app.json", 22 | "assets": [ 23 | { 24 | "glob": "**/*", 25 | "input": "src/assets", 26 | "output": "assets" 27 | }, 28 | { 29 | "glob": "**/*.svg", 30 | "input": "node_modules/ionicons/dist/ionicons/svg", 31 | "output": "./svg" 32 | } 33 | ], 34 | "styles": [ 35 | { 36 | "input": "src/theme/variables.scss" 37 | }, 38 | { 39 | "input": "src/global.scss" 40 | } 41 | ], 42 | "scripts": [] 43 | }, 44 | "configurations": { 45 | "production": { 46 | "fileReplacements": [ 47 | { 48 | "replace": "src/environments/environment.ts", 49 | "with": "src/environments/environment.prod.ts" 50 | } 51 | ], 52 | "optimization": true, 53 | "outputHashing": "all", 54 | "sourceMap": false, 55 | "namedChunks": false, 56 | "aot": true, 57 | "extractLicenses": true, 58 | "vendorChunk": false, 59 | "buildOptimizer": true, 60 | "budgets": [ 61 | { 62 | "type": "initial", 63 | "maximumWarning": "2mb", 64 | "maximumError": "5mb" 65 | } 66 | ] 67 | }, 68 | "ci": { 69 | "progress": false 70 | } 71 | } 72 | }, 73 | "serve": { 74 | "builder": "@angular-devkit/build-angular:dev-server", 75 | "options": { 76 | "browserTarget": "app:build" 77 | }, 78 | "configurations": { 79 | "production": { 80 | "browserTarget": "app:build:production" 81 | }, 82 | "ci": { 83 | "progress": false 84 | } 85 | } 86 | }, 87 | "extract-i18n": { 88 | "builder": "@angular-devkit/build-angular:extract-i18n", 89 | "options": { 90 | "browserTarget": "app:build" 91 | } 92 | }, 93 | "test": { 94 | "builder": "@angular-devkit/build-angular:karma", 95 | "options": { 96 | "main": "src/test.ts", 97 | "polyfills": "src/polyfills.ts", 98 | "tsConfig": "tsconfig.spec.json", 99 | "karmaConfig": "karma.conf.js", 100 | "styles": [], 101 | "scripts": [], 102 | "assets": [ 103 | { 104 | "glob": "favicon.ico", 105 | "input": "src/", 106 | "output": "/" 107 | }, 108 | { 109 | "glob": "**/*", 110 | "input": "src/assets", 111 | "output": "/assets" 112 | } 113 | ] 114 | }, 115 | "configurations": { 116 | "ci": { 117 | "progress": false, 118 | "watch": false 119 | } 120 | } 121 | }, 122 | "lint": { 123 | "builder": "@angular-devkit/build-angular:tslint", 124 | "options": { 125 | "tsConfig": [ 126 | "tsconfig.app.json", 127 | "tsconfig.spec.json", 128 | "e2e/tsconfig.json" 129 | ], 130 | "exclude": ["**/node_modules/**"] 131 | } 132 | }, 133 | "e2e": { 134 | "builder": "@angular-devkit/build-angular:protractor", 135 | "options": { 136 | "protractorConfig": "e2e/protractor.conf.js", 137 | "devServerTarget": "app:serve" 138 | }, 139 | "configurations": { 140 | "production": { 141 | "devServerTarget": "app:serve:production" 142 | }, 143 | "ci": { 144 | "devServerTarget": "app:serve:ci" 145 | } 146 | } 147 | }, 148 | "ionic-cordova-build": { 149 | "builder": "@ionic/angular-toolkit:cordova-build", 150 | "options": { 151 | "browserTarget": "app:build" 152 | }, 153 | "configurations": { 154 | "production": { 155 | "browserTarget": "app:build:production" 156 | } 157 | } 158 | }, 159 | "ionic-cordova-serve": { 160 | "builder": "@ionic/angular-toolkit:cordova-serve", 161 | "options": { 162 | "cordovaBuildTarget": "app:ionic-cordova-build", 163 | "devServerTarget": "app:serve" 164 | }, 165 | "configurations": { 166 | "production": { 167 | "cordovaBuildTarget": "app:ionic-cordova-build:production", 168 | "devServerTarget": "app:serve:production" 169 | } 170 | } 171 | } 172 | } 173 | } 174 | }, 175 | "cli": { 176 | "defaultCollection": "@ionic/angular-toolkit" 177 | }, 178 | "schematics": { 179 | "@ionic/angular-toolkit:component": { 180 | "styleext": "scss" 181 | }, 182 | "@ionic/angular-toolkit:page": { 183 | "styleext": "scss" 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /browserslist: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. 13 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #cleanup 4 | rm -rf deploy 5 | 6 | # build personal web app 7 | ionic build --prod 8 | 9 | # Pause execution to see if the build process worked 10 | read -p "Press Enter to resume ..." 11 | 12 | # copy everything to deploy directory 13 | mkdir deploy 14 | cp -Rp www deploy/ 15 | mkdir deploy/server 16 | mkdir deploy/server/config 17 | cp -p server/config/config-example.json deploy/server/config/ 18 | cp -p server.js deploy/ 19 | cp -p package-deploy.json deploy/package.json 20 | cp -p README.md deploy/ 21 | 22 | # archive 23 | cd deploy 24 | zip -r ../../sonos-kids-controller.zip . 25 | cd .. 26 | 27 | #cleanup 28 | rm -rf deploy -------------------------------------------------------------------------------- /e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | 6 | exports.config = { 7 | allScriptsTimeout: 11000, 8 | specs: [ 9 | './src/**/*.e2e-spec.ts' 10 | ], 11 | capabilities: { 12 | 'browserName': 'chrome' 13 | }, 14 | directConnect: true, 15 | baseUrl: 'http://localhost:4200/', 16 | framework: 'jasmine', 17 | jasmineNodeOpts: { 18 | showColors: true, 19 | defaultTimeoutInterval: 30000, 20 | print: function() {} 21 | }, 22 | onPrepare() { 23 | require('ts-node').register({ 24 | project: require('path').join(__dirname, './tsconfig.json') 25 | }); 26 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | 3 | describe('new App', () => { 4 | let page: AppPage; 5 | 6 | beforeEach(() => { 7 | page = new AppPage(); 8 | }); 9 | 10 | it('should be blank', () => { 11 | page.navigateTo(); 12 | expect(page.getParagraphText()).toContain('Start with Ionic UI Components'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.deepCss('app-root ion-content')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ionic.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sonos-kids-controller", 3 | "integrations": {}, 4 | "type": "angular" 5 | } -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../coverage'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /package-deploy.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sonos-Kids-Controller", 3 | "version": "1.6.0", 4 | "author": "Tobias Wiedenmann", 5 | "homepage": "https://github.com/Thyraz/Sonos-Kids-Controller", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "private": true, 10 | "dependencies": { 11 | "rxjs": "~6.6.0", 12 | "cors": "^2.8.5", 13 | "express": "^4.17.2", 14 | "jsonfile": "^6.1.0", 15 | "simple-keyboard": "^3.4.46", 16 | "spotify-web-api-js": "^1.5.2", 17 | "spotify-web-api-node": "^5.0.2", 18 | "tslib": "^2.2.0", 19 | "zone.js": "~0.11.4" 20 | }, 21 | "devDependencies": { 22 | }, 23 | "description": "Software for self made touchscreen jukeboxes for kids" 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sonos-Kids-Controller", 3 | "version": "1.6.0", 4 | "author": "Tobias Wiedenmann", 5 | "homepage": "https://github.com/Thyraz/Sonos-Kids-Controller", 6 | "scripts": { 7 | "ng": "ng", 8 | "start": "node server.js", 9 | "build": "ionic build --prod", 10 | "deploy": "ionic build --prod" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/common": "~13.0.0", 15 | "@angular/core": "~13.0.0", 16 | "@angular/forms": "~13.0.0", 17 | "@angular/platform-browser": "~13.0.0", 18 | "@angular/platform-browser-dynamic": "~13.0.0", 19 | "@angular/router": "~13.0.0", 20 | "@ionic-native/splash-screen": "^5.36.0", 21 | "@ionic-native/status-bar": "^5.36.0", 22 | "@ionic/angular": "^6.0.0", 23 | "cors": "^2.8.5", 24 | "express": "^4.17.2", 25 | "jsonfile": "^6.1.0", 26 | "rxjs": "~6.6.0", 27 | "simple-keyboard": "^3.4.46", 28 | "spotify-web-api-js": "^1.5.2", 29 | "spotify-web-api-node": "^5.0.2", 30 | "tslib": "^2.2.0", 31 | "zone.js": "~0.11.4" 32 | }, 33 | "devDependencies": { 34 | "@angular-devkit/build-angular": "^13.2.3", 35 | "@angular-eslint/builder": "~13.0.1", 36 | "@angular-eslint/eslint-plugin": "~13.0.1", 37 | "@angular-eslint/eslint-plugin-template": "~13.0.1", 38 | "@angular-eslint/template-parser": "~13.0.1", 39 | "@angular/cli": "~13.0.1", 40 | "@angular/compiler": "~13.0.0", 41 | "@angular/compiler-cli": "~13.0.0", 42 | "@angular/language-service": "~13.0.0", 43 | "@ionic/angular-toolkit": "^5.0.0", 44 | "@types/jasmine": "~3.6.0", 45 | "@types/jasminewd2": "~2.0.3", 46 | "@types/node": "^12.11.1", 47 | "@typescript-eslint/eslint-plugin": "5.3.0", 48 | "@typescript-eslint/parser": "5.3.0", 49 | "eslint": "^7.6.0", 50 | "eslint-plugin-import": "2.22.1", 51 | "eslint-plugin-jsdoc": "30.7.6", 52 | "eslint-plugin-prefer-arrow": "1.2.2", 53 | "jasmine-core": "~3.8.0", 54 | "jasmine-spec-reporter": "~5.0.0", 55 | "karma": "~6.3.2", 56 | "karma-chrome-launcher": "~3.1.0", 57 | "karma-coverage": "~2.0.3", 58 | "karma-coverage-istanbul-reporter": "~3.0.2", 59 | "karma-jasmine": "~4.0.0", 60 | "karma-jasmine-html-reporter": "^1.5.0", 61 | "protractor": "~7.0.0", 62 | "ts-node": "~8.3.0", 63 | "typescript": "~4.4.4" 64 | }, 65 | "description": "Software for self made touchscreen jukeboxes for kids" 66 | } 67 | 68 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | // Setup 2 | const express = require('express'); 3 | const cors = require('cors'); 4 | const app = express(); 5 | const path = require('path'); 6 | const jsonfile = require('jsonfile'); 7 | var SpotifyWebApi = require('spotify-web-api-node'); 8 | const config = require('./server/config/config.json'); 9 | 10 | app.use(cors()); 11 | 12 | var spotifyApi = new SpotifyWebApi({ 13 | clientId: config.spotify.clientId, 14 | clientSecret: config.spotify.clientSecret 15 | }); 16 | 17 | // Configuration 18 | const dataFile = './server/config/data.json' 19 | 20 | app.use(express.json()); 21 | app.use(express.urlencoded({ extended: false })); 22 | 23 | app.use(express.static(path.join(__dirname, 'www'))); // Static path to compiled Ionic app 24 | 25 | 26 | // Routes 27 | app.get('/api/data', (req, res) => { 28 | jsonfile.readFile(dataFile, (error, data) => { 29 | if (error) data = []; 30 | res.json(data); 31 | }); 32 | }); 33 | 34 | app.post('/api/add', (req, res) => { 35 | jsonfile.readFile(dataFile, (error, data) => { 36 | if (error) data = []; 37 | data.push(req.body); 38 | 39 | jsonfile.writeFile(dataFile, data, { spaces: 4 }, (error) => { 40 | if (error) throw err; 41 | res.status(200).send(); 42 | }); 43 | }); 44 | }); 45 | 46 | app.post('/api/delete', (req, res) => { 47 | jsonfile.readFile(dataFile, (error, data) => { 48 | if (error) data = []; 49 | data.splice(req.body.index, 1); 50 | 51 | jsonfile.writeFile(dataFile, data, { spaces: 4 }, (error) => { 52 | if (error) throw err; 53 | res.status(200).send(); 54 | }); 55 | }); 56 | }); 57 | 58 | app.get('/api/token', (req, res) => { 59 | // Retrieve an access token from Spotify 60 | spotifyApi.clientCredentialsGrant().then( 61 | function(data) { 62 | res.status(200).send(data.body['access_token']); 63 | }, 64 | function(err) { 65 | console.log( 66 | 'Something went wrong when retrieving a new Spotify access token', 67 | err.message 68 | ); 69 | 70 | res.status(500).send(err.message); 71 | } 72 | ); 73 | }); 74 | 75 | app.get('/api/sonos', (req, res) => { 76 | // Send server address and port of the node-sonos-http-api instance to the client 77 | res.status(200).send(config['node-sonos-http-api']); 78 | }); 79 | 80 | // Catch all other routes and return the index file from Ionic app 81 | app.get('*', (req, res) => { 82 | res.sendFile(path.join(__dirname, 'www/index.html')); 83 | }); 84 | 85 | // listen (start app with 'node server.js') 86 | app.listen(8200); 87 | console.log("App listening on port 8200"); -------------------------------------------------------------------------------- /server/config/config-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "node-sonos-http-api": { 3 | "server": "127.0.0.1", 4 | "port": "5005", 5 | "rooms": [ 6 | "Livingroom", 7 | "Kitchen" 8 | ], 9 | "tts": { 10 | "enabled": true, 11 | "language": "de-de", 12 | "volume": "40" 13 | } 14 | }, 15 | "spotify": { 16 | "clientId": "your_id", 17 | "clientSecret": "your_secret" 18 | } 19 | } -------------------------------------------------------------------------------- /src/app/activity-indicator.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { ActivityIndicatorService } from './activity-indicator.service'; 4 | 5 | describe('ActivityIndicatorService', () => { 6 | let service: ActivityIndicatorService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(ActivityIndicatorService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/activity-indicator.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { LoadingController } from '@ionic/angular'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class ActivityIndicatorService { 8 | 9 | constructor( 10 | public loadingController: LoadingController 11 | ) { } 12 | 13 | create(): Promise { 14 | return this.loadingController.create({ 15 | mode: 'ios', 16 | spinner: 'circles' 17 | }); 18 | } 19 | 20 | dismiss() { 21 | this.loadingController.dismiss(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/add/add-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | 4 | import { AddPage } from './add.page'; 5 | 6 | const routes: Routes = [ 7 | { 8 | path: '', 9 | component: AddPage 10 | } 11 | ]; 12 | 13 | @NgModule({ 14 | imports: [RouterModule.forChild(routes)], 15 | exports: [RouterModule], 16 | }) 17 | export class AddPageRoutingModule {} 18 | -------------------------------------------------------------------------------- /src/app/add/add.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { FormsModule } from '@angular/forms'; 4 | 5 | import { IonicModule } from '@ionic/angular'; 6 | 7 | import { AddPageRoutingModule } from './add-routing.module'; 8 | 9 | import { AddPage } from './add.page'; 10 | 11 | @NgModule({ 12 | imports: [ 13 | CommonModule, 14 | FormsModule, 15 | IonicModule, 16 | AddPageRoutingModule 17 | ], 18 | declarations: [AddPage] 19 | }) 20 | export class AddPageModule {} 21 | -------------------------------------------------------------------------------- /src/app/add/add.page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Radio Play / Audio Book 5 | Music 6 | Playlist 7 | Radio 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Library 17 | 18 | 19 | Spotify 20 | 21 | 22 | Amazon 23 | 24 | 25 | Apple 26 | 27 | 28 | TuneIn 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 |
37 |
38 | 39 | 40 | 41 |
42 | 43 | 44 | 45 |
46 |
47 | 48 |
49 | 50 | 51 | 52 |
53 |
54 |
55 | 56 | 57 |
58 | 59 | 60 | 61 |
62 |
63 |
64 | 65 | 66 |
67 |
68 | Add 69 |
70 |
71 |
72 | 73 |
74 |
75 | Cancel 76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | 84 |
85 |
86 | 87 | 88 | 89 |
90 | 91 | Media search type 92 | 93 | Artist + Title 94 | Artist ID 95 | Media ID 96 | Search Query 97 | 98 | 99 |
100 |
101 |
102 | 103 | 104 | 105 |
106 | 107 | 108 | 109 |
110 |
111 | 112 |
113 | 114 | 115 | 116 | 117 | 118 | 119 |
120 |
121 |
122 | 123 | 124 | 125 |
126 |
127 | Add 128 |
129 |
130 |
131 | 132 |
133 |
134 | Cancel 135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 | 143 |
144 |
145 | 146 | 147 | 148 |
149 | 150 | 151 | 152 |
153 |
154 | 155 |
156 | 157 | 158 | 159 |
160 |
161 |
162 | 163 | 164 |
165 | 166 | 167 | 168 |
169 |
170 | 171 |
172 | 173 | 174 | 175 |
176 |
177 |
178 | 179 | 180 |
181 |
182 | Add 183 |
184 |
185 |
186 | 187 |
188 |
189 | Cancel 190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 | 198 |
199 |
200 | 201 | 202 | 203 |
204 | 205 | 206 | 207 |
208 |
209 | 210 |
211 | 212 | 213 | 214 |
215 |
216 |
217 | 218 | 219 |
220 | 221 | 222 | 223 |
224 |
225 | 226 |
227 | 228 | 229 | 230 |
231 |
232 |
233 | 234 | 235 |
236 |
237 | Add 238 |
239 |
240 |
241 | 242 |
243 |
244 | Cancel 245 |
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 | 255 | 256 | 257 |
258 | 259 | 260 | 261 |
262 |
263 | 264 |
265 | 266 | 267 | 268 |
269 |
270 |
271 | 272 | 273 |
274 | 275 | 276 | 277 |
278 |
279 |
280 | 281 | 282 |
283 |
284 | Add 285 |
286 |
287 |
288 | 289 |
290 |
291 | Cancel 292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 | 301 |
302 |
303 |
304 |
-------------------------------------------------------------------------------- /src/app/add/add.page.scss: -------------------------------------------------------------------------------- 1 | ion-toolbar { 2 | ion-back-button { 3 | --min-height: 70px; 4 | --min-width: 120px; 5 | --icon-font-size: 30px; 6 | } 7 | ion-button { 8 | height: 70px !important; 9 | width: 70px !important; 10 | font-size: 20px !important; 11 | } 12 | ion-segment-button { 13 | font-size: 18px; 14 | --indicator-color: red !important; 15 | } 16 | ion-select { 17 | display: none; 18 | } 19 | } 20 | 21 | ion-item { 22 | font-size: 18px; 23 | } 24 | 25 | .add-button, .cancel-button { 26 | margin-top: -4px; 27 | } 28 | 29 | .top { 30 | padding-bottom: 180px; 31 | } 32 | 33 | .bottom { 34 | position: fixed; 35 | left: 0; 36 | bottom: 0; 37 | right: 0; 38 | z-index: 9999; 39 | background-color: rgba(220, 220, 220, 1.0); 40 | } 41 | 42 | .item .dimmed-text { 43 | opacity: 0.5; 44 | } 45 | 46 | // Onscreen Keyboard 47 | .simple-keyboard { 48 | max-width: 800px; 49 | } 50 | 51 | .hg-button { 52 | color: var(--ion-color-light); 53 | } 54 | 55 | .simple-keyboard.hg-theme-default { 56 | font-family: inherit !important; 57 | } 58 | 59 | .simple-keyboard.hg-theme-ios { 60 | width: 800px; 61 | margin: auto; 62 | } 63 | 64 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-row .hg-button { 65 | flex-grow: 1; 66 | cursor: pointer; 67 | max-width: initial; 68 | } 69 | 70 | .simple-keyboard.hg-theme-ios .hg-row { 71 | display: flex; 72 | } 73 | 74 | .simple-keyboard.hg-theme-ios .hg-row:not(:last-child) { 75 | margin-bottom: 5px; 76 | } 77 | 78 | .simple-keyboard.hg-theme-ios .hg-row .hg-button:not(:last-child) { 79 | margin-right: 5px; 80 | } 81 | 82 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button.hg-button-shift { 83 | max-width: 123px; 84 | min-width: 123px; 85 | } 86 | 87 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button.hg-button-shiftactivated { 88 | max-width: 123px; 89 | min-width: 123px; 90 | } 91 | 92 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button.hg-button-bksp { 93 | max-width: 126px; 94 | min-width: 126px; 95 | } 96 | 97 | .simple-keyboard.hg-theme-ios.hg-theme-default { 98 | background-color: rgba(220, 220, 220, 1.0); 99 | padding: 5px; 100 | border-radius: 5px; 101 | } 102 | 103 | .simple-keyboard.hg-theme-ios.hg-theme-default.hg-layout-custom { 104 | background-color: #e5e5e5; 105 | padding: 5px; 106 | } 107 | 108 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button { 109 | border-radius: 5px; 110 | box-sizing: border-box; 111 | padding: 0; 112 | background: white; 113 | border-bottom: 1px solid #b5b5b5; 114 | cursor: pointer; 115 | display: flex; 116 | align-items: center; 117 | justify-content: center; 118 | box-shadow: none; 119 | font-weight: 400; 120 | font-size: 18px; 121 | max-width: 45px; 122 | min-width: 45px; 123 | height: 48px; 124 | min-height: 48px; 125 | } 126 | 127 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button:active, 128 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button:focus { 129 | background: #e4e4e4; 130 | } 131 | 132 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button.hg-functionBtn { 133 | background-color: #adb5bb; 134 | } 135 | 136 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button.hg-button-space, 137 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button.hg-button-shift, 138 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button.hg-button-shiftactivated { 139 | background-color: #ffffff; 140 | } 141 | 142 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button-space { 143 | max-width: 448px; 144 | min-width: 448px; 145 | } 146 | 147 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button-enter { 148 | max-width: 110px; 149 | min-width: 110px; 150 | background-color: #356feb !important; 151 | color: white; 152 | } 153 | 154 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button-altright, 155 | .simple-keyboard.hg-theme-ios.hg-theme-default .hg-button-back { 156 | min-width: 80px; 157 | max-width: 80px; 158 | } -------------------------------------------------------------------------------- /src/app/add/add.page.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { IonicModule } from '@ionic/angular'; 3 | 4 | import { AddPage } from './add.page'; 5 | 6 | describe('AddPage', () => { 7 | let component: AddPage; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [ AddPage ], 13 | imports: [IonicModule.forRoot()] 14 | }).compileComponents(); 15 | 16 | fixture = TestBed.createComponent(AddPage); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | })); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/add/add.page.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewEncapsulation, AfterViewInit, ViewChild } from '@angular/core'; 2 | import { NavController, IonSelect, IonInput, IonSegment } from '@ionic/angular'; 3 | import { MediaService } from '../media.service'; 4 | import { Media } from '../media'; 5 | import Keyboard from 'simple-keyboard'; 6 | import { NgForm } from '@angular/forms'; 7 | 8 | 9 | @Component({ 10 | selector: 'app-add', 11 | encapsulation: ViewEncapsulation.None, 12 | templateUrl: './add.page.html', 13 | styleUrls: [ 14 | './add.page.scss', 15 | '../../../node_modules/simple-keyboard/build/css/index.css' 16 | ] 17 | }) 18 | export class AddPage implements OnInit, AfterViewInit { 19 | @ViewChild('segment', { static: false }) segment: IonSegment; 20 | @ViewChild('select', { static: false }) select: IonSelect; 21 | @ViewChild('searchTypeSelect', { static: false }) searchTypeSelect: IonSelect; 22 | 23 | @ViewChild('library_segment', { static: false }) librarySegment: IonSelect; 24 | @ViewChild('spotify_segment', { static: false }) spotifySegment: IonSelect; 25 | @ViewChild('amazonmusic_segment', { static: false }) amazonmusicSegment: IonSelect; 26 | @ViewChild('applemusic_segment', { static: false }) applemusicSegment: IonSelect; 27 | @ViewChild('tunein_segment', { static: false }) tuneinSegment: IonSelect; 28 | 29 | @ViewChild('library_artist', { static: false }) libraryArtist: IonInput; 30 | @ViewChild('library_title', { static: false }) libraryTitle: IonInput; 31 | @ViewChild('library_cover', { static: false }) libraryCover: IonInput; 32 | @ViewChild('spotify_artist', { static: false }) spotifyArtist: IonInput; 33 | @ViewChild('spotify_id', { static: false }) spotifyID: IonInput; 34 | @ViewChild('spotify_artistid', { static: false }) spotifyArtistID: IonInput; 35 | @ViewChild('spotify_title', { static: false }) spotifyTitle: IonInput; 36 | @ViewChild('spotify_query', { static: false }) spotifyQuery: IonInput; 37 | @ViewChild('amazonmusic_artist', { static: false }) amazonmusicArtist: IonInput; 38 | @ViewChild('amazonmusic_id', { static: false }) amazonmusicID: IonInput; 39 | @ViewChild('amazonmusic_title', { static: false }) amazonmusicTitle: IonInput; 40 | @ViewChild('amazonmusic_cover', { static: false }) amazonmusicCover: IonInput; 41 | @ViewChild('applemusic_artist', { static: false }) applemusicArtist: IonInput; 42 | @ViewChild('applemusic_id', { static: false }) applemusicID: IonInput; 43 | @ViewChild('applemusic_title', { static: false }) applemusicTitle: IonInput; 44 | @ViewChild('applemusic_cover', { static: false }) applemusicCover: IonInput; 45 | @ViewChild('tunein_title', { static: false }) tuneinTitle: IonInput; 46 | @ViewChild('tunein_id', { static: false }) tuneinID: IonInput; 47 | @ViewChild('tunein_cover', { static: false }) tuneinCover: IonInput; 48 | 49 | source = 'spotify'; 50 | category = 'audiobook'; 51 | searchType = 'media_id'; 52 | keyboard: Keyboard; 53 | selectedInputElem: any; 54 | valid = false; 55 | 56 | categoryIcons = { 57 | audiobook: 'book-outline', 58 | music: 'musical-notes-outline', 59 | playlist: 'document-text-outline', 60 | radio: 'radio-outline' 61 | }; 62 | 63 | constructor( 64 | private mediaService: MediaService, 65 | private navController: NavController 66 | ) { } 67 | 68 | ngOnInit() { 69 | } 70 | 71 | ngAfterViewInit() { 72 | this.tuneinSegment.disabled = true; 73 | 74 | this.keyboard = new Keyboard({ 75 | onChange: input => { 76 | this.selectedInputElem.value = input; 77 | this.validate(); 78 | }, 79 | onKeyPress: button => { 80 | this.handleLayoutChange(button); 81 | }, 82 | theme: 'hg-theme-default hg-theme-ios', 83 | layout: { 84 | default: [ 85 | 'q w e r t z u i o p ü', 86 | 'a s d f g h j k l ö ä', 87 | '{shift} y x c v b n m {shift}', 88 | '{alt} {space} . {bksp}' 89 | ], 90 | shift: [ 91 | 'Q W E R T Z U I O P Ü', 92 | 'A S D F G H J K L Ö Ä', 93 | '{shiftactivated} Y X C V B N M {shift}', 94 | '{alt} {space} . {bksp}' 95 | ], 96 | alt: [ 97 | '1 2 3 4 5 6 7 8 9 0 =', 98 | `% @ # $ & * / ( ) ' "`, 99 | '{shift} , - + ; : ! ? {shift}', 100 | '{default} {space} . {bksp}' 101 | ] 102 | }, 103 | display: { 104 | '{alt}': '123', 105 | '{smileys}': '\uD83D\uDE03', 106 | '{shift}': '⇧', 107 | '{shiftactivated}': '⇧', 108 | '{enter}': '⮐ ', 109 | '{bksp}': '⌫', 110 | '{altright}': '123', 111 | '{downkeyboard}': '🞃', 112 | '{space}': ' ', 113 | '{default}': 'ABC', 114 | '{back}': '⇦' 115 | } 116 | }); 117 | 118 | this.selectedInputElem = document.querySelector('ion-input:first-child'); 119 | } 120 | 121 | cancelButtonPressed() { 122 | this.navController.back(); 123 | } 124 | 125 | categoryButtonPressed(event: any) { 126 | this.select.open(event); 127 | } 128 | 129 | categoryChanged() { 130 | if (this.category === 'radio' && this.source !== 'tunein') { 131 | this.source = 'tunein'; 132 | } else if (this.category !== 'radio' && this.source === 'tunein') { 133 | this.source = 'spotify'; 134 | } 135 | 136 | this.validate(); 137 | } 138 | 139 | searchTypeChanged() { 140 | this.validate(); 141 | } 142 | 143 | focusChanged(event: any) { 144 | this.selectedInputElem = event.target; 145 | 146 | this.keyboard.setOptions({ 147 | inputName: event.target.name 148 | }); 149 | } 150 | 151 | inputChanged(event: any) { 152 | this.keyboard.setInput(event.target.value, event.target.name); 153 | this.validate(); 154 | } 155 | 156 | handleLayoutChange(button) { 157 | const currentLayout = this.keyboard.options.layoutName; 158 | let layout: string; 159 | 160 | switch (button) { 161 | case '{shift}': 162 | case '{shiftactivated}': 163 | case '{default}': 164 | layout = currentLayout === 'default' ? 'shift' : 'default'; 165 | break; 166 | case '{alt}': 167 | case '{altright}': 168 | layout = currentLayout === 'alt' ? 'default' : 'alt'; 169 | break; 170 | case '{smileys}': 171 | layout = currentLayout === 'smileys' ? 'default' : 'smileys'; 172 | break; 173 | default: 174 | break; 175 | } 176 | 177 | if (layout) { 178 | this.keyboard.setOptions({ 179 | layoutName: layout 180 | }); 181 | } 182 | } 183 | 184 | segmentChanged(event: any) { 185 | this.source = event.detail.value; 186 | window.setTimeout(() => { // wait for new elements to be visible before altering them 187 | this.validate(); 188 | }, 10); 189 | } 190 | 191 | submit(form: NgForm) { 192 | const media: Media = { 193 | type: this.source, 194 | category: this.category 195 | }; 196 | 197 | if (this.source === 'spotify') { 198 | if (form.form.value.spotify_artist?.length) { media.artist = form.form.value.spotify_artist; } 199 | if (form.form.value.spotify_title?.length) { media.title = form.form.value.spotify_title; } 200 | if (form.form.value.spotify_query?.length) { media.query = form.form.value.spotify_query; } 201 | if (form.form.value.spotify_id?.length) { media.id = form.form.value.spotify_id; } 202 | if (form.form.value.spotify_artistid?.length) { media.artistid = form.form.value.spotify_artistid; } 203 | 204 | } else if (this.source === 'library') { 205 | if (form.form.value.library_artist?.length) { media.artist = form.form.value.library_artist; } 206 | if (form.form.value.library_title?.length) { media.title = form.form.value.library_title; } 207 | if (form.form.value.library_cover?.length) { media.cover = form.form.value.library_cover; } 208 | 209 | } else if (this.source === 'amazonmusic') { 210 | if (form.form.value.amazonmusic_artist?.length) { media.artist = form.form.value.amazonmusic_artist; } 211 | if (form.form.value.amazonmusic_title?.length) { media.title = form.form.value.amazonmusic_title; } 212 | if (form.form.value.amazonmusic_cover?.length) { media.cover = form.form.value.amazonmusic_cover; } 213 | if (form.form.value.amazonmusic_id?.length) { media.id = form.form.value.amazonmusic_id; } 214 | 215 | } else if (this.source === 'applemusic') { 216 | if (form.form.value.applemusic_artist?.length) { media.artist = form.form.value.applemusic_artist; } 217 | if (form.form.value.applemusic_title?.length) { media.title = form.form.value.applemusic_title; } 218 | if (form.form.value.applemusic_cover?.length) { media.cover = form.form.value.applemusic_cover; } 219 | if (form.form.value.applemusic_id?.length) { media.id = form.form.value.applemusic_id; } 220 | 221 | } else if (this.source === 'tunein') { 222 | if (form.form.value.tunein_title?.length) { media.title = form.form.value.tunein_title; } 223 | if (form.form.value.tunein_cover?.length) { media.cover = form.form.value.tunein_cover; } 224 | if (form.form.value.tunein_id?.length) { media.id = form.form.value.tunein_id; } 225 | } 226 | 227 | this.mediaService.addRawMedia(media); 228 | 229 | form.reset(); 230 | 231 | this.keyboard.clearInput('spotify_artist'); 232 | this.keyboard.clearInput('spotify_title'); 233 | this.keyboard.clearInput('spotify_id'); 234 | this.keyboard.clearInput('spotify_artistid'); 235 | this.keyboard.clearInput('spotify_query'); 236 | 237 | this.keyboard.clearInput('library_artist'); 238 | this.keyboard.clearInput('library_title'); 239 | this.keyboard.clearInput('library_cover'); 240 | 241 | this.keyboard.clearInput('amazonmusic_artist'); 242 | this.keyboard.clearInput('amazonmusic_title'); 243 | this.keyboard.clearInput('amazonmusic_id'); 244 | this.keyboard.clearInput('amazonmusic_cover'); 245 | 246 | this.keyboard.clearInput('applemusic_artist'); 247 | this.keyboard.clearInput('applemusic_title'); 248 | this.keyboard.clearInput('applemusic_id'); 249 | this.keyboard.clearInput('applemusic_cover'); 250 | 251 | this.keyboard.clearInput('tunein_title'); 252 | this.keyboard.clearInput('tunein_id'); 253 | this.keyboard.clearInput('tunein_cover'); 254 | 255 | this.validate(); 256 | 257 | this.navController.back(); 258 | } 259 | 260 | validate() { 261 | if (this.librarySegment) { this.librarySegment.disabled = false; } 262 | if (this.spotifySegment) { this.spotifySegment.disabled = false; } 263 | if (this.amazonmusicSegment) { this.amazonmusicSegment.disabled = false; } 264 | if (this.applemusicSegment) { this.applemusicSegment.disabled = false; } 265 | if (this.tuneinSegment) { this.tuneinSegment.disabled = false; } 266 | 267 | if (this.libraryArtist) { this.libraryArtist.disabled = false; } 268 | if (this.spotifyArtist) { this.spotifyArtist.disabled = false; } 269 | if (this.spotifyQuery) { this.spotifyQuery.disabled = false; } 270 | if (this.amazonmusicArtist) { this.amazonmusicArtist.disabled = false; } 271 | if (this.applemusicArtist) { this.applemusicArtist.disabled = false; } 272 | 273 | if (this.searchTypeSelect) { 274 | if (this.category === 'playlist') { 275 | this.searchTypeSelect.disabled = true; 276 | this.searchType = 'media_id'; 277 | } else { 278 | this.searchTypeSelect.disabled = false; 279 | } 280 | } 281 | 282 | switch (this.category) { 283 | case 'audiobook': 284 | case 'music': 285 | if (this.tuneinSegment) { this.tuneinSegment.disabled = true; } 286 | break; 287 | case 'playlist': 288 | if (this.tuneinSegment) { this.tuneinSegment.disabled = true; } 289 | if (this.libraryArtist) { this.libraryArtist.disabled = true; } 290 | if (this.spotifyArtist) { this.spotifyArtist.disabled = true; } 291 | if (this.spotifyQuery) { this.spotifyQuery.disabled = true; } 292 | if (this.amazonmusicArtist) { this.amazonmusicArtist.disabled = true; } 293 | if (this.applemusicArtist) { this.applemusicArtist.disabled = true; } 294 | break; 295 | case 'radio': 296 | if (this.librarySegment) { this.librarySegment.disabled = true; } 297 | if (this.spotifySegment) { this.spotifySegment.disabled = true; } 298 | if (this.amazonmusicSegment) { this.amazonmusicSegment.disabled = true; } 299 | if (this.applemusicSegment) { this.applemusicSegment.disabled = true; } 300 | } 301 | 302 | if (this.source === 'spotify') { 303 | const artist = this.keyboard.getInput('spotify_artist'); 304 | const title = this.keyboard.getInput('spotify_title'); 305 | const id = this.keyboard.getInput('spotify_id'); 306 | const artistid = this.keyboard.getInput('spotify_artistid'); 307 | const query = this.keyboard.getInput('spotify_query'); 308 | 309 | this.valid = ( 310 | (this.category === 'audiobook' || this.category === 'music') && ( 311 | (title?.length > 0 && artist?.length > 0 && !(query?.length > 0) && !(id?.length > 0) && !(artistid?.length > 0)) 312 | || 313 | (query?.length > 0 && !(title?.length > 0) && !(id?.length > 0) && !(artistid?.length > 0)) 314 | || 315 | (id?.length > 0 && !(query?.length > 0)) 316 | || 317 | (artistid?.length > 0 && !(query?.length > 0)) 318 | ) 319 | || 320 | this.category === 'playlist' && id?.length > 0 321 | ); 322 | } else if (this.source === 'library') { 323 | const artist = this.keyboard.getInput('library_artist'); 324 | const title = this.keyboard.getInput('library_title'); 325 | 326 | this.valid = ( 327 | title?.length > 0 && artist?.length > 0 328 | ); 329 | } else if (this.source === 'amazonmusic') { 330 | const artist = this.keyboard.getInput('amazonmusic_artist'); 331 | const title = this.keyboard.getInput('amazonmusic_title'); 332 | const id = this.keyboard.getInput('amazonmusic_id'); 333 | 334 | this.valid = ( 335 | (this.category === 'audiobook' || this.category === 'music') && ( 336 | artist?.length > 0 && title?.length > 0 && id?.length > 0 337 | ) 338 | || 339 | this.category === 'playlist' && title?.length > 0 && id?.length > 0 340 | ); 341 | } else if (this.source === 'applemusic') { 342 | const artist = this.keyboard.getInput('applemusic_artist'); 343 | const title = this.keyboard.getInput('applemusic_title'); 344 | const id = this.keyboard.getInput('applemusic_id'); 345 | 346 | this.valid = ( 347 | (this.category === 'audiobook' || this.category === 'music') && ( 348 | artist?.length > 0 && title?.length > 0 && id?.length > 0 349 | ) 350 | || 351 | this.category === 'playlist' && title?.length > 0 && id?.length > 0 352 | ); 353 | } else if (this.source === 'tunein') { 354 | const artist = this.keyboard.getInput('tunein_artist'); 355 | const title = this.keyboard.getInput('tunein_title'); 356 | const id = this.keyboard.getInput('tunein_id'); 357 | 358 | this.valid = ( 359 | title?.length > 0 && id?.length > 0 360 | ); 361 | } 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { PreloadAllModules, RouterModule, Routes } from '@angular/router'; 3 | 4 | const routes: Routes = [ 5 | { 6 | path: 'home', 7 | loadChildren: () => import('./home/home.module').then( m => m.HomePageModule) 8 | }, 9 | { 10 | path: '', 11 | redirectTo: 'home', 12 | pathMatch: 'full' 13 | }, 14 | { 15 | path: 'medialist', 16 | loadChildren: () => import('./medialist/medialist.module').then( m => m.MedialistPageModule) 17 | }, 18 | { 19 | path: 'player', 20 | loadChildren: () => import('./player/player.module').then( m => m.PlayerPageModule) 21 | }, 22 | { 23 | path: 'edit', 24 | loadChildren: () => import('./edit/edit.module').then( m => m.EditPageModule) 25 | }, { 26 | path: 'add', 27 | loadChildren: () => import('./add/add.module').then( m => m.AddPageModule) 28 | } 29 | 30 | ]; 31 | 32 | @NgModule({ 33 | imports: [ 34 | RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }) 35 | ], 36 | exports: [RouterModule] 37 | }) 38 | export class AppRoutingModule { } 39 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thyraz/Sonos-Kids-Controller/172f33cac5cf2563955b94928a2b0ed0230f3a8f/src/app/app.component.scss -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { TestBed, async } from '@angular/core/testing'; 3 | 4 | import { Platform } from '@ionic/angular'; 5 | import { SplashScreen } from '@ionic-native/splash-screen/ngx'; 6 | import { StatusBar } from '@ionic-native/status-bar/ngx'; 7 | 8 | import { AppComponent } from './app.component'; 9 | 10 | describe('AppComponent', () => { 11 | 12 | let statusBarSpy, splashScreenSpy, platformReadySpy, platformSpy; 13 | 14 | beforeEach(async(() => { 15 | statusBarSpy = jasmine.createSpyObj('StatusBar', ['styleDefault']); 16 | splashScreenSpy = jasmine.createSpyObj('SplashScreen', ['hide']); 17 | platformReadySpy = Promise.resolve(); 18 | platformSpy = jasmine.createSpyObj('Platform', { ready: platformReadySpy }); 19 | 20 | TestBed.configureTestingModule({ 21 | declarations: [AppComponent], 22 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 23 | providers: [ 24 | { provide: StatusBar, useValue: statusBarSpy }, 25 | { provide: SplashScreen, useValue: splashScreenSpy }, 26 | { provide: Platform, useValue: platformSpy }, 27 | ], 28 | }).compileComponents(); 29 | })); 30 | 31 | it('should create the app', () => { 32 | const fixture = TestBed.createComponent(AppComponent); 33 | const app = fixture.debugElement.componentInstance; 34 | expect(app).toBeTruthy(); 35 | }); 36 | 37 | it('should initialize the app', async () => { 38 | TestBed.createComponent(AppComponent); 39 | expect(platformSpy.ready).toHaveBeenCalled(); 40 | await platformReadySpy; 41 | expect(statusBarSpy.styleDefault).toHaveBeenCalled(); 42 | expect(splashScreenSpy.hide).toHaveBeenCalled(); 43 | }); 44 | 45 | // TODO: add more tests! 46 | 47 | }); 48 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | import { Platform } from '@ionic/angular'; 4 | import { SplashScreen } from '@ionic-native/splash-screen/ngx'; 5 | import { StatusBar } from '@ionic-native/status-bar/ngx'; 6 | 7 | @Component({ 8 | selector: 'app-root', 9 | templateUrl: 'app.component.html', 10 | styleUrls: ['app.component.scss'] 11 | }) 12 | export class AppComponent { 13 | constructor( 14 | private platform: Platform, 15 | private splashScreen: SplashScreen, 16 | private statusBar: StatusBar 17 | ) { 18 | this.initializeApp(); 19 | } 20 | 21 | initializeApp() { 22 | this.platform.ready().then(() => { 23 | this.statusBar.styleDefault(); 24 | this.splashScreen.hide(); 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { RouteReuseStrategy } from '@angular/router'; 4 | 5 | import { IonicModule, IonicRouteStrategy } from '@ionic/angular'; 6 | import { SplashScreen } from '@ionic-native/splash-screen/ngx'; 7 | import { StatusBar } from '@ionic-native/status-bar/ngx'; 8 | 9 | import { AppComponent } from './app.component'; 10 | import { AppRoutingModule } from './app-routing.module'; 11 | 12 | import { HttpClientModule, HttpClientJsonpModule } from '@angular/common/http'; 13 | import { MediaService } from './media.service'; 14 | 15 | @NgModule({ 16 | declarations: [AppComponent], 17 | entryComponents: [], 18 | imports: [ 19 | BrowserModule, 20 | IonicModule.forRoot({ 21 | mode: 'md' 22 | }), 23 | AppRoutingModule, 24 | HttpClientModule, 25 | HttpClientJsonpModule 26 | ], 27 | providers: [ 28 | MediaService, 29 | StatusBar, 30 | SplashScreen, 31 | { provide: RouteReuseStrategy, useClass: IonicRouteStrategy } 32 | ], 33 | bootstrap: [AppComponent] 34 | }) 35 | export class AppModule {} 36 | -------------------------------------------------------------------------------- /src/app/artist.ts: -------------------------------------------------------------------------------- 1 | import { Media } from './media'; 2 | 3 | export interface Artist { 4 | name: string; 5 | albumCount: string; 6 | cover: string; 7 | coverMedia: Media; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/artwork.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { ArtworkService } from './artwork.service'; 4 | 5 | describe('ArtworkService', () => { 6 | let service: ArtworkService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(ArtworkService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/artwork.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { Media } from './media'; 4 | import { SpotifyService } from './spotify.service'; 5 | 6 | @Injectable({ 7 | providedIn: 'root' 8 | }) 9 | export class ArtworkService { 10 | 11 | constructor( 12 | private spotifyService: SpotifyService 13 | ) { } 14 | 15 | getArtwork(media: Media): Observable { 16 | let artwork: Observable; 17 | 18 | if (media.type === 'spotify' && !media.cover) { 19 | artwork = this.spotifyService.getAlbumArtwork(media.artist, media.title); 20 | } else { 21 | artwork = new Observable((observer) => { 22 | observer.next(media.cover); 23 | }); 24 | } 25 | 26 | return artwork; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/edit/edit-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { EditPage } from './edit.page'; 4 | 5 | const routes: Routes = [ 6 | { 7 | path: '', 8 | component: EditPage 9 | } 10 | ]; 11 | 12 | @NgModule({ 13 | imports: [RouterModule.forChild(routes)], 14 | exports: [RouterModule], 15 | }) 16 | export class EditPageRoutingModule {} 17 | -------------------------------------------------------------------------------- /src/app/edit/edit.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { FormsModule } from '@angular/forms'; 4 | 5 | import { IonicModule } from '@ionic/angular'; 6 | 7 | import { EditPageRoutingModule } from './edit-routing.module'; 8 | 9 | import { EditPage } from './edit.page'; 10 | 11 | @NgModule({ 12 | imports: [ 13 | CommonModule, 14 | FormsModule, 15 | IonicModule, 16 | EditPageRoutingModule 17 | ], 18 | declarations: [EditPage] 19 | }) 20 | export class EditPageModule {} 21 | -------------------------------------------------------------------------------- /src/app/edit/edit.page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Edit Library 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |

Type:

23 |

Category:

24 |

Artist:

25 |

Title:

26 |

Query:

27 |

ID:

28 |

ArtistID:

29 |

Image:

30 |
31 |
32 | 33 | 34 |

{{item.category || 'audiobook'}}

35 |

{{item.type || '-'}}

36 |

{{item.artist || '-'}}

37 |

{{item.title || '-'}}

38 |

{{item.query || '-'}}

39 |

{{item.id || '-'}}

40 |

{{item.artistid || '-'}}

41 |

{{item.cover || '-'}}

42 |
43 |
44 | 45 | 46 | 47 | 48 | 49 |
50 |
51 |
52 |
53 |
-------------------------------------------------------------------------------- /src/app/edit/edit.page.scss: -------------------------------------------------------------------------------- 1 | ion-toolbar { 2 | ion-back-button { 3 | --min-height: 70px; 4 | --min-width: 120px; 5 | --icon-font-size: 30px; 6 | } 7 | ion-button { 8 | height: 70px !important; 9 | width: 70px !important; 10 | font-size: 20px !important; 11 | } 12 | } 13 | 14 | ion-item { 15 | --border-color: #2e2e2e; 16 | --inner-padding-top: 10px; 17 | --inner-padding-bottom: 10px; 18 | } 19 | 20 | ion-grid { 21 | width: 100%; 22 | } 23 | 24 | p { 25 | font-size: 18px; 26 | padding-top: 1px; 27 | padding-bottom: 1px; 28 | } 29 | 30 | .transparent { 31 | opacity: 0.65; 32 | } 33 | 34 | .vertical-center-content { 35 | display: flex; 36 | align-items: center; 37 | } 38 | 39 | .round { 40 | width: 34px; 41 | height: 34px; 42 | --border-radius: 50%; 43 | --vertical-align: middle; 44 | --padding-start: 4px; 45 | --padding-end: 4px; 46 | } 47 | 48 | .larger { 49 | font-size: 20px; 50 | } -------------------------------------------------------------------------------- /src/app/edit/edit.page.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { IonicModule } from '@ionic/angular'; 3 | 4 | import { EditPage } from './edit.page'; 5 | 6 | describe('EditPage', () => { 7 | let component: EditPage; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [ EditPage ], 13 | imports: [IonicModule.forRoot()] 14 | }).compileComponents(); 15 | 16 | fixture = TestBed.createComponent(EditPage); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | })); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/edit/edit.page.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { AlertController } from '@ionic/angular'; 4 | import { MediaService } from '../media.service'; 5 | import { Media } from '../media'; 6 | 7 | @Component({ 8 | selector: 'app-edit', 9 | templateUrl: './edit.page.html', 10 | styleUrls: ['./edit.page.scss'], 11 | }) 12 | export class EditPage implements OnInit { 13 | 14 | media: Media[] = []; 15 | 16 | constructor( 17 | private mediaService: MediaService, 18 | public alertController: AlertController, 19 | private router: Router 20 | ) { } 21 | 22 | ngOnInit() { 23 | // Subscribe 24 | this.mediaService.getRawMediaObservable().subscribe(media => { 25 | this.media = media; 26 | }); 27 | 28 | // Retreive data through subscription above 29 | this.mediaService.updateRawMedia(); 30 | } 31 | 32 | async deleteButtonPressed(index: number) { 33 | const alert = await this.alertController.create({ 34 | cssClass: 'alert', 35 | header: 'Warning', 36 | message: 'Do you want to delete the selected item from your library?', 37 | buttons: [ 38 | { 39 | text: 'Ok', 40 | handler: () => { 41 | this.mediaService.deleteRawMediaAtIndex(index); 42 | } 43 | }, 44 | { 45 | text: 'Cancel' 46 | } 47 | ] 48 | }); 49 | 50 | await alert.present(); 51 | } 52 | 53 | addButtonPressed() { 54 | this.router.navigate(['/add']); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/app/home/home-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { HomePage } from './home.page'; 4 | 5 | const routes: Routes = [ 6 | { 7 | path: '', 8 | component: HomePage, 9 | } 10 | ]; 11 | 12 | @NgModule({ 13 | imports: [RouterModule.forChild(routes)], 14 | exports: [RouterModule] 15 | }) 16 | export class HomePageRoutingModule {} 17 | -------------------------------------------------------------------------------- /src/app/home/home.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { IonicModule } from '@ionic/angular'; 4 | import { FormsModule } from '@angular/forms'; 5 | import { HomePage } from './home.page'; 6 | 7 | import { HomePageRoutingModule } from './home-routing.module'; 8 | 9 | 10 | @NgModule({ 11 | imports: [ 12 | CommonModule, 13 | FormsModule, 14 | IonicModule, 15 | HomePageRoutingModule 16 | ], 17 | declarations: [HomePage] 18 | }) 19 | export class HomePageModule {} 20 | -------------------------------------------------------------------------------- /src/app/home/home.page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {{currentArtist.name}} 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | {{currentMedia.title}} 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 |
-------------------------------------------------------------------------------- /src/app/home/home.page.scss: -------------------------------------------------------------------------------- 1 | ion-toolbar { 2 | ion-button { 3 | height: 70px !important; 4 | width: 70px !important; 5 | font-size: 20px !important; 6 | } 7 | ion-icon { 8 | font-size: 28px; 9 | } 10 | } 11 | 12 | ion-card { 13 | margin: 0px auto; 14 | } 15 | 16 | .circle-card { 17 | width: 240px; 18 | height: 240px; 19 | border-radius: 120px; 20 | border: 6px solid var(--ion-color-dark); 21 | margin-top: 32px; 22 | } 23 | 24 | .title-card { 25 | margin-top: 20px; 26 | ion-card { 27 | width: 200px; 28 | height: 56px; 29 | --background: #262626; 30 | } 31 | padding-bottom: 21px; 32 | } 33 | 34 | ion-row.media-card { 35 | padding-top: 35px; 36 | padding-bottom: 42px; 37 | 38 | ion-card { 39 | width: 230px; 40 | --background: #262626; 41 | 42 | ion-card-title { 43 | padding-bottom: 5px; 44 | } 45 | } 46 | } 47 | 48 | .truncate-text { 49 | overflow: hidden; 50 | text-overflow: ellipsis; 51 | } 52 | 53 | .button-container { 54 | margin-top: -15px; 55 | ion-button { 56 | --padding-start: 50px; 57 | --padding-end: 50px; 58 | --border-radius: 6px; 59 | padding-left: 20px; 60 | padding-right: 20px; 61 | } 62 | } 63 | 64 | .swiper-slide { 65 | touch-action: none; 66 | } -------------------------------------------------------------------------------- /src/app/home/home.page.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { IonicModule } from '@ionic/angular'; 3 | 4 | import { HomePage } from './home.page'; 5 | 6 | describe('HomePage', () => { 7 | let component: HomePage; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [ HomePage ], 13 | imports: [IonicModule.forRoot()] 14 | }).compileComponents(); 15 | 16 | fixture = TestBed.createComponent(HomePage); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | })); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/home/home.page.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild } from '@angular/core'; 2 | import { IonSlides } from '@ionic/angular'; 3 | import { Router, NavigationExtras } from '@angular/router'; 4 | import { MediaService } from '../media.service'; 5 | import { ArtworkService } from '../artwork.service'; 6 | import { PlayerService } from '../player.service'; 7 | import { ActivityIndicatorService } from '../activity-indicator.service'; 8 | import { Artist } from '../artist'; 9 | import { Media } from '../media'; 10 | 11 | @Component({ 12 | selector: 'app-home', 13 | templateUrl: 'home.page.html', 14 | styleUrls: ['home.page.scss'], 15 | }) 16 | export class HomePage implements OnInit { 17 | @ViewChild('artist_slider', { static: false }) artistSlider: IonSlides; 18 | @ViewChild('media_slider', { static: false }) mediaSlider: IonSlides; 19 | 20 | category = 'audiobook'; 21 | 22 | artists: Artist[] = []; 23 | media: Media[] = []; 24 | covers = {}; 25 | activityIndicatorVisible = false; 26 | editButtonclickCount = 0; 27 | editClickTimer = 0; 28 | 29 | needsUpdate = false; 30 | 31 | slideOptions = { 32 | initialSlide: 0, 33 | slidesPerView: 3, 34 | autoplay: false, 35 | loop: false, 36 | freeMode: true, 37 | freeModeSticky: true, 38 | freeModeMomentumBounce: false, 39 | freeModeMomentumRatio: 1.0, 40 | freeModeMomentumVelocityRatio: 1.0 41 | }; 42 | 43 | constructor( 44 | private mediaService: MediaService, 45 | private artworkService: ArtworkService, 46 | private playerService: PlayerService, 47 | private activityIndicatorService: ActivityIndicatorService, 48 | private router: Router 49 | ) {} 50 | 51 | ngOnInit() { 52 | this.mediaService.setCategory('audiobook'); 53 | 54 | // Subscribe 55 | this.mediaService.getMedia().subscribe(media => { 56 | this.media = media; 57 | 58 | this.media.forEach(currentMedia => { 59 | this.artworkService.getArtwork(currentMedia).subscribe(url => { 60 | this.covers[currentMedia.title] = url; 61 | }); 62 | }); 63 | this.mediaSlider?.update(); 64 | 65 | // Workaround as the scrollbar handle isn't visible after the immediate update 66 | // Seems like a size calculation issue, as resizing the browser window helps 67 | // Better fix for this? 68 | window.setTimeout(() => { 69 | this.mediaSlider?.update(); 70 | }, 1000); 71 | }); 72 | 73 | this.mediaService.getArtists().subscribe(artists => { 74 | this.artists = artists; 75 | 76 | this.artists.forEach(artist => { 77 | this.artworkService.getArtwork(artist.coverMedia).subscribe(url => { 78 | this.covers[artist.name] = url; 79 | }); 80 | }); 81 | this.artistSlider?.update(); 82 | 83 | // Workaround as the scrollbar handle isn't visible after the immediate update 84 | // Seems like a size calculation issue, as resizing the browser window helps 85 | // Better fix for this? 86 | window.setTimeout(() => { 87 | this.artistSlider?.update(); 88 | }, 1000); 89 | }); 90 | 91 | this.update(); 92 | } 93 | 94 | ionViewWillEnter() { 95 | if (this.needsUpdate) { 96 | this.update(); 97 | } 98 | } 99 | 100 | ionViewDidLeave() { 101 | if (this.activityIndicatorVisible) { 102 | this.activityIndicatorService.dismiss(); 103 | this.activityIndicatorVisible = false; 104 | } 105 | } 106 | 107 | categoryChanged(event: any) { 108 | this.category = event.detail.value; 109 | this.mediaService.setCategory(this.category); 110 | this.update(); 111 | } 112 | 113 | update() { 114 | if (this.category === 'audiobook' || this.category === 'music') { 115 | this.mediaService.publishArtists(); 116 | } else { 117 | this.mediaService.publishMedia(); 118 | } 119 | this.needsUpdate = false; 120 | } 121 | 122 | artistCoverClicked(clickedArtist: Artist) { 123 | this.activityIndicatorService.create().then(indicator => { 124 | this.activityIndicatorVisible = true; 125 | indicator.present().then(() => { 126 | const navigationExtras: NavigationExtras = { 127 | state: { 128 | artist: clickedArtist 129 | } 130 | }; 131 | this.router.navigate(['/medialist'], navigationExtras); 132 | }); 133 | }); 134 | } 135 | 136 | artistNameClicked(clickedArtist: Artist) { 137 | this.playerService.getConfig().subscribe(config => { 138 | if (config.tts == null || config.tts.enabled === true) { 139 | this.playerService.say(clickedArtist.name); 140 | } 141 | }); 142 | } 143 | 144 | mediaCoverClicked(clickedMedia: Media) { 145 | const navigationExtras: NavigationExtras = { 146 | state: { 147 | media: clickedMedia 148 | } 149 | }; 150 | this.router.navigate(['/player'], navigationExtras); 151 | } 152 | 153 | mediaNameClicked(clickedMedia: Media) { 154 | this.playerService.getConfig().subscribe(config => { 155 | if (config.tts == null || config.tts.enabled === true) { 156 | this.playerService.say(clickedMedia.title); 157 | } 158 | }); 159 | } 160 | 161 | editButtonPressed() { 162 | window.clearTimeout(this.editClickTimer); 163 | 164 | if (this.editButtonclickCount < 9) { 165 | this.editButtonclickCount++; 166 | 167 | this.editClickTimer = window.setTimeout(() => { 168 | this.editButtonclickCount = 0; 169 | }, 500); 170 | } else { 171 | this.router.navigate(['/edit']); 172 | this.editButtonclickCount = 0; 173 | this.needsUpdate = true; 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/app/media.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { MediaService } from './media.service'; 4 | 5 | describe('MediaService', () => { 6 | let service: MediaService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(MediaService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/media.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable, from, of, iif, Subject } from 'rxjs'; 4 | import { map, mergeMap, tap, toArray, mergeAll } from 'rxjs/operators'; 5 | import { environment } from '../environments/environment'; 6 | import { SpotifyService } from './spotify.service'; 7 | import { Media } from './media'; 8 | import { Artist } from './artist'; 9 | 10 | @Injectable({ 11 | providedIn: 'root' 12 | }) 13 | export class MediaService { 14 | 15 | private category = 'audiobook'; 16 | 17 | private rawMediaSubject = new Subject(); 18 | 19 | private artistSubject = new Subject(); 20 | private mediaSubject = new Subject(); 21 | private artistMediaSubject = new Subject(); 22 | 23 | constructor( 24 | private http: HttpClient, 25 | private spotifyService: SpotifyService, 26 | ) { } 27 | 28 | // -------------------------------------------- 29 | // Handling of RAW media entries from data.json 30 | // -------------------------------------------- 31 | 32 | getRawMediaObservable() { 33 | return this.rawMediaSubject; 34 | } 35 | 36 | updateRawMedia() { 37 | const url = (environment.production) ? '../api/data' : 'http://localhost:8200/api/data'; 38 | this.http.get(url).subscribe(media => { 39 | this.rawMediaSubject.next(media); 40 | }); 41 | } 42 | 43 | deleteRawMediaAtIndex(index: number) { 44 | const url = (environment.production) ? '../api/delete' : 'http://localhost:8200/api/delete'; 45 | const body = { 46 | index 47 | }; 48 | 49 | this.http.post(url, body).subscribe(response => { 50 | this.updateRawMedia(); 51 | }); 52 | } 53 | 54 | addRawMedia(media: Media) { 55 | const url = (environment.production) ? '../api/add' : 'http://localhost:8200/api/add'; 56 | 57 | this.http.post(url, media).subscribe(response => { 58 | this.updateRawMedia(); 59 | }); 60 | } 61 | 62 | // Get the media data for the current category from the server 63 | private updateMedia() { 64 | const url = (environment.production) ? '../api/data' : 'http://localhost:8200/api/data'; 65 | 66 | return this.http.get(url).pipe( 67 | map(items => { // Filter to get only items for the chosen category 68 | items.forEach(item => item.category = (item.category === undefined) ? 'audiobook' : item.category); // default category 69 | items = items.filter(item => item.category === this.category); 70 | return items; 71 | }), 72 | mergeMap(items => from(items)), // parallel calls for each item 73 | map((item) => // get media for the current item 74 | iif( 75 | () => (item.query && item.query.length > 0) ? true : false, // Get media by query 76 | this.spotifyService.getMediaByQuery(item.query, item.category).pipe( 77 | map(items => { // If the user entered an user-defined artist name in addition to a query, overwrite orignal artist from spotify 78 | if (item.artist?.length > 0) { 79 | items.forEach(currentItem => { 80 | currentItem.artist = item.artist; 81 | }); 82 | } 83 | return items; 84 | }) 85 | ), 86 | iif( 87 | () => (item.artistid && item.artistid.length > 0) ? true : false, // Get media by artist 88 | this.spotifyService.getMediaByArtistID(item.artistid, item.category).pipe( 89 | map(items => { // If the user entered an user-defined artist name in addition to a query, overwrite orignal artist from spotify 90 | if (item.artist?.length > 0) { 91 | items.forEach(currentItem => { 92 | currentItem.artist = item.artist; 93 | }); 94 | } 95 | return items; 96 | }) 97 | ), 98 | iif( 99 | () => (item.type === 'spotify' && item.id && item.id.length > 0) ? true : false, // Get media by album 100 | this.spotifyService.getMediaByID(item.id, item.category).pipe( 101 | map(currentItem => { // If the user entered an user-defined artist or album name, overwrite values from spotify 102 | if (item.artist?.length > 0) { 103 | currentItem.artist = item.artist; 104 | } 105 | if (item.title?.length > 0) { 106 | currentItem.title = item.title; 107 | } 108 | return [currentItem]; 109 | }) 110 | ), 111 | of([item]) // Single album. Also return as array, so we always have the same data type 112 | ) 113 | ) 114 | ) 115 | ), 116 | mergeMap(items => from(items)), // seperate arrays to single observables 117 | mergeAll(), // merge everything together 118 | toArray(), // convert to array 119 | map(media => { // add dummy image for missing covers 120 | return media.map(currentMedia => { 121 | if (!currentMedia.cover) { 122 | currentMedia.cover = '../assets/images/nocover.png'; 123 | } 124 | return currentMedia; 125 | }); 126 | }) 127 | ); 128 | } 129 | 130 | publishArtists() { 131 | this.updateMedia().subscribe(media => { 132 | this.artistSubject.next(media); 133 | }); 134 | } 135 | 136 | publishMedia() { 137 | this.updateMedia().subscribe(media => { 138 | this.mediaSubject.next(media); 139 | }); 140 | } 141 | 142 | publishArtistMedia() { 143 | this.updateMedia().subscribe(media => { 144 | this.artistMediaSubject.next(media); 145 | }); 146 | } 147 | 148 | // Get all artists for the current category 149 | getArtists(): Observable { 150 | return this.artistSubject.pipe( 151 | map((media: Media[]) => { 152 | // Create temporary object with artists as keys and albumCounts as values 153 | const mediaCounts = media.reduce((tempCounts, currentMedia) => { 154 | tempCounts[currentMedia.artist] = (tempCounts[currentMedia.artist] || 0) + 1; 155 | return tempCounts; 156 | }, {}); 157 | 158 | // Create temporary object with artists as keys and covers (first media cover) as values 159 | const covers = media.sort((a, b) => a.title <= b.title ? -1 : 1).reduce((tempCovers, currentMedia) => { 160 | if (!tempCovers[currentMedia.artist]) { tempCovers[currentMedia.artist] = currentMedia.cover; } 161 | return tempCovers; 162 | }, {}); 163 | 164 | // Create temporary object with artists as keys and first media as values 165 | const coverMedia = media.sort((a, b) => a.title <= b.title ? -1 : 1).reduce((tempMedia, currentMedia) => { 166 | if (!tempMedia[currentMedia.artist]) { tempMedia[currentMedia.artist] = currentMedia; } 167 | return tempMedia; 168 | }, {}); 169 | 170 | // Build Array of Artist objects sorted by Artist name 171 | const artists: Artist[] = Object.keys(mediaCounts).sort().map(currentName => { 172 | const artist: Artist = { 173 | name: currentName, 174 | albumCount: mediaCounts[currentName], 175 | cover: covers[currentName], 176 | coverMedia: coverMedia[currentName] 177 | }; 178 | return artist; 179 | }); 180 | 181 | return artists; 182 | }) 183 | ); 184 | } 185 | 186 | // Collect albums from a given artist in the current category 187 | getMediaFromArtist(artist: Artist): Observable { 188 | return this.artistMediaSubject.pipe( 189 | map((media: Media[]) => { 190 | return media 191 | .filter(currentMedia => currentMedia.artist === artist.name) 192 | .sort((a, b) => a.title.localeCompare(b.title, undefined, { 193 | numeric: true, 194 | sensitivity: 'base' 195 | })); 196 | }) 197 | ); 198 | } 199 | 200 | // Get all media entries for the current category 201 | getMedia(): Observable { 202 | return this.mediaSubject.pipe( 203 | map((media: Media[]) => { 204 | return media 205 | .sort((a, b) => a.title.localeCompare(b.title, undefined, { 206 | numeric: true, 207 | sensitivity: 'base' 208 | })); 209 | }) 210 | ); 211 | } 212 | 213 | // Choose which media category should be displayed in the app 214 | setCategory(category: string) { 215 | this.category = category; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/app/media.ts: -------------------------------------------------------------------------------- 1 | export interface Media { 2 | artist?: string; 3 | title?: string; 4 | query?: string; 5 | id?: string; 6 | artistid?: string; 7 | cover?: string; 8 | type: string; 9 | category: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/medialist/medialist-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | 4 | import { MedialistPage } from './medialist.page'; 5 | 6 | const routes: Routes = [ 7 | { 8 | path: '', 9 | component: MedialistPage 10 | } 11 | ]; 12 | 13 | @NgModule({ 14 | imports: [RouterModule.forChild(routes)], 15 | exports: [RouterModule], 16 | }) 17 | export class MedialistPageRoutingModule {} 18 | -------------------------------------------------------------------------------- /src/app/medialist/medialist.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { FormsModule } from '@angular/forms'; 4 | 5 | import { IonicModule } from '@ionic/angular'; 6 | 7 | import { MedialistPageRoutingModule } from './medialist-routing.module'; 8 | 9 | import { MedialistPage } from './medialist.page'; 10 | 11 | @NgModule({ 12 | imports: [ 13 | CommonModule, 14 | FormsModule, 15 | IonicModule, 16 | MedialistPageRoutingModule 17 | ], 18 | declarations: [MedialistPage] 19 | }) 20 | export class MedialistPageModule {} 21 | -------------------------------------------------------------------------------- /src/app/medialist/medialist.page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{artist.name}} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {{currentMedia.title}} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/app/medialist/medialist.page.scss: -------------------------------------------------------------------------------- 1 | ion-back-button { 2 | --min-height: 70px; 3 | --min-width: 120px; 4 | --icon-font-size: 30px; 5 | } 6 | 7 | ion-card { 8 | width: 230px; 9 | margin: 0 auto !important; 10 | --background: #262626; 11 | } 12 | 13 | ion-card-title { 14 | padding-bottom: 5px; 15 | } 16 | 17 | ion-row.media-card { 18 | padding-top: 35px; 19 | padding-bottom: 42px; 20 | } 21 | 22 | .truncate-text { 23 | overflow: hidden; 24 | text-overflow: ellipsis; 25 | } 26 | 27 | .button-container { 28 | margin-top: -15px; 29 | ion-button { 30 | --padding-start: 50px; 31 | --padding-end: 50px; 32 | --border-radius: 6px; 33 | padding-left: 20px; 34 | padding-right: 20px; 35 | } 36 | } 37 | 38 | .swiper-slide { 39 | touch-action: none; 40 | } -------------------------------------------------------------------------------- /src/app/medialist/medialist.page.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { IonicModule } from '@ionic/angular'; 3 | 4 | import { MedialistPage } from './medialist.page'; 5 | 6 | describe('MedialistPage', () => { 7 | let component: MedialistPage; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [ MedialistPage ], 13 | imports: [IonicModule.forRoot()] 14 | }).compileComponents(); 15 | 16 | fixture = TestBed.createComponent(MedialistPage); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | })); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/medialist/medialist.page.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild } from '@angular/core'; 2 | import { IonSlides } from '@ionic/angular'; 3 | import { ActivatedRoute, Router, NavigationExtras } from '@angular/router'; 4 | import { MediaService } from '../media.service'; 5 | import { ArtworkService } from '../artwork.service'; 6 | import { PlayerService } from '../player.service'; 7 | import { Media } from '../media'; 8 | import { Artist } from '../artist'; 9 | 10 | @Component({ 11 | selector: 'app-medialist', 12 | templateUrl: './medialist.page.html', 13 | styleUrls: ['./medialist.page.scss'], 14 | }) 15 | export class MedialistPage implements OnInit { 16 | @ViewChild('slider', { static: false }) slider: IonSlides; 17 | 18 | artist: Artist; 19 | media: Media[] = []; 20 | covers = {}; 21 | 22 | slideOptions = { 23 | initialSlide: 0, 24 | slidesPerView: 3, 25 | autoplay: false, 26 | loop: false, 27 | freeMode: true, 28 | freeModeSticky: true, 29 | freeModeMomentumBounce: false, 30 | freeModeMomentumRatio: 1.0, 31 | freeModeMomentumVelocityRatio: 1.0 32 | }; 33 | 34 | constructor( 35 | private route: ActivatedRoute, 36 | private router: Router, 37 | private mediaService: MediaService, 38 | private artworkService: ArtworkService, 39 | private playerService: PlayerService 40 | ) { 41 | this.route.queryParams.subscribe(params => { 42 | if (this.router.getCurrentNavigation().extras.state) { 43 | this.artist = this.router.getCurrentNavigation().extras.state.artist; 44 | } 45 | }); 46 | } 47 | 48 | ngOnInit() { 49 | // Subscribe 50 | this.mediaService.getMediaFromArtist(this.artist).subscribe(media => { 51 | this.media = media; 52 | 53 | this.media.forEach(currentMedia => { 54 | this.artworkService.getArtwork(currentMedia).subscribe(url => { 55 | this.covers[currentMedia.title] = url; 56 | }); 57 | }); 58 | this.slider.update(); 59 | 60 | // Workaround as the scrollbar handle isn't visible after the immediate update 61 | // Seems like a size calculation issue, as resizing the browser window helps 62 | // Better fix for this? 63 | window.setTimeout(() => { 64 | this.slider.update(); 65 | }, 1000); 66 | }); 67 | 68 | // Retreive data through subscription above 69 | this.mediaService.publishArtistMedia(); 70 | } 71 | 72 | coverClicked(clickedMedia: Media) { 73 | const navigationExtras: NavigationExtras = { 74 | state: { 75 | media: clickedMedia 76 | } 77 | }; 78 | this.router.navigate(['/player'], navigationExtras); 79 | } 80 | 81 | mediaNameClicked(clickedMedia: Media) { 82 | this.playerService.getConfig().subscribe(config => { 83 | if (config.tts == null || config.tts.enabled === true) { 84 | this.playerService.say(clickedMedia.title); 85 | } 86 | }); 87 | } 88 | 89 | slideDidChange() { 90 | // console{}.log('Slide did change'); 91 | } 92 | 93 | slidePrev() { 94 | this.slider.slidePrev(); 95 | } 96 | 97 | slideNext() { 98 | this.slider.slideNext(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/app/player.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { PlayerService } from './player.service'; 4 | 5 | describe('PlayerService', () => { 6 | let service: PlayerService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(PlayerService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/player.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Media } from './media'; 4 | import { SonosApiConfig } from './sonos-api'; 5 | import { environment } from '../environments/environment'; 6 | import { Observable } from 'rxjs'; 7 | import { publishReplay, refCount } from 'rxjs/operators'; 8 | 9 | export enum PlayerCmds { 10 | PLAY = 'play', 11 | PAUSE = 'pause', 12 | PLAYPAUSE = 'playpause', 13 | PREVIOUS = 'previous', 14 | NEXT = 'next', 15 | VOLUMEUP = 'volume/+5', 16 | VOLUMEDOWN = 'volume/-5', 17 | CLEARQUEUE = 'clearqueue' 18 | } 19 | 20 | @Injectable({ 21 | providedIn: 'root' 22 | }) 23 | export class PlayerService { 24 | 25 | private config: Observable = null; 26 | 27 | constructor(private http: HttpClient) {} 28 | 29 | getConfig() { 30 | // Observable with caching: 31 | // publishReplay(1) tells rxjs to cache the last response of the request 32 | // refCount() keeps the observable alive until all subscribers unsubscribed 33 | if (!this.config) { 34 | const url = (environment.production) ? '../api/sonos' : 'http://localhost:8200/api/sonos'; 35 | 36 | this.config = this.http.get(url).pipe( 37 | publishReplay(1), // cache result 38 | refCount() 39 | ); 40 | } 41 | 42 | return this.config; 43 | } 44 | 45 | getState() { 46 | this.sendRequest('state'); 47 | } 48 | 49 | sendCmd(cmd: PlayerCmds) { 50 | this.sendRequest(cmd); 51 | } 52 | 53 | playMedia(media: Media) { 54 | let url: string; 55 | 56 | switch (media.type) { 57 | case 'applemusic': { 58 | if (media.category === 'playlist') { 59 | url = 'applemusic/now/playlist:' + encodeURIComponent(media.id); 60 | } else { 61 | url = 'applemusic/now/album:' + encodeURIComponent(media.id); 62 | } 63 | break; 64 | } 65 | case 'amazonmusic': { 66 | if (media.category === 'playlist') { 67 | url = 'amazonmusic/now/playlist:' + encodeURIComponent(media.id); 68 | } else { 69 | url = 'amazonmusic/now/album:' + encodeURIComponent(media.id); 70 | } 71 | break; 72 | } 73 | case 'library': { 74 | if (!media.id) { 75 | media.id = media.title; 76 | } 77 | if (media.category === 'playlist') { 78 | url = 'playlist/' + encodeURIComponent(media.id); 79 | } else { 80 | url = 'musicsearch/library/album/' + encodeURIComponent(media.id); 81 | } 82 | break; 83 | } 84 | case 'spotify': { 85 | if (media.category === 'playlist') { 86 | url = 'spotify/now/spotify:user:spotify:playlist:' + encodeURIComponent(media.id); 87 | } else { 88 | if (media.id) { 89 | url = 'spotify/now/spotify:album:' + encodeURIComponent(media.id); 90 | } else { 91 | url = 'musicsearch/spotify/album/artist:"' + encodeURIComponent(media.artist) + '" album:"' + encodeURIComponent(media.title) + '"'; 92 | } 93 | } 94 | break; 95 | } 96 | case 'tunein': { 97 | url = 'tunein/play/' + encodeURIComponent(media.id); 98 | break; 99 | } 100 | } 101 | 102 | this.sendRequest(url); 103 | } 104 | 105 | say(text: string) { 106 | this.getConfig().subscribe(config => { 107 | let url = 'say/' + encodeURIComponent(text) + '/' + ((config.tts?.language?.length > 0) ? config.tts.language : 'de-de'); 108 | 109 | if (config.tts?.volume?.length > 0) { 110 | url += '/' + config.tts.volume; 111 | } 112 | 113 | this.sendRequest(url); 114 | }); 115 | } 116 | 117 | private sendRequest(url: string) { 118 | this.getConfig().subscribe(config => { 119 | const baseUrl = 'http://' + config.server + ':' + config.port + '/' + config.rooms[0] + '/'; 120 | this.http.get(baseUrl + url).subscribe(); 121 | }); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/app/player/player-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | 4 | import { PlayerPage } from './player.page'; 5 | 6 | const routes: Routes = [ 7 | { 8 | path: '', 9 | component: PlayerPage 10 | } 11 | ]; 12 | 13 | @NgModule({ 14 | imports: [RouterModule.forChild(routes)], 15 | exports: [RouterModule], 16 | }) 17 | export class PlayerPageRoutingModule {} 18 | -------------------------------------------------------------------------------- /src/app/player/player.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { FormsModule } from '@angular/forms'; 4 | 5 | import { IonicModule } from '@ionic/angular'; 6 | 7 | import { PlayerPageRoutingModule } from './player-routing.module'; 8 | 9 | import { PlayerPage } from './player.page'; 10 | 11 | @NgModule({ 12 | imports: [ 13 | CommonModule, 14 | FormsModule, 15 | IonicModule, 16 | PlayerPageRoutingModule 17 | ], 18 | declarations: [PlayerPage] 19 | }) 20 | export class PlayerPageModule {} 21 | -------------------------------------------------------------------------------- /src/app/player/player.page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{media.title}} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/app/player/player.page.scss: -------------------------------------------------------------------------------- 1 | ion-back-button { 2 | --min-height: 70px; 3 | --min-width: 120px; 4 | --icon-font-size: 30px; 5 | } 6 | 7 | .cover-card { 8 | margin-top: 20px; 9 | width: 350px; 10 | height: 350px; 11 | margin-left: auto !important; 12 | margin-right: auto !important; 13 | } 14 | 15 | .play-controls { 16 | margin-top: 90px; 17 | } 18 | 19 | .indicator-button { 20 | opacity: 0.35; 21 | pointer-events: none; 22 | } 23 | 24 | .volume-controls { 25 | margin-top: 80px; 26 | } 27 | 28 | .truncate-text { 29 | overflow: hidden; 30 | text-overflow: ellipsis; 31 | } 32 | 33 | .small-icon { 34 | font-size: 30px !important; 35 | } 36 | 37 | ion-button { 38 | --padding-start: 35px; 39 | --padding-end: 35px; 40 | --padding-top: 35px; 41 | --padding-bottom: 35px; 42 | font-size: 30px !important; 43 | } 44 | 45 | .volume-button { 46 | --background: #262626; 47 | } -------------------------------------------------------------------------------- /src/app/player/player.page.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { IonicModule } from '@ionic/angular'; 3 | 4 | import { PlayerPage } from './player.page'; 5 | 6 | describe('PlayerPage', () => { 7 | let component: PlayerPage; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [ PlayerPage ], 13 | imports: [IonicModule.forRoot()] 14 | }).compileComponents(); 15 | 16 | fixture = TestBed.createComponent(PlayerPage); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | })); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/player/player.page.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute, Router } from '@angular/router'; 3 | 4 | import { ArtworkService } from '../artwork.service'; 5 | import { PlayerService, PlayerCmds } from '../player.service'; 6 | import { Media } from '../media'; 7 | 8 | @Component({ 9 | selector: 'app-player', 10 | templateUrl: './player.page.html', 11 | styleUrls: ['./player.page.scss'], 12 | }) 13 | export class PlayerPage implements OnInit { 14 | 15 | media: Media; 16 | cover = ''; 17 | playing = true; 18 | 19 | constructor( 20 | private route: ActivatedRoute, 21 | private router: Router, 22 | private artworkService: ArtworkService, 23 | private playerService: PlayerService 24 | ) { 25 | this.route.queryParams.subscribe(params => { 26 | if (this.router.getCurrentNavigation().extras.state) { 27 | this.media = this.router.getCurrentNavigation().extras.state.media; 28 | } 29 | }); 30 | } 31 | 32 | ngOnInit() { 33 | this.artworkService.getArtwork(this.media).subscribe(url => { 34 | this.cover = url; 35 | }); 36 | } 37 | 38 | ionViewWillEnter() { 39 | if (this.media) { 40 | this.playerService.sendCmd(PlayerCmds.CLEARQUEUE); 41 | 42 | window.setTimeout(() => { 43 | this.playerService.playMedia(this.media); 44 | }, 1000); 45 | } 46 | } 47 | 48 | ionViewWillLeave() { 49 | this.playerService.sendCmd(PlayerCmds.PAUSE); 50 | } 51 | 52 | volUp() { 53 | this.playerService.sendCmd(PlayerCmds.VOLUMEUP); 54 | } 55 | 56 | volDown() { 57 | this.playerService.sendCmd(PlayerCmds.VOLUMEDOWN); 58 | } 59 | 60 | skipPrev() { 61 | this.playerService.sendCmd(PlayerCmds.PREVIOUS); 62 | } 63 | 64 | skipNext() { 65 | this.playerService.sendCmd(PlayerCmds.NEXT); 66 | } 67 | 68 | playPause() { 69 | if (this.playing) { 70 | this.playing = false; 71 | this.playerService.sendCmd(PlayerCmds.PAUSE); 72 | } else { 73 | this.playing = true; 74 | this.playerService.sendCmd(PlayerCmds.PLAY); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/app/sonos-api.ts: -------------------------------------------------------------------------------- 1 | export interface SonosApiConfig { 2 | server: string; 3 | port: string; 4 | rooms: string[]; 5 | tts?: { 6 | enabled?: boolean; 7 | language?: string; 8 | volume?: string; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/spotify.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { SpotifyService } from './spotify.service'; 4 | 5 | describe('SpotifyService', () => { 6 | let service: SpotifyService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(SpotifyService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/spotify.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable, defer, throwError, of, range } from 'rxjs'; 3 | import { retryWhen, flatMap, tap, delay, take, map, mergeMap, mergeAll, toArray } from 'rxjs/operators'; 4 | import { environment } from 'src/environments/environment'; 5 | import { HttpClient } from '@angular/common/http'; 6 | import { SpotifyAlbumsResponse, SpotifyAlbumsResponseItem, SpotifyArtistsAlbumsResponse } from './spotify'; 7 | import { Media } from './media'; 8 | 9 | declare const require: any; 10 | 11 | @Injectable({ 12 | providedIn: 'root' 13 | }) 14 | export class SpotifyService { 15 | 16 | spotifyApi: any; 17 | refreshingToken = false; 18 | 19 | constructor(private http: HttpClient) { 20 | const SpotifyWebApi = require('../../src/app/spotify-web-api.js'); 21 | this.spotifyApi = new SpotifyWebApi(); 22 | } 23 | 24 | getMediaByQuery(query: string, category: string): Observable { 25 | const albums = defer(() => this.spotifyApi.searchAlbums(query, { limit: 1, offset: 0, market: 'DE' })).pipe( 26 | retryWhen(errors => { 27 | return this.errorHandler(errors); 28 | }), 29 | map((response: SpotifyAlbumsResponse) => response.albums.total), 30 | mergeMap(count => range(0, Math.ceil(count / 50))), 31 | mergeMap(multiplier => defer(() => this.spotifyApi.searchAlbums(query, { limit: 50, offset: 50 * multiplier, market: 'DE' })).pipe( 32 | retryWhen(errors => { 33 | return this.errorHandler(errors); 34 | }), 35 | map((response: SpotifyAlbumsResponse) => { 36 | return response.albums.items.map(item => { 37 | const media: Media = { 38 | id: item.id, 39 | artist: item.artists[0].name, 40 | title: item.name, 41 | cover: item.images[0].url, 42 | type: 'spotify', 43 | category 44 | }; 45 | return media; 46 | }); 47 | }) 48 | )), 49 | mergeAll(), 50 | toArray() 51 | ); 52 | 53 | return albums; 54 | } 55 | 56 | getMediaByArtistID(id: string, category: string): Observable { 57 | const albums = defer(() => this.spotifyApi.getArtistAlbums(id, { include_groups: 'album', limit: 1, offset: 0, market: 'DE' })).pipe( 58 | retryWhen(errors => { 59 | return this.errorHandler(errors); 60 | }), 61 | map((response: SpotifyArtistsAlbumsResponse) => response.total), 62 | mergeMap(count => range(0, Math.ceil(count / 50))), 63 | mergeMap(multiplier => defer(() => this.spotifyApi.getArtistAlbums(id, { include_groups: 'album', limit: 50, offset: 50 * multiplier, market: 'DE' })).pipe( 64 | retryWhen(errors => { 65 | return this.errorHandler(errors); 66 | }), 67 | map((response: SpotifyArtistsAlbumsResponse) => { 68 | return response.items.map(item => { 69 | const media: Media = { 70 | id: item.id, 71 | artist: item.artists[0].name, 72 | title: item.name, 73 | cover: item.images[0].url, 74 | type: 'spotify', 75 | category 76 | }; 77 | return media; 78 | }); 79 | }) 80 | )), 81 | mergeAll(), 82 | toArray() 83 | ); 84 | 85 | return albums; 86 | } 87 | 88 | getMediaByID(id: string, category: string): Observable { 89 | let fetch: any; 90 | 91 | switch (category) { 92 | case 'playlist': 93 | fetch = this.spotifyApi.getPlaylist; 94 | break; 95 | default: 96 | fetch = this.spotifyApi.getAlbum; 97 | } 98 | 99 | const album = defer(() => fetch(id, { limit: 1, offset: 0, market: 'DE' })).pipe( 100 | retryWhen(errors => { 101 | return this.errorHandler(errors); 102 | }), 103 | map((response: SpotifyAlbumsResponseItem) => { 104 | const media: Media = { 105 | id: response.id, 106 | artist: response.artists?.[0]?.name, 107 | title: response.name, 108 | cover: response?.images[0]?.url, 109 | type: 'spotify', 110 | category 111 | }; 112 | return media; 113 | }) 114 | ); 115 | 116 | return album; 117 | } 118 | 119 | // Only used for single "artist + title" entries with "type: spotify" in the database. 120 | // Artwork for spotify search queries are already fetched together with the initial searchAlbums request 121 | getAlbumArtwork(artist: string, title: string): Observable { 122 | const artwork = defer(() => this.spotifyApi.searchAlbums('album:' + title + ' artist:' + artist, { market: 'DE' })).pipe( 123 | retryWhen(errors => { 124 | return this.errorHandler(errors); 125 | }), 126 | map((response: SpotifyAlbumsResponse) => { 127 | return response?.albums?.items?.[0]?.images?.[0]?.url || ''; 128 | }) 129 | ); 130 | 131 | return artwork; 132 | } 133 | 134 | refreshToken() { 135 | const tokenUrl = (environment.production) ? '../api/token' : 'http://localhost:8200/api/token'; 136 | 137 | this.http.get(tokenUrl, {responseType: 'text'}).subscribe(token => { 138 | this.spotifyApi.setAccessToken(token); 139 | this.refreshingToken = false; 140 | }); 141 | } 142 | 143 | errorHandler(errors: Observable) { 144 | return errors.pipe( 145 | flatMap((error) => (error.status !== 401 && error.status !== 429) ? throwError(error) : of(error)), 146 | tap(_ => { 147 | if (!this.refreshingToken) { 148 | this.refreshToken(); 149 | this.refreshingToken = true; 150 | } 151 | }), 152 | delay(500), 153 | take(10) 154 | ); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/app/spotify.ts: -------------------------------------------------------------------------------- 1 | export interface SpotifyAlbumsResponseImage { 2 | height: number; 3 | url: string; 4 | } 5 | 6 | export interface SpotifyAlbumsResponseArtist { 7 | name: string; 8 | } 9 | 10 | export interface SpotifyAlbumsResponseItem { 11 | images: SpotifyAlbumsResponseImage[]; 12 | name: string; 13 | id: string; 14 | artists: SpotifyAlbumsResponseArtist[]; 15 | } 16 | 17 | export interface SpotifyAlbumsResponse { 18 | albums: { 19 | total: number; 20 | items: SpotifyAlbumsResponseItem[]; 21 | }; 22 | } 23 | 24 | export interface SpotifyArtistsAlbumsResponse { 25 | total: number; 26 | items: SpotifyAlbumsResponseItem[]; 27 | } 28 | -------------------------------------------------------------------------------- /src/assets/icon/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thyraz/Sonos-Kids-Controller/172f33cac5cf2563955b94928a2b0ed0230f3a8f/src/assets/icon/favicon.png -------------------------------------------------------------------------------- /src/assets/images/nocover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thyraz/Sonos-Kids-Controller/172f33cac5cf2563955b94928a2b0ed0230f3a8f/src/assets/images/nocover.png -------------------------------------------------------------------------------- /src/assets/shapes.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/global.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * App Global CSS 3 | * ---------------------------------------------------------------------------- 4 | * Put style rules here that you want to apply globally. These styles are for 5 | * the entire app and not just one component. Additionally, this file can be 6 | * used as an entry point to import other CSS/Sass files to be included in the 7 | * output CSS. 8 | * For more information on global stylesheets, visit the documentation: 9 | * https://ionicframework.com/docs/layout/global-stylesheets 10 | */ 11 | 12 | 13 | /* Core CSS required for Ionic components to work properly */ 14 | 15 | @import "~@ionic/angular/css/core.css"; 16 | 17 | /* Basic CSS for apps built with Ionic */ 18 | 19 | @import "~@ionic/angular/css/normalize.css"; 20 | @import "~@ionic/angular/css/structure.css"; 21 | @import "~@ionic/angular/css/typography.css"; 22 | @import '~@ionic/angular/css/display.css'; 23 | 24 | /* Optional CSS utils that can be commented out */ 25 | 26 | @import "~@ionic/angular/css/padding.css"; 27 | @import "~@ionic/angular/css/float-elements.css"; 28 | @import "~@ionic/angular/css/text-alignment.css"; 29 | @import "~@ionic/angular/css/text-transformation.css"; 30 | @import "~@ionic/angular/css/flex-utils.css"; 31 | 32 | /* no text selection in firefox */ 33 | 34 | html, 35 | body, 36 | div, 37 | a, 38 | i, 39 | button, 40 | select, 41 | option, 42 | optgroup, 43 | hr, 44 | br { 45 | -webkit-touch-callout: none; 46 | -webkit-user-select: none; 47 | -khtml-user-select: none; 48 | -moz-user-select: none; 49 | -ms-user-select: none; 50 | user-select: none; 51 | cursor: default; 52 | } 53 | 54 | .slides-md { 55 | --bullet-background: #ffffff; 56 | } 57 | 58 | ion-toolbar { 59 | --min-height: 70px; 60 | } 61 | 62 | .alert { 63 | --backdrop-opacity: 0.5; 64 | --background: #2c2c2c; 65 | --width: 400px; 66 | --min-width: 400px; 67 | --max-width: 400px; 68 | } 69 | 70 | .alert-button { 71 | font-size: 18px !important; 72 | } 73 | 74 | .alert-message { 75 | font-size: 18px !important; 76 | } 77 | 78 | ion-popover [popover]:not(:popover-open):not(dialog[open]) { 79 | display: contents; 80 | } 81 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Ionic App 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.log(err)); 13 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags.ts'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | import './zone-flags'; 56 | 57 | /*************************************************************************************************** 58 | * Zone JS is required by default for Angular itself. 59 | */ 60 | 61 | import 'zone.js/dist/zone'; // Included with Angular CLI. 62 | 63 | 64 | /*************************************************************************************************** 65 | * APPLICATION IMPORTS 66 | */ 67 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: any; 11 | 12 | // First, initialize the Angular testing environment. 13 | getTestBed().initTestEnvironment( 14 | BrowserDynamicTestingModule, 15 | platformBrowserDynamicTesting() 16 | ); 17 | // Then we find all the tests. 18 | const context = require.context('./', true, /\.spec\.ts$/); 19 | // And load the modules. 20 | context.keys().map(context); 21 | -------------------------------------------------------------------------------- /src/theme/variables.scss: -------------------------------------------------------------------------------- 1 | // Ionic Variables and Theming. For more info, please see: 2 | // http://ionicframework.com/docs/theming/ 3 | 4 | /** Ionic CSS Variables **/ 5 | 6 | :root { 7 | /** primary **/ 8 | --ion-color-primary: #3880ff; 9 | --ion-color-primary-rgb: 56, 128, 255; 10 | --ion-color-primary-contrast: #ffffff; 11 | --ion-color-primary-contrast-rgb: 255, 255, 255; 12 | --ion-color-primary-shade: #3171e0; 13 | --ion-color-primary-tint: #4c8dff; 14 | /** secondary **/ 15 | --ion-color-secondary: #3dc2ff; 16 | --ion-color-secondary-rgb: 61, 194, 255; 17 | --ion-color-secondary-contrast: #ffffff; 18 | --ion-color-secondary-contrast-rgb: 255, 255, 255; 19 | --ion-color-secondary-shade: #36abe0; 20 | --ion-color-secondary-tint: #50c8ff; 21 | /** tertiary **/ 22 | --ion-color-tertiary: #5260ff; 23 | --ion-color-tertiary-rgb: 82, 96, 255; 24 | --ion-color-tertiary-contrast: #ffffff; 25 | --ion-color-tertiary-contrast-rgb: 255, 255, 255; 26 | --ion-color-tertiary-shade: #4854e0; 27 | --ion-color-tertiary-tint: #6370ff; 28 | /** success **/ 29 | --ion-color-success: #2dd36f; 30 | --ion-color-success-rgb: 45, 211, 111; 31 | --ion-color-success-contrast: #ffffff; 32 | --ion-color-success-contrast-rgb: 255, 255, 255; 33 | --ion-color-success-shade: #28ba62; 34 | --ion-color-success-tint: #42d77d; 35 | /** warning **/ 36 | --ion-color-warning: #ffc409; 37 | --ion-color-warning-rgb: 255, 196, 9; 38 | --ion-color-warning-contrast: #000000; 39 | --ion-color-warning-contrast-rgb: 0, 0, 0; 40 | --ion-color-warning-shade: #e0ac08; 41 | --ion-color-warning-tint: #ffca22; 42 | /** danger **/ 43 | --ion-color-danger: #eb445a; 44 | --ion-color-danger-rgb: 235, 68, 90; 45 | --ion-color-danger-contrast: #ffffff; 46 | --ion-color-danger-contrast-rgb: 255, 255, 255; 47 | --ion-color-danger-shade: #cf3c4f; 48 | --ion-color-danger-tint: #ed576b; 49 | /** dark **/ 50 | --ion-color-dark: #222428; 51 | --ion-color-dark-rgb: 34, 36, 40; 52 | --ion-color-dark-contrast: #ffffff; 53 | --ion-color-dark-contrast-rgb: 255, 255, 255; 54 | --ion-color-dark-shade: #1e2023; 55 | --ion-color-dark-tint: #383a3e; 56 | /** medium **/ 57 | --ion-color-medium: #92949c; 58 | --ion-color-medium-rgb: 146, 148, 156; 59 | --ion-color-medium-contrast: #ffffff; 60 | --ion-color-medium-contrast-rgb: 255, 255, 255; 61 | --ion-color-medium-shade: #808289; 62 | --ion-color-medium-tint: #9d9fa6; 63 | /** light **/ 64 | --ion-color-light: #f4f5f8; 65 | --ion-color-light-rgb: 244, 245, 248; 66 | --ion-color-light-contrast: #000000; 67 | --ion-color-light-contrast-rgb: 0, 0, 0; 68 | --ion-color-light-shade: #d7d8da; 69 | --ion-color-light-tint: #f5f6f9; 70 | } 71 | 72 | // @media (prefers-color-scheme: dark) { 73 | // /* 74 | // * Dark Colors 75 | // * ------------------------------------------- 76 | // */ 77 | body { 78 | --ion-color-primary: #428cff; 79 | --ion-color-primary-rgb: 66, 140, 255; 80 | --ion-color-primary-contrast: #ffffff; 81 | --ion-color-primary-contrast-rgb: 255, 255, 255; 82 | --ion-color-primary-shade: #3a7be0; 83 | --ion-color-primary-tint: #5598ff; 84 | --ion-color-secondary: #50c8ff; 85 | --ion-color-secondary-rgb: 80, 200, 255; 86 | --ion-color-secondary-contrast: #ffffff; 87 | --ion-color-secondary-contrast-rgb: 255, 255, 255; 88 | --ion-color-secondary-shade: #46b0e0; 89 | --ion-color-secondary-tint: #62ceff; 90 | --ion-color-tertiary: #9864ec; 91 | --ion-color-tertiary-rgb: 152, 100, 236; 92 | --ion-color-tertiary-contrast: #ffffff; 93 | --ion-color-tertiary-contrast-rgb: 255, 255, 255; 94 | --ion-color-tertiary-shade: #5d58e0; 95 | --ion-color-tertiary-tint: #7974ff; 96 | --ion-color-success: #02af81; 97 | --ion-color-success-rgb: 2, 175, 129; 98 | --ion-color-success-contrast: #ffffff; 99 | --ion-color-success-contrast-rgb: 255, 255, 255; 100 | --ion-color-success-shade: #01a277; 101 | --ion-color-success-tint: #03e2a7; 102 | --ion-color-warning: #faba0a; 103 | --ion-color-warning-rgb: 255, 208, 30; 104 | --ion-color-warning-contrast: #ffffff; 105 | --ion-color-warning-contrast-rgb: 255, 255, 255; 106 | --ion-color-warning-shade: #e0bb2e; 107 | --ion-color-warning-tint: #ffd948; 108 | --ion-color-danger: #ff314c; 109 | --ion-color-danger-rgb: 255, 73, 97; 110 | --ion-color-danger-contrast: #ffffff; 111 | --ion-color-danger-contrast-rgb: 255, 255, 255; 112 | --ion-color-danger-shade: #e04055; 113 | --ion-color-danger-tint: #ff5b71; 114 | --ion-color-dark: #f4f5f8; 115 | --ion-color-dark-rgb: 244, 245, 248; 116 | --ion-color-dark-contrast: #000000; 117 | --ion-color-dark-contrast-rgb: 0, 0, 0; 118 | --ion-color-dark-shade: #d7d8da; 119 | --ion-color-dark-tint: #f5f6f9; 120 | --ion-color-medium: #989aa2; 121 | --ion-color-medium-rgb: 152, 154, 162; 122 | --ion-color-medium-contrast: #000000; 123 | --ion-color-medium-contrast-rgb: 0, 0, 0; 124 | --ion-color-medium-shade: #86888f; 125 | --ion-color-medium-tint: #a2a4ab; 126 | --ion-color-light: #2a2a2a; 127 | --ion-color-light-rgb: 42, 42, 42; 128 | --ion-color-light-contrast: #ffffff; 129 | --ion-color-light-contrast-rgb: 255, 255, 255; 130 | --ion-color-light-shade: #1e2023; 131 | --ion-color-light-tint: #383a3e; 132 | } 133 | 134 | 135 | /* 136 | * iOS Dark Theme 137 | * ------------------------------------------- 138 | */ 139 | 140 | .ios body { 141 | --ion-background-color: #000000; 142 | --ion-background-color-rgb: 0, 0, 0; 143 | --ion-text-color: #ffffff; 144 | --ion-text-color-rgb: 255, 255, 255; 145 | --ion-color-step-50: #0d0d0d; 146 | --ion-color-step-100: #1a1a1a; 147 | --ion-color-step-150: #262626; 148 | --ion-color-step-200: #333333; 149 | --ion-color-step-250: #404040; 150 | --ion-color-step-300: #4d4d4d; 151 | --ion-color-step-350: #595959; 152 | --ion-color-step-400: #666666; 153 | --ion-color-step-450: #737373; 154 | --ion-color-step-500: #808080; 155 | --ion-color-step-550: #8c8c8c; 156 | --ion-color-step-600: #999999; 157 | --ion-color-step-650: #a6a6a6; 158 | --ion-color-step-700: #b3b3b3; 159 | --ion-color-step-750: #bfbfbf; 160 | --ion-color-step-800: #cccccc; 161 | --ion-color-step-850: #d9d9d9; 162 | --ion-color-step-900: #e6e6e6; 163 | --ion-color-step-950: #f2f2f2; 164 | --ion-toolbar-background: #0d0d0d; 165 | --ion-item-background: #000000; 166 | } 167 | 168 | 169 | /* 170 | * Material Design Dark Theme 171 | * ------------------------------------------- 172 | */ 173 | 174 | .md body { 175 | --ion-background-color: #121212; 176 | --ion-background-color-rgb: 18, 18, 18; 177 | --ion-text-color: #ffffff; 178 | --ion-text-color-rgb: 255, 255, 255; 179 | --ion-border-color: #222222; 180 | --ion-color-step-50: #1e1e1e; 181 | --ion-color-step-100: #2a2a2a; 182 | --ion-color-step-150: #363636; 183 | --ion-color-step-200: #414141; 184 | --ion-color-step-250: #4d4d4d; 185 | --ion-color-step-300: #595959; 186 | --ion-color-step-350: #656565; 187 | --ion-color-step-400: #717171; 188 | --ion-color-step-450: #7d7d7d; 189 | --ion-color-step-500: #898989; 190 | --ion-color-step-550: #949494; 191 | --ion-color-step-600: #a0a0a0; 192 | --ion-color-step-650: #acacac; 193 | --ion-color-step-700: #b8b8b8; 194 | --ion-color-step-750: #c4c4c4; 195 | --ion-color-step-800: #d0d0d0; 196 | --ion-color-step-850: #dbdbdb; 197 | --ion-color-step-900: #e7e7e7; 198 | --ion-color-step-950: #f3f3f3; 199 | --ion-item-background: #1e1e1e; 200 | --ion-toolbar-background: #1f1f1f; 201 | --ion-tab-bar-background: #1f1f1f; 202 | } 203 | 204 | // } -------------------------------------------------------------------------------- /src/zone-flags.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Prevents Angular change detection from 3 | * running with certain Web Component callbacks 4 | */ 5 | (window as any).__Zone_disable_customElements = true; 6 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "src/main.ts", 9 | "src/polyfills.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.ts", 13 | "src/**/*.d.ts" 14 | ], 15 | "exclude": [ 16 | "src/**/*.spec.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "downlevelIteration": true, 9 | "experimentalDecorators": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "es2015", 14 | "lib": [ 15 | "es2018", 16 | "dom" 17 | ] 18 | }, 19 | "angularCompilerOptions": { 20 | "fullTemplateTypeCheck": true, 21 | "strictInjectionParameters": true 22 | } 23 | } -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "align": { 5 | "options": [ 6 | "parameters", 7 | "statements" 8 | ] 9 | }, 10 | "array-type": false, 11 | "arrow-return-shorthand": true, 12 | "curly": true, 13 | "deprecation": { 14 | "severity": "warning" 15 | }, 16 | "component-class-suffix": [true, "Page", "Component"], 17 | "contextual-lifecycle": true, 18 | "directive-class-suffix": true, 19 | "directive-selector": [ 20 | true, 21 | "attribute", 22 | "app", 23 | "camelCase" 24 | ], 25 | "component-selector": [ 26 | true, 27 | "element", 28 | "app", 29 | "kebab-case" 30 | ], 31 | "eofline": true, 32 | "import-blacklist": [ 33 | true, 34 | "rxjs/Rx" 35 | ], 36 | "import-spacing": true, 37 | "indent": { 38 | "options": [ 39 | "spaces" 40 | ] 41 | }, 42 | "max-classes-per-file": false, 43 | "member-ordering": [ 44 | true, 45 | { 46 | "order": [ 47 | "static-field", 48 | "instance-field", 49 | "static-method", 50 | "instance-method" 51 | ] 52 | } 53 | ], 54 | "no-console": [ 55 | true, 56 | "debug", 57 | "info", 58 | "time", 59 | "timeEnd", 60 | "trace" 61 | ], 62 | "no-empty": false, 63 | "no-inferrable-types": [ 64 | true, 65 | "ignore-params" 66 | ], 67 | "no-non-null-assertion": true, 68 | "no-redundant-jsdoc": true, 69 | "no-switch-case-fall-through": true, 70 | "no-var-requires": false, 71 | "object-literal-key-quotes": [ 72 | true, 73 | "as-needed" 74 | ], 75 | "quotemark": [ 76 | true, 77 | "single" 78 | ], 79 | "semicolon": { 80 | "options": [ 81 | "always" 82 | ] 83 | }, 84 | "space-before-function-paren": { 85 | "options": { 86 | "anonymous": "never", 87 | "asyncArrow": "always", 88 | "constructor": "never", 89 | "method": "never", 90 | "named": "never" 91 | } 92 | }, 93 | "typedef-whitespace": { 94 | "options": [ 95 | { 96 | "call-signature": "nospace", 97 | "index-signature": "nospace", 98 | "parameter": "nospace", 99 | "property-declaration": "nospace", 100 | "variable-declaration": "nospace" 101 | }, 102 | { 103 | "call-signature": "onespace", 104 | "index-signature": "onespace", 105 | "parameter": "onespace", 106 | "property-declaration": "onespace", 107 | "variable-declaration": "onespace" 108 | } 109 | ] 110 | }, 111 | "variable-name": { 112 | "options": [ 113 | "ban-keywords", 114 | "check-format", 115 | "allow-pascal-case" 116 | ] 117 | }, 118 | "whitespace": { 119 | "options": [ 120 | "check-branch", 121 | "check-decl", 122 | "check-operator", 123 | "check-separator", 124 | "check-type", 125 | "check-typecast" 126 | ] 127 | }, 128 | "no-conflicting-lifecycle": true, 129 | "no-host-metadata-property": true, 130 | "no-input-rename": true, 131 | "no-inputs-metadata-property": true, 132 | "no-output-native": true, 133 | "no-output-on-prefix": true, 134 | "no-output-rename": true, 135 | "no-outputs-metadata-property": true, 136 | "template-banana-in-box": true, 137 | "template-no-negated-async": true, 138 | "use-lifecycle-interface": true, 139 | "use-pipe-transform-interface": true, 140 | "object-literal-sort-keys": false 141 | }, 142 | "rulesDirectory": [ 143 | "codelyzer" 144 | ] 145 | } --------------------------------------------------------------------------------