more cleanup
authormarcelveldt <marcelvanderveldt@MacBook-Silvia.local>
Tue, 15 Oct 2019 22:13:47 +0000 (00:13 +0200)
committermarcelveldt <marcelvanderveldt@MacBook-Silvia.local>
Tue, 15 Oct 2019 22:13:47 +0000 (00:13 +0200)
better structured

118 files changed:
Dockerfile
main.py [deleted file]
mass.py [new file with mode: 0755]
music_assistant/__init__.py
music_assistant/cache.py
music_assistant/config.py [new file with mode: 0755]
music_assistant/constants.py
music_assistant/database.py
music_assistant/homeassistant.py
music_assistant/http_streamer.py
music_assistant/metadata.py
music_assistant/models/musicprovider.py
music_assistant/models/player.py
music_assistant/models/playerprovider.py
music_assistant/music_manager.py
music_assistant/musicproviders/file.py
music_assistant/musicproviders/qobuz.py
music_assistant/musicproviders/spotify.py
music_assistant/musicproviders/tunein.py
music_assistant/player_manager.py
music_assistant/playerproviders/chromecast.py
music_assistant/playerproviders/squeezebox.py
music_assistant/utils.py
music_assistant/web.py
music_assistant/web/components/headermenu.vue.js [new file with mode: 0755]
music_assistant/web/components/infoheader.vue.js [new file with mode: 0644]
music_assistant/web/components/listviewItem.vue.js [new file with mode: 0755]
music_assistant/web/components/player.vue.js [new file with mode: 0755]
music_assistant/web/components/playmenu.vue.js [new file with mode: 0644]
music_assistant/web/components/providericons.vue.js [new file with mode: 0644]
music_assistant/web/components/readmore.vue.js [new file with mode: 0644]
music_assistant/web/components/searchbox.vue.js [new file with mode: 0644]
music_assistant/web/components/volumecontrol.vue.js [new file with mode: 0644]
music_assistant/web/css/nprogress.css [new file with mode: 0644]
music_assistant/web/css/site.css [new file with mode: 0755]
music_assistant/web/css/vue-loading.css [new file with mode: 0644]
music_assistant/web/images/default_artist.png [new file with mode: 0644]
music_assistant/web/images/icons/aac.png [new file with mode: 0644]
music_assistant/web/images/icons/chromecast.png [new file with mode: 0644]
music_assistant/web/images/icons/file.png [new file with mode: 0644]
music_assistant/web/images/icons/flac.png [new file with mode: 0644]
music_assistant/web/images/icons/hires.png [new file with mode: 0644]
music_assistant/web/images/icons/homeassistant.png [new file with mode: 0644]
music_assistant/web/images/icons/http_streamer.png [new file with mode: 0644]
music_assistant/web/images/icons/icon-128x128.png [new file with mode: 0644]
music_assistant/web/images/icons/icon-256x256.png [new file with mode: 0644]
music_assistant/web/images/icons/icon-apple.png [new file with mode: 0644]
music_assistant/web/images/icons/info_gradient.jpg [new file with mode: 0644]
music_assistant/web/images/icons/lms.png [new file with mode: 0644]
music_assistant/web/images/icons/mp3.png [new file with mode: 0644]
music_assistant/web/images/icons/qobuz.png [new file with mode: 0644]
music_assistant/web/images/icons/spotify.png [new file with mode: 0644]
music_assistant/web/images/icons/squeezebox.png [new file with mode: 0644]
music_assistant/web/images/icons/tunein.png [new file with mode: 0644]
music_assistant/web/images/icons/vorbis.png [new file with mode: 0644]
music_assistant/web/images/icons/web.png [new file with mode: 0644]
music_assistant/web/images/info_gradient.jpg [new file with mode: 0644]
music_assistant/web/index.html [new file with mode: 0755]
music_assistant/web/lib/vue-loading-overlay.js [new file with mode: 0644]
music_assistant/web/manifest.json [new file with mode: 0755]
music_assistant/web/pages/albumdetails.vue.js [new file with mode: 0755]
music_assistant/web/pages/artistdetails.vue.js [new file with mode: 0755]
music_assistant/web/pages/browse.vue.js [new file with mode: 0755]
music_assistant/web/pages/config.vue.js [new file with mode: 0755]
music_assistant/web/pages/home.vue.js [new file with mode: 0755]
music_assistant/web/pages/playlistdetails.vue.js [new file with mode: 0755]
music_assistant/web/pages/queue.vue.js [new file with mode: 0755]
music_assistant/web/pages/search.vue.js [new file with mode: 0755]
music_assistant/web/pages/trackdetails.vue.js [new file with mode: 0755]
music_assistant/web/strings.js [new file with mode: 0644]
requirements.txt
run.sh [deleted file]
web/components/headermenu.vue.js [deleted file]
web/components/infoheader.vue.js [deleted file]
web/components/listviewItem.vue.js [deleted file]
web/components/player.vue.js [deleted file]
web/components/playmenu.vue.js [deleted file]
web/components/providericons.vue.js [deleted file]
web/components/readmore.vue.js [deleted file]
web/components/searchbox.vue.js [deleted file]
web/components/volumecontrol.vue.js [deleted file]
web/css/nprogress.css [deleted file]
web/css/site.css [deleted file]
web/css/vue-loading.css [deleted file]
web/images/default_artist.png [deleted file]
web/images/icons/aac.png [deleted file]
web/images/icons/chromecast.png [deleted file]
web/images/icons/file.png [deleted file]
web/images/icons/flac.png [deleted file]
web/images/icons/hires.png [deleted file]
web/images/icons/homeassistant.png [deleted file]
web/images/icons/http_streamer.png [deleted file]
web/images/icons/icon-128x128.png [deleted file]
web/images/icons/icon-256x256.png [deleted file]
web/images/icons/icon-apple.png [deleted file]
web/images/icons/info_gradient.jpg [deleted file]
web/images/icons/lms.png [deleted file]
web/images/icons/mp3.png [deleted file]
web/images/icons/qobuz.png [deleted file]
web/images/icons/spotify.png [deleted file]
web/images/icons/squeezebox.png [deleted file]
web/images/icons/tunein.png [deleted file]
web/images/icons/vorbis.png [deleted file]
web/images/icons/web.png [deleted file]
web/images/info_gradient.jpg [deleted file]
web/index.html [deleted file]
web/lib/vue-loading-overlay.js [deleted file]
web/manifest.json [deleted file]
web/pages/albumdetails.vue.js [deleted file]
web/pages/artistdetails.vue.js [deleted file]
web/pages/browse.vue.js [deleted file]
web/pages/config.vue.js [deleted file]
web/pages/home.vue.js [deleted file]
web/pages/playlistdetails.vue.js [deleted file]
web/pages/queue.vue.js [deleted file]
web/pages/search.vue.js [deleted file]
web/pages/trackdetails.vue.js [deleted file]
web/strings.js [deleted file]

index 924172dddd53a5e33acfbc3eac098bac5b06f431..d0b9aea1e92d871da0a6142b68e5a22f5c924648 100755 (executable)
@@ -1,26 +1,27 @@
-FROM python:3.8.0rc1-alpine3.10
+FROM python:3.7-buster
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+               flac sox zip curl wget ffmpeg libsndfile1 libtag1-dev build-essential \
+        python3-numpy python3-scipy python3-matplotlib python3-taglib \
+       && rm -rf /var/lib/apt/lists/*
 
-# install deps
-RUN apk add flac sox zip curl wget ffmpeg taglib
-# RUN apk add --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/testing py3-numpy py3-scipy py3-matplotlib py3-aiohttp py3-cairocffi
 COPY requirements.txt requirements.txt
-RUN apk --no-cache add --virtual .builddeps build-base taglib-dev && \
-    python3 -m pip install -r requirements.txt && \
-    apk del .builddeps && \
-    rm -rf /root/.cache
+RUN pip install -r requirements.txt
 
-# copy files
+# copy app files
 RUN mkdir -p /usr/src/app
 WORKDIR /usr/src/app
 COPY music_assistant /usr/src/app/music_assistant
-COPY main.py /usr/src/app/main.py
-RUN chmod a+x /usr/src/app/main.py
+COPY mass.py /usr/src/app/main.py
+RUN chmod a+x /usr/src/app/mass.py
 
 VOLUME ["/data"]
 
 COPY run.sh /run.sh
 RUN chmod +x /run.sh
 
-ENV autoupdate false
+ENV mass_debug false
+ENV mass_datadir /data
+ENV mass_update false
 
-CMD ["/run.sh"]
\ No newline at end of file
+CMD ["python3 /usr/src/app/mass.py"]
\ No newline at end of file
diff --git a/main.py b/main.py
deleted file mode 100755 (executable)
index 7cb9825..0000000
--- a/main.py
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import sys
-import os
-
-from music_assistant import MusicAssistant
-
-if __name__ == "__main__":
-
-    if len(sys.argv) > 1:
-        datapath = sys.argv[1]
-    else:
-         datapath = os.path.dirname(os.path.abspath(__file__))
-    if len(sys.argv) > 2:
-        debug = sys.argv[2] == "debug"
-    else:
-        debug = False
-
-    MusicAssistant(datapath, debug)
-    
\ No newline at end of file
diff --git a/mass.py b/mass.py
new file mode 100755 (executable)
index 0000000..5fd436d
--- /dev/null
+++ b/mass.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import sys
+import os
+import logging
+from aiorun import run
+import asyncio
+import uvloop
+
+logger = logging.getLogger()
+logformat = logging.Formatter('%(asctime)-15s %(levelname)-5s %(name)s.%(module)s -- %(message)s')
+consolehandler = logging.StreamHandler()
+consolehandler.setFormatter(logformat)
+logger.addHandler(consolehandler)
+
+
+def get_config():
+    ''' start config handling '''
+    data_dir = ''
+    debug = False
+    update_latest = False
+    # prefer command line args
+    if len(sys.argv) > 1:
+        data_dir = sys.argv[1]
+    if len(sys.argv) > 2:
+        debug = sys.argv[2] == "debug"
+    if len(sys.argv) > 3:
+        update_latest = sys.argv[3] == "update"
+    # fall back to environment variables (for plain docker)
+    if os.environ.get('mass_datadir'):
+        data_dir = os.environ['mass_datadir']
+    if os.environ.get('mass_debug'):
+        debug = os.environ['mass_datadir'].lower() != 'false'
+    if os.environ.get('mass_update'):
+        update_latest = os.environ['mass_update'].lower() != 'false'
+    # config file found
+    if os.path.isfile('options.json'):
+        try:
+            import json
+            with open('options.json') as f:
+                conf = json.loads(f.read())
+                data_dir = conf['data_dir']
+                debug = conf['debug_messages']
+                update_latest = conf['auto_update']
+        except:
+            logger.exception('could not load options.json')
+    return data_dir, debug, update_latest
+
+def do_update():
+    ''' auto update to latest git version '''
+    if os.path.isdir(".git"):
+        # dev environment
+        return
+    logger.info("Updating to latest Git version!")
+    import subprocess
+    # TODO: handle this properly
+    args = """
+        cd /tmp
+        curl -LOks "https://github.com/marcelveldt/musicassistant/archive/master.zip"
+        unzip -q master.zip
+        rm -R music_assistant
+        cp -rf musicassistant-master/music_assistant .
+        cp -rf musicassistant-master/mass.py .
+        rm -R /tmp/musicassistant-master
+    """
+    if subprocess.call(args, shell=True) == 0:
+        logger.info("Update succesfull")
+    else:
+        logger.error("Update failed - do you have curl and zip installed ?")
+
+
+if __name__ == "__main__":
+    # get config
+    data_dir, debug, update_latest = get_config()
+    if update_latest:
+        update_latest()
+    # create event_loop with uvloop
+    event_loop = asyncio.get_event_loop()
+    uvloop.install()
+    # config debug settings if needed
+    if debug:
+        event_loop.set_debug(True)
+        logger.setLevel(logging.DEBUG)
+        logging.getLogger('aiosqlite').setLevel(logging.INFO)
+        logging.getLogger('asyncio').setLevel(logging.INFO)
+    else:
+        logger.setLevel(logging.INFO)
+    # start music assistant!
+    do_update()
+    from music_assistant import MusicAssistant
+    mass = MusicAssistant(data_dir, event_loop)
+    run(mass.start(), loop=event_loop)
+    
\ No newline at end of file
index 9d08c84bd6204e8badaf679d39ecbb885c6d170b..fdfa84c6d4f4434245abe124705a572a6d477474 100644 (file)
@@ -1,10 +1,7 @@
 #!/usr/bin/env python3
 # -*- coding:utf-8 -*-
 
-import sys
 import asyncio
-from concurrent.futures import ThreadPoolExecutor
-from contextlib import suppress
 import re
 import uvloop
 import os
@@ -16,70 +13,54 @@ import time
 import logging
 
 from .database import Database
+from .config import MassConfig
 from .utils import run_periodic, LOGGER, try_parse_bool
 from .metadata import MetaData
 from .cache import Cache
-from .music_manager import Music
+from .music_manager import MusicManager
 from .player_manager import PlayerManager
 from .http_streamer import HTTPStreamer
-from .homeassistant import setup as hass_setup
-from .web import setup as web_setup
+from .homeassistant import HomeAssistant
+from .web import Web
 
-def handle_exception(loop, context):
-    # context["message"] will always be there; but context["exception"] may not
-    msg = context.get("exception", context["message"])
-    LOGGER.exception(f"Caught exception: {msg}")
 
 class MusicAssistant():
 
-    def __init__(self, datapath, debug=False):
-        debug = try_parse_bool(debug)
-        logformat = logging.Formatter('%(asctime)-15s %(levelname)-5s %(name)s.%(module)s -- %(message)s')
-        consolehandler = logging.StreamHandler()
-        consolehandler.setFormatter(logformat)
-        LOGGER.addHandler(consolehandler)
-        if debug:
-            LOGGER.setLevel(logging.DEBUG)
-            logging.getLogger('aiosqlite').setLevel(logging.INFO)
-            logging.getLogger('asyncio').setLevel(logging.INFO)
-        else:
-            LOGGER.setLevel(logging.INFO)
-        uvloop.install()
+    def __init__(self, datapath, event_loop):
+        ''' 
+            Create an instance of MusicAssistant
+            :param datapath: file location to store the data
+            :param event_loop: asyncio event_loop
+        '''
+        self.event_loop = event_loop
+        self.event_loop.set_exception_handler(self.handle_exception)
         self.datapath = datapath
-        self.parse_config()
-        self.event_loop = asyncio.get_event_loop()
-        self.event_loop.set_debug(debug)
-        self.bg_executor = ThreadPoolExecutor() 
-        self.event_loop.set_default_executor(self.bg_executor)
-        #self.event_loop.set_exception_handler(handle_exception)
         self.event_listeners = {}
-
-        # init database and metadata modules
-        self.db = Database(datapath, self.event_loop)
-        # allow some time for the database to initialize
-        while not self.db.db_ready:
-            time.sleep(0.15)
-        self.cache = Cache(datapath)
-        self.metadata = MetaData(self.event_loop, self.db, self.cache)
-
+        self.config = MassConfig(self)
         # init modules
-        self.web = web_setup(self)
-        self.hass = hass_setup(self)
-        self.music = Music(self)
-        self.player = PlayerManager(self)
+        self.db = Database(datapath)
+        self.cache = Cache(datapath)
+        self.metadata = MetaData(self)
+        self.web = Web(self)
+        self.hass = HomeAssistant(self)
+        self.music = MusicManager(self)
+        self.players = PlayerManager(self)
         self.http_streamer = HTTPStreamer(self)
 
-        # start the event loop
-        try:
-            self.event_loop.run_forever()
-        except (KeyboardInterrupt, SystemExit):
-            LOGGER.info('Exit requested!')
-            self.event_loop.create_task(self.signal_event("system_shutdown"))
-            self.event_loop.stop()
-            self.save_config()
-            time.sleep(5)
-            self.event_loop.close()
-            LOGGER.info('Shutdown complete.')
+    async def start(self):
+        ''' start running the music assistant server '''
+        await self.db.setup()
+        await self.cache.setup()
+        await self.metadata.setup()
+        await self.music.setup()
+        await self.players.setup()
+        await self.web.setup()
+        await self.http_streamer.setup()
+
+    def handle_exception(self, loop, context):
+        ''' global exception handler '''
+        loop.default_exception_handler(context)
+        LOGGER.exception(f"Caught exception: {context}")
 
     async def signal_event(self, msg, msg_details=None):
         ''' signal (systemwide) event '''
@@ -98,40 +79,3 @@ class MusicAssistant():
     async def remove_event_listener(self, cb_id):
         ''' remove callback from our event listeners '''
         self.event_listeners.pop(cb_id, None)
-
-    def save_config(self):
-        ''' save config to file '''
-        # backup existing file
-        conf_file = os.path.join(self.datapath, 'config.json')
-        conf_file_backup = os.path.join(self.datapath, 'config.json.backup')
-        if os.path.isfile(conf_file):
-            shutil.move(conf_file, conf_file_backup)
-        # remove description keys from config
-        final_conf = {}
-        for key, value in self.config.items():
-            final_conf[key] = {}
-            for subkey, subvalue in value.items():
-                if subkey != "__desc__":
-                    final_conf[key][subkey] = subvalue
-        with open(conf_file, 'w') as f:
-            f.write(json.dumps(final_conf, indent=4))
-        
-    def parse_config(self):
-        '''get config from config file'''
-        config = {
-            "base": {},
-            "musicproviders": {},
-            "playerproviders": {},
-            "player_settings": {}
-            }
-        conf_file = os.path.join(self.datapath, 'config.json')
-        if os.path.isfile(conf_file):
-            with open(conf_file) as f:
-                data = f.read()
-                if data:
-                    data = json.loads(data)
-                    for key, value in data.items():
-                        config[key] = value
-        self.config = config
-
-    
\ No newline at end of file
index 583080f2c9d23dd1e887ccd2fc412f83cd511d30..95d4bd21da660099d236977fe47b2b198b80e992 100644 (file)
@@ -22,9 +22,13 @@ class Cache(object):
 
     def __init__(self, datapath):
         '''Initialize our caching class'''
+        if not os.path.isdir(datapath):
+            raise FileNotFoundError(f"data directory {datapath} does not exist!")
         self._datapath = datapath
-        asyncio.ensure_future(self._do_cleanup())
-        LOGGER.debug("Initialized")
+
+    async def setup(self):
+        ''' async initialize of cache module '''
+        asyncio.create_task(self._do_cleanup())
 
     async def get(self, endpoint, checksum=""):
         '''
diff --git a/music_assistant/config.py b/music_assistant/config.py
new file mode 100755 (executable)
index 0000000..8de4feb
--- /dev/null
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import os
+import shutil
+
+from .utils import try_load_json_file, json, LOGGER
+from .constants import CONF_KEY_BASE, CONF_KEY_PLAYERSETTINGS, \
+        CONF_KEY_MUSICPROVIDERS, CONF_KEY_PLAYERPROVIDERS, EVENT_CONFIG_CHANGED
+
+
+class MassConfig(dict):
+    ''' Class which holds our configuration '''
+
+    def __init__(self, mass):
+        self.mass = mass
+        self.loading = False
+        self[CONF_KEY_BASE] = {}
+        self[CONF_KEY_MUSICPROVIDERS] = {}
+        self[CONF_KEY_PLAYERPROVIDERS] = {}
+        self[CONF_KEY_PLAYERSETTINGS] = {}
+        self.__load()
+
+
+    @property
+    def base(self):
+        ''' return base config '''
+        return self[CONF_KEY_BASE]
+
+    @property
+    def players(self):
+        ''' return player settings '''
+        return self[CONF_KEY_PLAYERSETTINGS]
+
+    @property
+    def playerproviders(self):
+        ''' return playerprovider settings '''
+        return self[CONF_KEY_PLAYERPROVIDERS]
+
+    @property
+    def musicproviders(self):
+        ''' return musicprovider settings '''
+        return self[CONF_KEY_MUSICPROVIDERS]
+
+    def create_module_config(self, conf_key, conf_entries, base_key=CONF_KEY_BASE):
+        ''' create (or update) module configuration '''
+        cur_conf = self[base_key].get(conf_key)
+        new_conf = {}
+        for key, def_value, desc in conf_entries:
+            if not cur_conf or not key in cur_conf:
+                new_conf[key] = def_value
+            else:
+                new_conf[key] = cur_conf[key]
+        new_conf['__desc__'] = conf_entries
+        self[base_key][conf_key] = new_conf
+        return self[base_key][conf_key]
+
+    def __setitem__(self, key, new_value):
+        # optional processing here
+        if self[key] != new_value:
+            # value changed
+            self[key] = new_value
+            self.mass.event_loop.create_task(
+                    self.mass.signal_event(EVENT_CONFIG_CHANGED, self.__dict__))
+            self.__save()
+
+    def __save(self):
+        ''' save config to file '''
+        if self.loading:
+            LOGGER.warning("save already running")
+            return
+        self.loading = True
+        # backup existing file
+        conf_file = os.path.join(self.mass.datapath, 'config.json')
+        conf_file_backup = os.path.join(self.mass.datapath, 'config.json.backup')
+        if os.path.isfile(conf_file):
+            shutil.move(conf_file, conf_file_backup)
+        # remove description keys from config
+        final_conf = {}
+        for key, value in self.items():
+            final_conf[key] = {}
+            for subkey, subvalue in value.items():
+                if subkey != "__desc__":
+                    final_conf[key][subkey] = subvalue
+        with open(conf_file, 'w') as f:
+            f.write(json.dumps(final_conf, indent=4))
+        self.loading = False
+        
+    def __load(self):
+        '''load config from file'''
+        self.loading = True
+        conf_file = os.path.join(self.mass.datapath, 'config.json')
+        data = try_load_json_file(conf_file)
+        if not data:
+            # might be a corrupt config file, retry with backup file
+            conf_file_backup = os.path.join(self.mass.datapath, 'config.json.backup')
+            data = try_load_json_file(conf_file_backup)
+        if data:
+            for key, value in data.items():
+                self[key] = value
+        self.loading = False
index 93f1e06d94ca25bd6793bc84ab63e6880303515e..a83bf0f99d62cc12ed1cd2d742ef1a8c3732a049 100755 (executable)
@@ -5,4 +5,18 @@ CONF_USERNAME = "username"
 CONF_PASSWORD = "password"
 CONF_ENABLED = "enabled"
 CONF_HOSTNAME = "hostname"
-CONF_PORT = "port"
\ No newline at end of file
+CONF_PORT = "port"
+CONF_TOKEN = "token"
+CONF_URL = "url"
+
+CONF_TYPE_PASSWORD = '<password>'
+
+CONF_KEY_BASE = "base"
+CONF_KEY_PLAYERSETTINGS = "player_settings"
+CONF_KEY_MUSICPROVIDERS = "musicproviders"
+CONF_KEY_PLAYERPROVIDERS = "playerproviders"
+
+EVENT_PLAYER_CHANGED = "player changed"
+EVENT_STREAM_STARTED = "streaming started"
+EVENT_STREAM_ENDED = "streaming ended"
+EVENT_CONFIG_CHANGED = "config changed"
index 7503eb37d0d8669fa894b8d6f63a1d918cc9a363..fa8a228d311b2916a0f9315fc89b1d46fa88cec6 100755 (executable)
@@ -6,20 +6,21 @@ import os
 from typing import List
 import aiosqlite
 import operator
+import logging
 
 from .utils import run_periodic, LOGGER, get_sort_name, try_parse_int
 from .models.media_types import MediaType, Artist, Album, Track, Playlist, Radio
 
 class Database():
 
-    def __init__(self, datapath, event_loop):
-        self.event_loop = event_loop
+    def __init__(self, datapath):
+        if not os.path.isdir(datapath):
+            raise FileNotFoundError(f"data directory {datapath} does not exist!")
         self.dbfile = os.path.join(datapath, "database.db")
-        self.db_ready = False
-        event_loop.run_until_complete(self.__init_database())
+        logging.getLogger('aiosqlite').setLevel(logging.INFO)
 
-    async def __init_database(self):
-        ''' init database tables'''
+    async def setup(self):
+        ''' init database '''
         async with aiosqlite.connect(self.dbfile) as db:
 
             await db.execute('CREATE TABLE IF NOT EXISTS library_items(item_id INTEGER NOT NULL, provider TEXT NOT NULL, media_type INTEGER NOT NULL, UNIQUE(item_id, provider, media_type));')
@@ -49,7 +50,6 @@ class Database():
             
             await db.commit()
             await db.execute('VACUUM;')
-            self.db_ready = True
 
     async def get_database_id(self, provider:str, prov_item_id:str, media_type:MediaType):
         ''' get the database id for the given prov_id '''
index 3b884425c9ba4ee26d8036c47a8871e012971c99..de9f8d12fffc8e13feef19d27eb34907a07b9ccd 100644 (file)
@@ -16,69 +16,57 @@ import slugify as slug
 import json
 from .utils import run_periodic, LOGGER, parse_track_title, try_parse_int
 from .models.media_types import Track
-from .constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT
+from .constants import CONF_ENABLED, CONF_URL, CONF_TOKEN, EVENT_PLAYER_CHANGED
 from .cache import use_cache
 
-
-'''
-    Homeassistant integration
-    allows publishing of our players to hass
-    allows using hass entities (like switches, media_players or gui inputs) to be triggered
-'''
-
-def setup(mass):
-    ''' setup the module and read/apply config'''
-    create_config_entries(mass.config)
-    conf = mass.config['base']['homeassistant']
-    enabled = conf.get(CONF_ENABLED)
-    token = conf.get('token')
-    url = conf.get('url')
-    if enabled and url and token:
-        return HomeAssistant(mass, url, token)
-    return None
-
-def create_config_entries(config):
-    ''' get the config entries for this module (list with key/value pairs)'''
-    config_entries = [
-        (CONF_ENABLED, False, 'enabled'),
-        ('url', 'localhost', 'hass_url'), 
-        ('token', '<password>', 'hass_token'),
-        ('publish_players', True, 'hass_publish')
+CONF_KEY = 'homeassistant'
+CONF_PUBLISH_PLAYERS = "publish_players"
+EVENT_HASS_CHANGED = "hass entity changed"
+CONFIG_ENTRIES = [
+        (CONF_ENABLED, False, CONF_ENABLED),
+        (CONF_URL, 'localhost', 'hass_url'), 
+        (CONF_TOKEN, '<password>', 'hass_token'),
+        (CONF_PUBLISH_PLAYERS, True, 'hass_publish')
         ]
-    if not config['base'].get('homeassistant'):
-        config['base']['homeassistant'] = {}
-    config['base']['homeassistant']['__desc__'] = config_entries
-    for key, def_value, desc in config_entries:
-        if not key in config['base']['homeassistant']:
-            config['base']['homeassistant'][key] = def_value
 
 class HomeAssistant():
-    ''' HomeAssistant integration '''
+    '''
+        Homeassistant integration
+        allows publishing of our players to hass
+        allows using hass entities (like switches, media_players or gui inputs) to be triggered
+    '''
 
-    def __init__(self, mass, url, token):
+    def __init__(self, mass):
         self.mass = mass
         self._published_players = {}
         self._tracked_entities = {}
         self._state_listeners = {}
         self._sources = []
-        self._token = token
+        self.__send_ws = None
+        self.__last_id = 10
+        # load/create/update config
+        config = self.mass.config.create_module_config(CONF_KEY, CONFIG_ENTRIES)
+        self.enabled = config[CONF_ENABLED]
+        if self.enabled and (not config[CONF_URL] or 
+                not config[CONF_TOKEN]):
+            LOGGER.warning("Invalid configuration for Home Assistant")
+            self.enabled = False
+        self._token = config[CONF_TOKEN]
+        url = config[CONF_URL]
         if url.startswith('https://'):
             self._use_ssl = True
             self._host = url.replace('https://','').split('/')[0]
         else:
             self._use_ssl = False
             self._host = url.replace('http://','').split('/')[0]
-        self.__send_ws = None
-        self.__last_id = 10
         LOGGER.info('Homeassistant integration is enabled')
-        self.mass.event_loop.create_task(self.setup())
 
     async def setup(self):
         ''' perform async setup '''
         self.http_session = aiohttp.ClientSession(
                 loop=self.mass.event_loop, connector=aiohttp.TCPConnector())
         self.mass.event_loop.create_task(self.__hass_websocket())
-        await self.mass.add_event_listener(self.mass_event, "player changed")
+        await self.mass.add_event_listener(self.mass_event, EVENT_PLAYER_CHANGED)
         self.mass.event_loop.create_task(self.__get_sources())
 
     async def get_state_async(self, entity_id, attribute='state'):
@@ -108,11 +96,11 @@ class HomeAssistant():
         state_obj = await self.__get_data('states/%s' % entity_id)
         self._tracked_entities[entity_id] = state_obj
         self.mass.event_loop.create_task(
-            self.mass.signal_event("hass entity changed", entity_id))
+            self.mass.signal_event(EVENT_HASS_CHANGED, entity_id))
     
     async def mass_event(self, msg, msg_details):
         ''' received event from mass '''
-        if msg == "player changed":
+        if msg == EVENT_PLAYER_CHANGED:
             await self.publish_player(msg_details)
 
     async def hass_event(self, event_type, event_data):
@@ -121,7 +109,7 @@ class HomeAssistant():
             if event_data['entity_id'] in self._tracked_entities:
                 self._tracked_entities[event_data['entity_id']] = event_data['new_state']
                 self.mass.event_loop.create_task(
-                    self.mass.signal_event("hass entity changed", event_data['entity_id']))
+                    self.mass.signal_event(EVENT_HASS_CHANGED, event_data['entity_id']))
         elif event_type == 'call_service' and event_data['domain'] == 'media_player':
             await self.__handle_player_command(event_data['service'], event_data['service_data'])
 
@@ -136,7 +124,7 @@ class HomeAssistant():
             if entity_id in self._published_players:
                 # call is for one of our players so handle it
                 player_id = self._published_players[entity_id]
-                player = await self.mass.player.get_player(player_id)
+                player = await self.mass.players.get_player(player_id)
                 if service == 'turn_on':
                     await player.power_on()
                 elif service == 'turn_off':
@@ -177,16 +165,16 @@ class HomeAssistant():
                 playlist = await self.mass.music.playlist_by_name(playlist_str)
                 if playlist:
                     media_items.append(playlist)
-            return await self.mass.player.play_media(player_id, media_items, queue_opt)
+            return await self.mass.players.play_media(player_id, media_items, queue_opt)
         elif media_content_type == 'playlist' and 'spotify://playlist' in media_content_id:
             # TODO: handle parsing of other uri's here
             playlist = self.mass.music.providers['spotify'].playlist(media_content_id.split(':')[-1])
-            return await self.mass.player.play_media(player_id, playlist, queue_opt)
+            return await self.mass.players.play_media(player_id, playlist, queue_opt)
         elif media_content_id.startswith('http'):
             track = Track()
             track.uri = media_content_id
             track.provider = 'http'
-            return await self.mass.player.play_media(player_id, track, queue_opt)
+            return await self.mass.players.play_media(player_id, track, queue_opt)
     
     async def publish_player(self, player):
         ''' publish player details to hass'''
index 3dacd2424a796198ab50cd276c098df66c2548a2..72921b1c8105780bb1395839569eece559aadf91 100755 (executable)
@@ -25,6 +25,11 @@ class HTTPStreamer():
         self.mass = mass
         self.local_ip = get_ip()
         self.analyze_jobs = {}
+
+    async def setup(self):
+        ''' async initialize of module '''
+        # TODO: cleanup of cache files etc.
+        pass
     
     async def stream(self, http_request):
         ''' 
@@ -32,7 +37,7 @@ class HTTPStreamer():
         '''
         # make sure we have a valid player
         player_id = http_request.match_info.get('player_id','')
-        player = await self.mass.player.get_player(player_id)
+        player = await self.mass.players.get_player(player_id)
         if not player:
             LOGGER.error("Received stream request for non-existing player %s" %(player_id))
             return
@@ -49,12 +54,12 @@ class HTTPStreamer():
             if queue_item:
                 # single stream requested, run stream in executor
                 bg_task = run_async_background_task(
-                    self.mass.bg_executor
+                    None
                     self.__stream_single, player, queue_item, buf_queue, cancelled)
             else:
                 # no item is given, start queue stream, run stream in executor
                 bg_task = run_async_background_task(
-                    self.mass.bg_executor
+                    None
                     self.__stream_queue, player, buf_queue, cancelled)
             try:
                 while True:
@@ -70,8 +75,7 @@ class HTTPStreamer():
                 await asyncio.sleep(1)
                 del buf_queue
                 raise asyncio.CancelledError()
-        if not cancelled.is_set():
-            return resp
+        return resp
     
     async def __stream_single(self, player, queue_item, buffer, cancelled):
         ''' start streaming single track from provider '''
@@ -424,9 +428,3 @@ class HTTPStreamer():
         crossfade_part, stderr = process.communicate()
         LOGGER.debug("Got %s bytes in memory for crossfade_part after sox" % len(crossfade_part))
         return crossfade_part
-
-    # def readexactly(streamobj, chunksize):
-    #     ''' read exactly n bytes from the stream object '''
-    #     buf = b''
-    #     while len(buf) < chunksize:
-    #         new_data = streamobj.read(chunksize)
index 103c73df5a4b04bb70bc023b79f28e0f1085262b..5971f35c6924357b5199a2eddf32c67c1d8c2b3c 100755 (executable)
@@ -18,12 +18,15 @@ LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
 class MetaData():
     ''' several helpers to search and store mediadata for mediaitems '''
     
-    def __init__(self, event_loop, db, cache):
-        self.event_loop = event_loop
-        self.db = db
-        self.cache = cache
-        self.musicbrainz = MusicBrainz(event_loop, cache)
-        self.fanarttv = FanartTv(event_loop, cache)
+    def __init__(self, mass):
+        self.mass = mass
+        self.musicbrainz = MusicBrainz(mass)
+        self.fanarttv = FanartTv(mass)
+
+    async def setup(self):
+        ''' async initialize of metadata module '''
+        await self.musicbrainz.setup()
+        await self.fanarttv.setup()
 
     async def get_artist_metadata(self, mb_artist_id, cur_metadata):
         ''' get/update rich metadata for an artist by providing the musicbrainz artist id '''
@@ -58,15 +61,13 @@ class MetaData():
 
 class MusicBrainz():
 
-    def __init__(self, event_loop, cache):
-        self.event_loop = event_loop
-        self.cache = cache
-        self.event_loop.create_task(self.setup())
+    def __init__(self, mass):
+        self.mass = mass
 
     async def setup(self):
         ''' perform async setup '''
         self.http_session = aiohttp.ClientSession(
-                loop=self.event_loop, connector=aiohttp.TCPConnector())
+                loop=self.mass.event_loop, connector=aiohttp.TCPConnector())
         self.throttler = Throttler(rate_limit=1, period=1)
 
     async def search_artist_by_album(self, artistname, albumname=None, album_upc=None):
@@ -138,15 +139,13 @@ class MusicBrainz():
 
 class FanartTv():
 
-    def __init__(self, event_loop, cache):
-        self.event_loop = event_loop
-        self.cache = cache
-        self.event_loop.create_task(self.setup())
+    def __init__(self, mass):
+        self.mass = mass
 
     async def setup(self):
         ''' perform async setup '''
         self.http_session = aiohttp.ClientSession(
-                loop=self.event_loop, connector=aiohttp.TCPConnector())
+                loop=self.mass.event_loop, connector=aiohttp.TCPConnector())
         self.throttler = Throttler(rate_limit=1, period=1)
 
     async def artist_images(self, mb_artist_id):
index 41c1ca2ba6e581567a5035555fcd392655326c2f..0ed1376bbc0ea56ed0c30b9314db991c2a93c0d3 100755 (executable)
@@ -21,10 +21,14 @@ class MusicProvider():
     prov_id = 'my_provider' # used as id
     icon = ''
 
-    def __init__(self, mass):
+    def __init__(self, mass, conf):
         self.mass = mass
         self.cache = mass.cache
 
+    async def setup(self):
+        ''' async initialize of module '''
+        pass
+
     ### Common methods and properties ####
 
     async def artist(self, prov_item_id, lazy=True) -> Artist:
@@ -382,12 +386,12 @@ class PlayerProvider():
 
     async def add_player(self, player_id, name='', is_group=False):
         ''' register a new player '''
-        return await self.mass.player.add_player(player_id, 
+        return await self.mass.players.add_player(player_id, 
                 self.prov_id, name=name, is_group=is_group)
 
     async def remove_player(self, player_id):
         ''' remove a player '''
-        return await self.mass.player.remove_player(player_id)
+        return await self.mass.players.remove_player(player_id)
 
     ### Provider specific implementation #####
 
index 5c6d6c7e7879705fc91cb0cd0928dfa4de4e2f7d..6b8b2e43770313d89ebb632d1339e9c37400db9c 100755 (executable)
@@ -6,7 +6,7 @@ from enum import Enum
 from typing import List
 import operator
 from ..utils import run_periodic, LOGGER, parse_track_title, try_parse_int, try_parse_bool, try_parse_float
-from ..constants import CONF_ENABLED
+from ..constants import EVENT_PLAYER_CHANGED
 from ..cache import use_cache
 from .media_types import Track, MediaType
 from .player_queue import PlayerQueue, QueueItem
@@ -118,7 +118,7 @@ class Player():
         self.supports_crossfade = False # has native crossfading support
         self.supports_replay_gain = False # has native support for replaygain volume leveling
         # if home assistant support is enabled, register state listener
-        if self.mass.hass:
+        if self.mass.hass.enabled:
             self.mass.event_loop.create_task(
                 self.mass.add_event_listener(self.hass_state_listener, "hass entity changed"))
 
@@ -165,8 +165,7 @@ class Player():
         if not self.powered:
             return PlayerState.Off
         if self.group_parent:
-            group_player = self.mass.bg_executor.submit(asyncio.run, 
-                self.mass.player.get_player(self.group_parent)).result()
+            group_player = self.mass.players._players.get(self.group_parent)
             if group_player:
                 return group_player.state
         return self._state
@@ -182,13 +181,13 @@ class Player():
     def powered(self):
         ''' [PROTECTED] return power state for this player '''
         # homeassistant integration
-        if (self.mass.hass and self.settings.get('hass_power_entity') and 
+        if (self.mass.hass.enabled and self.settings.get('hass_power_entity') and 
                 self.settings.get('hass_power_entity_source')):
             hass_state = self.mass.hass.get_state(
                     self.settings['hass_power_entity'],
                     attribute='source')
             return hass_state == self.settings['hass_power_entity_source']
-        elif self.mass.hass and self.settings.get('hass_power_entity'):
+        elif self.mass.hass.enabled and self.settings.get('hass_power_entity'):
             hass_state = self.mass.hass.get_state(
                     self.settings['hass_power_entity'])
             return hass_state != 'off'
@@ -210,7 +209,7 @@ class Player():
         ''' [PROTECTED] cur_time (player's elapsed time) property of this player '''
         # handle group player
         if self.group_parent:
-            group_player = self.mass.player.get_player_sync(self.group_parent)
+            group_player = self.mass.players.get_player_sync(self.group_parent)
             if group_player:
                 return group_player.cur_time
         return self.queue.cur_item_time
@@ -227,7 +226,7 @@ class Player():
         ''' [PROTECTED] cur_uri (uri loaded in player) property of this player '''
         # handle group player
         if self.group_parent:
-            group_player = self.mass.player.get_player_sync(self.group_parent)
+            group_player = self.mass.players.get_player_sync(self.group_parent)
             if group_player:
                 return group_player.cur_uri
         return self._cur_uri
@@ -254,7 +253,7 @@ class Player():
                 group_volume = group_volume / active_players
             return group_volume
         # handle hass integration
-        elif self.mass.hass and self.settings.get('hass_volume_entity'):
+        elif self.mass.hass.enabled and self.settings.get('hass_volume_entity'):
             hass_state = self.mass.hass.get_state(
                     self.settings['hass_volume_entity'],
                     attribute='volume_level')
@@ -300,7 +299,7 @@ class Player():
         ''' [PROTECTED] return group childs '''
         if not self.is_group:
             return []
-        return [item for item in self.mass.player.players if item.group_parent == self.player_id]
+        return [item for item in self.mass.players.players if item.group_parent == self.player_id]
 
     @property
     def enabled(self):
@@ -312,7 +311,7 @@ class Player():
         ''' [PROTECTED] player's queue '''
         # handle group player
         if self.group_parent:
-            group_player = self.mass.player.get_player_sync(self.group_parent)
+            group_player = self.mass.players.get_player_sync(self.group_parent)
             if group_player:
                 return group_player.queue
         return self._queue
@@ -326,7 +325,7 @@ class Player():
         ''' [PROTECTED] send stop command to player '''
         if self.group_parent:
             # redirect playback related commands to parent player
-            group_player = await self.mass.player.get_player(self.group_parent)
+            group_player = await self.mass.players.get_player(self.group_parent)
             if group_player:
                 return await group_player.stop()
         else:
@@ -336,7 +335,7 @@ class Player():
         ''' [PROTECTED] send play (unpause) command to player '''
         if self.group_parent:
             # redirect playback related commands to parent player
-            group_player = await self.mass.player.get_player(self.group_parent)
+            group_player = await self.mass.players.get_player(self.group_parent)
             if group_player:
                 return await group_player.play()
         elif self.state == PlayerState.Paused:
@@ -348,7 +347,7 @@ class Player():
         ''' [PROTECTED] send pause command to player '''
         if self.group_parent:
             # redirect playback related commands to parent player
-            group_player = await self.mass.player.get_player(self.group_parent)
+            group_player = await self.mass.players.get_player(self.group_parent)
             if group_player:
                 return await group_player.pause()
         else:
@@ -365,7 +364,7 @@ class Player():
         ''' [PROTECTED] send next command to player '''
         if self.group_parent:
             # redirect playback related commands to parent player
-            group_player = await self.mass.player.get_player(self.group_parent)
+            group_player = await self.mass.players.get_player(self.group_parent)
             if group_player:
                 return await group_player.next()
         else:
@@ -375,7 +374,7 @@ class Player():
         ''' [PROTECTED] send previous command to player '''
         if self.group_parent:
             # redirect playback related commands to parent player
-            group_player = await self.mass.player.get_player(self.group_parent)
+            group_player = await self.mass.players.get_player(self.group_parent)
             if group_player:
                 return await group_player.previous()
         else:
@@ -396,7 +395,7 @@ class Player():
         if self.settings.get('mute_as_power'):
             await self.volume_mute(False)
         # handle hass integration
-        if (self.mass.hass and 
+        if (self.mass.hass.enabled and 
                 self.settings.get('hass_power_entity') and 
                 self.settings.get('hass_power_entity_source')):
             cur_source = await self.mass.hass.get_state_async(
@@ -407,7 +406,7 @@ class Player():
                     'source': self.settings['hass_power_entity_source'] 
                 }
                 await self.mass.hass.call_service('media_player', 'select_source', service_data)
-        elif self.settings.get('hass_power_entity'):
+        elif self.mass.hass.enabled and self.settings.get('hass_power_entity'):
             domain = self.settings['hass_power_entity'].split('.')[0]
             service_data = { 'entity_id': self.settings['hass_power_entity']}
             await self.mass.hass.call_service(domain, 'turn_on', service_data)
@@ -417,7 +416,7 @@ class Player():
         # handle group power
         if self.group_parent:
             # player has a group parent, check if it should be turned on
-            group_player = await self.mass.player.get_player(self.group_parent)
+            group_player = await self.mass.players.get_player(self.group_parent)
             if group_player and not group_player.powered:
                 return await group_player.power_on()
 
@@ -428,7 +427,7 @@ class Player():
         if self.settings.get('mute_as_power'):
             await self.volume_mute(True)
         # handle hass integration
-        if (self.mass.hass and 
+        if (self.mass.hass.enabled and 
                 self.settings.get('hass_power_entity') and 
                 self.settings.get('hass_power_entity_source')):
             cur_source = await self.mass.hass.get_state_async(
@@ -436,7 +435,7 @@ class Player():
             if cur_source == self.settings['hass_power_entity_source']:
                 service_data = { 'entity_id': self.settings['hass_power_entity'] }
                 await self.mass.hass.call_service('media_player', 'turn_off', service_data)
-        elif self.mass.hass and self.settings.get('hass_power_entity'):
+        elif self.mass.hass.enabled and self.settings.get('hass_power_entity'):
             domain = self.settings['hass_power_entity'].split('.')[0]
             service_data = { 'entity_id': self.settings['hass_power_entity']}
             await self.mass.hass.call_service(domain, 'turn_off', service_data)
@@ -448,7 +447,7 @@ class Player():
                     await item.power_off()
         elif self.group_parent:
             # player has a group parent, check if it should be turned off
-            group_player = await self.mass.player.get_player(self.group_parent)
+            group_player = await self.mass.players.get_player(self.group_parent)
             if group_player.powered:
                 needs_power = False
                 for child_player in group_player.group_childs:
@@ -483,7 +482,7 @@ class Player():
                     new_child_volume = cur_child_volume + (cur_child_volume * volume_dif_percent)
                     await child_player.volume_set(new_child_volume)
         # handle hass integration
-        elif self.mass.hass and self.settings.get('hass_volume_entity'):
+        elif self.mass.hass.enabled and self.settings.get('hass_volume_entity'):
             service_data = { 
                 'entity_id': self.settings['hass_volume_entity'], 
                 'volume_level': volume_level/100
@@ -514,8 +513,7 @@ class Player():
     async def update(self):
         ''' [PROTECTED] signal player updated '''
         await self.queue.update()
-        LOGGER.debug("player changed: %s" % self.name)
-        await self.mass.signal_event('player changed', self)
+        await self.mass.signal_event(EVENT_PLAYER_CHANGED, self)
         self.get_player_settings()
     
     async def hass_state_listener(self, msg, msg_details=None):
@@ -548,7 +546,7 @@ class Player():
             ("play_power_on", False, "player_power_play"),
         ]
         # append player specific settings
-        config_entries += self.mass.player.providers[self._prov_id].player_config_entries
+        config_entries += self.mass.players.providers[self._prov_id].player_config_entries
         # hass integration
         if self.mass.config['base'].get('homeassistant',{}).get("enabled"):
             # append hass specific config entries
index 0dce5c332489ce7c9c087ab7cebdab0faf410978..a93570908a97c3cae91dceaa08908b302599294d 100755 (executable)
@@ -20,7 +20,7 @@ class PlayerProvider():
     '''
     
 
-    def __init__(self, mass):
+    def __init__(self, mass, conf):
         self.mass = mass
         self.name = 'My great Musicplayer provider' # display name
         self.prov_id = 'my_provider' # used as id
@@ -32,19 +32,19 @@ class PlayerProvider():
     @property
     def players(self):
         ''' return all players for this provider '''
-        return [item for item in self.mass.player.players if item.player_provider == self.prov_id]
+        return [item for item in self.mass.players.players if item.player_provider == self.prov_id]
 
     async def get_player(self, player_id:str):
         ''' return player by id '''
-        return await self.mass.player.get_player(player_id)
+        return await self.mass.players.get_player(player_id)
 
     async def add_player(self, player:Player):
         ''' register a new player '''
-        return await self.mass.player.add_player(player)
+        return await self.mass.players.add_player(player)
 
     async def remove_player(self, player_id:str):
         ''' remove a player '''
-        return await self.mass.player.remove_player(player_id)
+        return await self.mass.players.remove_player(player_id)
 
     ### Provider specific implementation #####
 
index 4c4a2f8550fdd738718098196439c305ff3dd594..fc8fa3fdea0a0e2930b6197e20cc9103276d47b8 100755 (executable)
@@ -6,26 +6,28 @@ from typing import List
 import toolz
 import operator
 import os
-import importlib
 
-from .utils import run_periodic, LOGGER, try_supported
+from .utils import run_periodic, LOGGER, try_supported, load_provider_modules
 from .models.media_types import MediaType, Track, Artist, Album, Playlist, Radio
+from .constants import CONF_KEY_MUSICPROVIDERS
 
 
-BASE_DIR = os.path.dirname(os.path.abspath(__file__))
-MODULES_PATH = os.path.join(BASE_DIR, "musicproviders" )
-
-class Music():
+class MusicManager():
     ''' several helpers around the musicproviders '''
     
     def __init__(self, mass):
         self.sync_running = False
         self.mass = mass
-        self.providers = {}
         # dynamically load musicprovider modules
-        self.load_music_providers()
+        self.providers = load_provider_modules(mass, CONF_KEY_MUSICPROVIDERS)
+
+    async def setup(self):
+        ''' async initialize of module '''
+        # start providers
+        for prov in self.providers.values():
+            await prov.setup()
         # schedule sync task
-        mass.event_loop.create_task(self.sync_music_providers())
+        self.mass.event_loop.create_task(self.sync_music_providers())
 
     async def item(self, item_id, media_type:MediaType, provider='database', lazy=True):
         ''' get single music item by id and media type'''
@@ -392,25 +394,3 @@ class Music():
                 await self.mass.db.remove_from_library(db_id, MediaType.Radio, prov_id)
         LOGGER.info("Finished syncing Radios for provider %s" % prov_id)
 
-    def load_music_providers(self):
-        ''' dynamically load musicproviders '''
-        for item in os.listdir(MODULES_PATH):
-            if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and 
-                    item.endswith('.py') and not item.startswith('.')):
-                module_name = item.replace(".py","")
-                LOGGER.debug("Loading musicprovider module %s" % module_name)
-                try:
-                    mod = importlib.import_module("." + module_name, "music_assistant.musicproviders")
-                    if not self.mass.config['musicproviders'].get(module_name):
-                        self.mass.config['musicproviders'][module_name] = {}
-                    self.mass.config['musicproviders'][module_name]['__desc__'] = mod.config_entries()
-                    for key, def_value, desc in mod.config_entries():
-                        if not key in self.mass.config['musicproviders'][module_name]:
-                            self.mass.config['musicproviders'][module_name][key] = def_value
-                    mod = mod.setup(self.mass)
-                    if mod:
-                        self.providers[mod.prov_id] = mod
-                        cls_name = mod.__class__.__name__
-                        LOGGER.info("Successfully initialized module %s" % cls_name)
-                except Exception as exc:
-                    LOGGER.exception("Error loading module %s: %s" %(module_name, exc))
index a6ffabb48cf51b5406412ea4c22c4162c72260a9..2773ef743d154ea9a007b6dc264dea5779453916 100644 (file)
@@ -14,25 +14,16 @@ from ..utils import run_periodic, LOGGER, parse_track_title
 from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
 from ..constants import CONF_ENABLED
 
+PROV_ID = 'file'
+PROV_NAME = 'Local files and playlists'
+PROV_CLASS = 'FileProvider'
 
+CONFIG_ENTRIES = [
+    (CONF_ENABLED, False, CONF_ENABLED),
+    ("music_dir", "", "file_prov_music_path"), 
+    ("playlists_dir", "", "file_prov_playlists_path")
+    ]
 
-def setup(mass):
-    ''' setup the provider'''
-    enabled = mass.config["musicproviders"]['file'].get(CONF_ENABLED)
-    music_dir = mass.config["musicproviders"]['file'].get('music_dir')
-    playlists_dir = mass.config["musicproviders"]['file'].get('playlists_dir')
-    if enabled and (music_dir or playlists_dir):
-        file_provider = FileProvider(mass, music_dir, playlists_dir)
-        return file_provider
-    return False
-
-def config_entries():
-    ''' get the config entries for this provider (list with key/value pairs)'''
-    return [
-        (CONF_ENABLED, False, CONF_ENABLED),
-        ("music_dir", "", "file_prov_music_path"), 
-        ("playlists_dir", "", "file_prov_playlists_path")
-        ]
 
 class FileProvider(MusicProvider):
     ''' 
@@ -45,13 +36,17 @@ class FileProvider(MusicProvider):
     '''
     
 
-    def __init__(self, mass, music_dir, playlists_dir):
-        self.name = 'Local files and playlists'
-        self.prov_id = 'file'
+    def __init__(self, mass, conf):
+        self.name = PROV_NAME
+        self.prov_id = PROV_ID
         self.mass = mass
         self.cache = mass.cache
-        self._music_dir = music_dir
-        self._playlists_dir = playlists_dir
+        self._music_dir = conf["music_dir"]
+        self._playlists_dir = conf["playlists_dir"]
+        if not os.path.isdir(conf["music_dir"]):
+            raise FileNotFoundError(f"Directory {conf['music_dir']} does not exist")
+        if not os.path.isdir(conf["playlists_dir"]):
+            raise FileNotFoundError(f"Directory {conf['playlists_dir']} does not exist")
 
     async def search(self, searchstring, media_types=List[MediaType], limit=5):
         ''' perform search on the provider '''
index 085d3a32d3f0cd2faef2df1897a6bf929c0d402d..8979a4468d32aa7445d0af21c070069c512033f3 100644 (file)
@@ -14,49 +14,44 @@ from asyncio_throttle import Throttler
 from ..cache import use_cache
 from ..utils import run_periodic, LOGGER, parse_track_title
 from ..app_vars import get_app_var
-from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
-from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED
+from ..models import MusicProvider, MediaType, TrackQuality, \
+        AlbumType, Artist, Album, Track, Playlist
+from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED, \
+        CONF_TYPE_PASSWORD, EVENT_STREAM_STARTED, EVENT_STREAM_ENDED
 
+PROV_ID = 'qobuz'
+PROV_NAME = 'Qobuz'
+PROV_CLASS = 'QobuzProvider'
 
-def setup(mass):
-    ''' setup the provider'''
-    enabled = mass.config["musicproviders"]['qobuz'].get(CONF_ENABLED)
-    username = mass.config["musicproviders"]['qobuz'].get(CONF_USERNAME)
-    password = mass.config["musicproviders"]['qobuz'].get(CONF_PASSWORD)
-    if enabled and username and password:
-        provider = QobuzProvider(mass, username, password)
-        return provider
-    return False
+CONFIG_ENTRIES = [
+    (CONF_ENABLED, False, CONF_ENABLED),
+    (CONF_USERNAME, "", CONF_USERNAME), 
+    (CONF_PASSWORD, CONF_TYPE_PASSWORD, CONF_PASSWORD)
+    ]
 
-def config_entries():
-    ''' get the config entries for this provider (list with key/value pairs)'''
-    return [
-        (CONF_ENABLED, False, CONF_ENABLED),
-        (CONF_USERNAME, "", CONF_USERNAME), 
-        (CONF_PASSWORD, "<password>", CONF_PASSWORD)
-        ]
 
 class QobuzProvider(MusicProvider):
     
-
-    def __init__(self, mass, username, password):
-        self.name = 'Qobuz'
-        self.prov_id = 'qobuz'
+    def __init__(self, mass, conf):
+        ''' Support for streaming music provider Qobuz '''
+        self.name = PROV_NAME
+        self.prov_id = PROV_ID
         self.mass = mass
         self.cache = mass.cache
-        self.__username = username
-        self.__password = password
+        self.__username = conf[CONF_USERNAME]
+        self.__password = conf[CONF_PASSWORD]
+        if not conf[CONF_USERNAME] or not conf[CONF_PASSWORD]:
+            raise Exception("Username and password must not be empty")
         self.__user_auth_info = None
         self.__logged_in = False
-        self.mass.event_loop.create_task(self.setup())
 
     async def setup(self):
         ''' perform async setup '''
         self.http_session = aiohttp.ClientSession(
                 loop=self.mass.event_loop, connector=aiohttp.TCPConnector())
         self.throttler = Throttler(rate_limit=2, period=1)
-        await self.mass.add_event_listener(self.mass_event, 'streaming_started')
-        await self.mass.add_event_listener(self.mass_event, 'streaming_ended')
+        await self.mass.add_event_listener(self.mass_event, EVENT_STREAM_STARTED)
+        await self.mass.add_event_listener(self.mass_event, EVENT_STREAM_ENDED)
     
     async def search(self, searchstring, media_types=List[MediaType], limit=5):
         ''' perform search on the provider '''
index 9bc99f76001aefeb9cf1587d03900869d7e113f3..866f05268f1ea90d9e3885ba92fb2e54776d20bd 100644 (file)
@@ -15,47 +15,40 @@ from ..cache import use_cache
 from ..utils import run_periodic, LOGGER, parse_track_title
 from ..app_vars import get_app_var
 from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
-from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED
-
-
-def setup(mass):
-    ''' setup the provider'''
-    enabled = mass.config["musicproviders"]['spotify'].get(CONF_ENABLED)
-    username = mass.config["musicproviders"]['spotify'].get(CONF_USERNAME)
-    password = mass.config["musicproviders"]['spotify'].get(CONF_PASSWORD)
-    if enabled and username and password:
-        spotify_provider = SpotifyProvider(mass, username, password)
-        return spotify_provider
-    return False
-
-def config_entries():
-    ''' get the config entries for this provider (list with key/value pairs)'''
-    return [
-        (CONF_ENABLED, False, CONF_ENABLED),
-        (CONF_USERNAME, "", CONF_USERNAME), 
-        (CONF_PASSWORD, "<password>", CONF_PASSWORD)
-        ]
+from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED, CONF_TYPE_PASSWORD
+
+
+PROV_ID = 'spotify'
+PROV_NAME = 'Spotify'
+PROV_CLASS = 'SpotifyProvider'
+
+CONFIG_ENTRIES = [
+    (CONF_ENABLED, False, CONF_ENABLED),
+    (CONF_USERNAME, "", CONF_USERNAME), 
+    (CONF_PASSWORD, CONF_TYPE_PASSWORD, CONF_PASSWORD)
+    ]
 
 class SpotifyProvider(MusicProvider):
     
-
-    def __init__(self, mass, username, password):
-        self.name = 'Spotify'
-        self.prov_id = 'spotify'
-        self._cur_user = None
+    def __init__(self, mass, conf):
+        ''' Support for streaming provider Spotify '''
         self.mass = mass
         self.cache = mass.cache
-        self._username = username
-        self._password = password
+        self.name = PROV_NAME
+        self.prov_id = PROV_ID
+        self._cur_user = None
+        if not conf[CONF_USERNAME] or not conf[CONF_PASSWORD]:
+            raise Exception("Username and password must not be empty")
+        self._username = conf[CONF_USERNAME]
+        self._password = conf[CONF_PASSWORD]
         self.__auth_token = {}
-        self.mass.event_loop.create_task(self.setup())
+
 
     async def setup(self):
         ''' perform async setup '''
         self.throttler = Throttler(rate_limit=1, period=1)
         self.http_session = aiohttp.ClientSession(
                 loop=self.mass.event_loop, connector=aiohttp.TCPConnector())
-        
 
     async def search(self, searchstring, media_types=List[MediaType], limit=5):
         ''' perform search on the provider '''
index 86fddfcc41f172a68a9ba8b407988f43bab30280..db6398da48d9ae108756c5437874e38a8cd54e33 100644 (file)
@@ -13,38 +13,32 @@ import aiohttp
 from ..cache import use_cache
 from ..utils import run_periodic, LOGGER, parse_track_title
 from ..models import MusicProvider, MediaType, TrackQuality, Radio
-from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED
+from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED, CONF_TYPE_PASSWORD
 
 
-def setup(mass):
-    ''' setup the provider'''
-    enabled = mass.config["musicproviders"]['tunein'].get(CONF_ENABLED)
-    username = mass.config["musicproviders"]['tunein'].get(CONF_USERNAME)
-    password = mass.config["musicproviders"]['tunein'].get(CONF_PASSWORD)
-    if enabled and username and password:
-        provider = TuneInProvider(mass, username, password)
-        return provider
-    return False
+PROV_ID = 'tunein'
+PROV_NAME = 'TuneIn Radio'
+PROV_CLASS = 'TuneInProvider'
 
-def config_entries():
-    ''' get the config entries for this provider (list with key/value pairs)'''
-    return [
-        (CONF_ENABLED, False, CONF_ENABLED),
-        (CONF_USERNAME, "", CONF_USERNAME), 
-        (CONF_PASSWORD, "<password>", CONF_PASSWORD)
-        ]
+CONFIG_ENTRIES = [
+    (CONF_ENABLED, False, CONF_ENABLED),
+    (CONF_USERNAME, "", CONF_USERNAME), 
+    (CONF_PASSWORD, CONF_TYPE_PASSWORD, CONF_PASSWORD)
+    ]
 
 class TuneInProvider(MusicProvider):
     
 
-    def __init__(self, mass, username, password):
-        self.name = 'TuneIn Radio'
-        self.prov_id = 'tunein'
+    def __init__(self, mass, conf):
+        ''' Support for streaming radio provider TuneIn '''
+        self.name = PROV_NAME
+        self.prov_id = PROV_ID
         self.mass = mass
         self.cache = mass.cache
-        self._username = username
-        self._password = password
-        self.mass.event_loop.create_task(self.setup())
+        if not conf[CONF_USERNAME] or not conf[CONF_PASSWORD]:
+            raise Exception("Username and password must not be empty")
+        self._username = conf[CONF_USERNAME]
+        self._password = conf[CONF_PASSWORD]
 
     async def setup(self):
         ''' perform async setup '''
index c6ff40c554a65445b4928ed1d155cdb5d2dbcacf..d3e2e13d3afeaac328a88bf9668a3bf343ccef85 100755 (executable)
@@ -8,9 +8,10 @@ import operator
 import random
 import functools
 import urllib
-import importlib
 
-from .utils import run_periodic, LOGGER, try_parse_int, try_parse_float, get_ip, run_async_background_task
+from .constants import CONF_KEY_PLAYERPROVIDERS
+from .utils import run_periodic, LOGGER, try_parse_int, try_parse_float, \
+    get_ip, run_async_background_task, load_provider_modules
 from .models.media_types import MediaType, TrackQuality
 from .models.player_queue import QueueItem
 from .models.playerstate import PlayerState
@@ -19,16 +20,20 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
 MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" )
 
 
-
 class PlayerManager():
     ''' several helpers to handle playback through player providers '''
     
     def __init__(self, mass):
         self.mass = mass
-        self.providers = {}
         self._players = {}
-        # dynamically load provider modules
-        self.load_providers()
+        # dynamically load musicprovider modules
+        self.providers = load_provider_modules(mass, CONF_KEY_PLAYERPROVIDERS)
+        
+    async def setup(self):
+        ''' async initialize of module '''
+        # start providers
+        for prov in self.providers.values():
+            await prov.setup()
     
     @property
     def players(self):
@@ -106,26 +111,4 @@ class PlayerManager():
             return await player.queue.insert(queue_items, 0)
         elif queue_opt == 'add':
             return await player.queue.append(queue_items)
-    
-    def load_providers(self):
-        ''' dynamically load providers '''
-        for item in os.listdir(MODULES_PATH):
-            if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and 
-                    item.endswith('.py') and not item.startswith('.')):
-                module_name = item.replace(".py","")
-                LOGGER.debug("Loading playerprovider module %s" % module_name)
-                try:
-                    mod = importlib.import_module("." + module_name, "music_assistant.playerproviders")
-                    if not self.mass.config['playerproviders'].get(module_name):
-                        self.mass.config['playerproviders'][module_name] = {}
-                    self.mass.config['playerproviders'][module_name]['__desc__'] = mod.config_entries()
-                    for key, def_value, desc in mod.config_entries():
-                        if not key in self.mass.config['playerproviders'][module_name]:
-                            self.mass.config['playerproviders'][module_name][key] = def_value
-                    mod = mod.setup(self.mass)
-                    if mod:
-                        self.providers[mod.prov_id] = mod
-                        cls_name = mod.__class__.__name__
-                        LOGGER.info("Successfully initialized module %s" % cls_name)
-                except Exception as exc:
-                    LOGGER.exception("Error loading module %s: %s" %(module_name, exc))
+    
\ No newline at end of file
index f301374bb881922b933ffff3e8d64f5f27cc9f8a..203dce862e130146376e6802fdaa79030adcfd35 100644 (file)
@@ -18,19 +18,17 @@ from ..models.playerstate import PlayerState
 from ..models.player_queue import QueueItem, PlayerQueue
 from ..constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT
 
-def setup(mass):
-    ''' setup the provider'''
-    enabled = mass.config["playerproviders"]['chromecast'].get(CONF_ENABLED)
-    if enabled:
-        provider = ChromecastProvider(mass)
-        return provider
-    return False
-
-def config_entries():
-    ''' get the config entries for this provider (list with key/value pairs)'''
-    return [
-        (CONF_ENABLED, True, CONF_ENABLED),
-        ]
+PROV_ID = 'chromecast'
+PROV_NAME = 'Chromecast'
+PROV_CLASS = 'ChromecastProvider'
+
+CONFIG_ENTRIES = [
+    (CONF_ENABLED, False, CONF_ENABLED),
+    ]
+
+PLAYER_CONFIG_ENTRIES = [
+   ("gapless_enabled", False, "gapless_enabled"),
+    ]
 
 class ChromecastPlayer(Player):
     ''' Chromecast player object '''
@@ -177,14 +175,18 @@ class ChromecastPlayer(Player):
 class ChromecastProvider(PlayerProvider):
     ''' support for ChromeCast Audio '''
     
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.prov_id = 'chromecast'
-        self.name = 'Chromecast'
+    def __init__(self, mass, conf):
+        super().__init__(mass, conf)
+        self.prov_id = PROV_ID
+        self.name = PROV_NAME
         self._discovery_running = False
         logging.getLogger('pychromecast').setLevel(logging.WARNING)
-        self.player_config_entries = [("gapless_enabled", False, "gapless_enabled")]
-        self.mass.event_loop.create_task(self.__periodic_chromecast_discovery())
+        self.player_config_entries = PLAYER_CONFIG_ENTRIES
+
+    async def setup(self):
+        ''' perform async setup '''
+        self.mass.event_loop.create_task(
+                self.__periodic_chromecast_discovery())
 
     async def __handle_player_state(self, chromecast, caststatus=None, mediastatus=None):
         ''' handle a player state message from the socket '''
@@ -265,8 +267,9 @@ class ChromecastProvider(PlayerProvider):
             discovery_info = listener.services[name]
             ip_address, port, uuid, model_name, friendly_name = discovery_info
             player_id = str(uuid)
-            player = self.mass.bg_executor.submit(asyncio.run, 
-                self.get_player(player_id)).result()
+            player = asyncio.run_coroutine_threadsafe(
+                    self.get_player(player_id), 
+                    self.mass.event_loop).result()
             if not player:
                 LOGGER.info("discovered chromecast: %s - %s:%s" % (friendly_name, ip_address, port))
                 asyncio.run_coroutine_threadsafe(
index 14bd9e2baa4fbf83bd768105a9aaf8e80c628bf2..f9e5090e0339f5574835686662d7cbac5c14ed49 100644 (file)
@@ -16,35 +16,35 @@ from ..models import PlayerProvider, Player, PlayerState, MediaType, TrackQualit
 from ..constants import CONF_ENABLED
 
 
-def setup(mass):
-    ''' setup the provider'''
-    enabled = mass.config["playerproviders"]['squeezebox'].get(CONF_ENABLED)
-    if enabled:
-        provider = PySqueezeServer(mass)
-        return provider
-    return False
-
-def config_entries():
-    ''' get the config entries for this provider (list with key/value pairs)'''
-    return [
-        (CONF_ENABLED, True, CONF_ENABLED)
-        ]
-
-
-class PySqueezeServer(PlayerProvider):
+PROV_ID = 'squeezebox'
+PROV_NAME = 'Squeezebox'
+PROV_CLASS = 'PySqueezeProvider'
+
+CONFIG_ENTRIES = [
+    (CONF_ENABLED, True, CONF_ENABLED),
+    ]
+
+PLAYER_CONFIG_ENTRIES = []
+
+
+class PySqueezeProvider(PlayerProvider):
     ''' Python implementation of SlimProto server '''
 
-    def __init__(self, mass):
-        super().__init__(mass)
-        self.prov_id = 'squeezebox'
-        self.name = 'Squeezebox'
+    def __init__(self, mass, conf):
+        super().__init__(mass, conf)
+        self.prov_id = PROV_ID
+        self.name = PROV_NAME
+        self.player_config_entries = PLAYER_CONFIG_ENTRIES
+
+     ### Provider specific implementation #####
+
+    async def setup(self):
+        ''' async initialize of module '''
         # start slimproto server
-        mass.event_loop.create_task(
+        self.mass.event_loop.create_task(
                 asyncio.start_server(self.__handle_socket_client, '0.0.0.0', 3483))
         # setup discovery
-        mass.event_loop.create_task(self.start_discovery())
-
-     ### Provider specific implementation #####
+        self.mass.event_loop.create_task(self.start_discovery())
 
     async def start_discovery(self):
         transport, protocol = await self.mass.event_loop.create_datagram_endpoint(
@@ -80,7 +80,7 @@ class PySqueezeServer(PlayerProvider):
                             player_id = str(device_mac).lower()
                             device_type = devices.get(dev_id, 'unknown device')
                             player = PySqueezePlayer(self.mass, player_id, self.prov_id, device_type, writer)
-                            self.mass.event_loop.create_task(self.mass.player.add_player(player))
+                            self.mass.event_loop.create_task(self.mass.players.add_player(player))
                         elif player != None:
                             player.process_msg(operation, packet)
                     
@@ -88,7 +88,7 @@ class PySqueezeServer(PlayerProvider):
             # connection lost ?
             LOGGER.warning(exc)
         # disconnect
-        await self.mass.player.remove_player(player)
+        await self.mass.players.remove_player(player)
 
 class PySqueezePlayer(Player):
     ''' Squeezebox socket client '''
index 3713253a3a1e123960fd1b50879ff70baf55e78a..f77c2c36acb5eed54a190eb0c046dc6e8ca34057 100755 (executable)
@@ -5,8 +5,15 @@ import asyncio
 import logging
 from concurrent.futures import ThreadPoolExecutor
 import socket
+import importlib
 import os
-LOGGER = logging.getLogger()
+try:
+    import simplejson as json
+except ImportError:
+    import json
+LOGGER = logging.getLogger('music_assistant')
+
+from .constants import CONF_KEY_MUSICPROVIDERS, CONF_ENABLED
 
 
 def run_periodic(period):
@@ -127,4 +134,76 @@ def get_folder_size(folderpath):
             fp = os.path.join(dirpath, f)
             total_size += os.path.getsize(fp)
     total_size_gb = total_size/float(1<<30)
-    return total_size_gb
\ No newline at end of file
+    return total_size_gb
+
+
+def json_serializer(obj):
+    ''' json serializer to recursively create serializable values for custom data types '''
+    def get_val(val):
+        if isinstance(val, (int, str, bool, float)):
+            return val
+        elif isinstance(val, list):
+            new_list = []
+            for item in val:
+                new_list.append( get_val(item))
+            return new_list
+        elif hasattr(val, 'to_dict'):
+            return get_val(val.to_dict())
+        elif isinstance(val, dict):
+            new_dict = {}
+            for key, value in val.items():
+                new_dict[key] = get_val(value)
+            return new_dict
+        elif hasattr(val, '__dict__'):
+            new_dict = {}
+            for key, value in val.__dict__.items():
+                new_dict[key] = get_val(value)
+            return new_dict
+    obj = get_val(obj)
+    return json.dumps(obj, skipkeys=True)
+
+
+def try_load_json_file(jsonfile):
+    ''' try to load json from file '''
+    try:
+        with open(jsonfile) as f:
+            return json.loads(f.read())
+    except Exception as exc:
+        LOGGER.debug("Could not load json from file %s - %s" % (jsonfile, str(exc)))
+        return None
+
+def load_provider_modules(mass, prov_type=CONF_KEY_MUSICPROVIDERS):
+    ''' dynamically load music/player providers '''
+    provider_modules = {}
+    base_dir = os.path.dirname(os.path.abspath(__file__))
+    modules_path = os.path.join(base_dir, prov_type )
+    for item in os.listdir(modules_path):
+        if (os.path.isfile(os.path.join(modules_path, item)) and not item.startswith("_") and 
+                item.endswith('.py') and not item.startswith('.')):
+            module_name = item.replace(".py","")
+            prov_mod = load_provider_module(mass, module_name, prov_type)
+            if prov_mod:
+                provider_modules[prov_mod.prov_id] = prov_mod
+    return provider_modules
+
+
+def load_provider_module(mass, module_name, prov_type):
+    ''' dynamically load music/player provider '''
+    LOGGER.debug("Loading provider module %s" % module_name)
+    try:
+        prov_mod = importlib.import_module(f".{module_name}", 
+                f"music_assistant.{prov_type}")
+        prov_conf_entries = prov_mod.CONFIG_ENTRIES
+        prov_id = prov_mod.PROV_ID
+        # get/create config for the module
+        prov_config = mass.config.create_module_config(
+                prov_id, prov_conf_entries, prov_type)
+        if prov_config[CONF_ENABLED]:
+            prov_mod_cls = getattr(prov_mod, prov_mod.PROV_CLASS)
+            provider = prov_mod_cls(mass, prov_config)
+            LOGGER.info("Successfully initialized module %s" % provider.name)
+            return provider
+        else:
+            return None
+    except Exception as exc:
+        LOGGER.exception("Error loading module %s: %s" %(module_name, exc))
\ No newline at end of file
index b20d8024bd05c6a65cb0f59df1f3bad0145caf9b..4b849aec1c747295de1f6f86824eac19e485e473 100755 (executable)
@@ -3,7 +3,6 @@
 
 import asyncio
 import os
-import json
 import aiohttp
 from aiohttp import web
 from functools import partial
@@ -12,85 +11,36 @@ import concurrent
 import threading
 from .models.media_types import MediaItem, MediaType, media_type_from_string
 from .models.player import Player
-from .utils import run_periodic, LOGGER, run_async_background_task, get_ip
+from .utils import run_periodic, LOGGER, run_async_background_task, get_ip, json_serializer
 
-#json_serializer = partial(json.dumps, default=lambda x: x.__dict__)
-
-def json_serializer(obj):
-
-    def get_val(val):
-        if isinstance(val, (int, str, bool, float)):
-            return val
-        elif isinstance(val, list):
-            new_list = []
-            for item in val:
-                new_list.append( get_val(item))
-            return new_list
-        elif hasattr(val, 'to_dict'):
-            return get_val(val.to_dict())
-        elif isinstance(val, dict):
-            new_dict = {}
-            for key, value in val.items():
-                new_dict[key] = get_val(value)
-            return new_dict
-        elif hasattr(val, '__dict__'):
-            new_dict = {}
-            for key, value in val.__dict__.items():
-                new_dict[key] = get_val(value)
-            return new_dict
-        
-    obj = get_val(obj)
-    return json.dumps(obj, skipkeys=True)
-
-def setup(mass):
-    ''' setup the module and read/apply config'''
-    create_config_entries(mass.config)
-    conf = mass.config['base']['web']
-    if conf['ssl_certificate'] and os.path.isfile(conf['ssl_certificate']):
-        ssl_cert = conf['ssl_certificate']
-    else:
-        ssl_cert = ''
-    if conf['ssl_key'] and os.path.isfile(conf['ssl_key']):
-        ssl_key = conf['ssl_key']
-    else:
-        ssl_key = ''
-    cert_fqdn_host = conf['cert_fqdn_host']
-    http_port = conf['http_port']
-    https_port = conf['https_port']
-    return Web(mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host)
-
-def create_config_entries(config):
-    ''' get the config entries for this module (list with key/value pairs)'''
-    config_entries = [
+CONF_KEY = 'web'
+CONFIG_ENTRIES = [
         ('http_port', 8095, 'webhttp_port'),
         ('https_port', 8096, 'web_https_port'),
         ('ssl_certificate', '', 'web_ssl_cert'), 
         ('ssl_key', '', 'web_ssl_key'),
         ('cert_fqdn_host', '', 'cert_fqdn_host')
         ]
-    if not config['base'].get('web'):
-        config['base']['web'] = {}
-    config['base']['web']['__desc__'] = config_entries
-    for key, def_value, desc in config_entries:
-        if not key in config['base']['web']:
-            config['base']['web'][key] = def_value
 
 class Web():
     ''' webserver and json/websocket api '''
     
-    def __init__(self, mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host):
+    def __init__(self, mass):
         self.mass = mass
+        # load/create/update config
+        config = self.mass.config.create_module_config(CONF_KEY, CONFIG_ENTRIES)
+        if config['ssl_certificate'] and not os.path.isfile(
+                self.mass.config['ssl_certificate']):
+            raise FileNotFoundError(
+                "SSL certificate file not found: %s" % config['ssl_certificate'])
+        if config['ssl_key'] and not os.path.isfile(config['ssl_key']):
+            raise FileNotFoundError(
+                "SSL certificate key file not found: %s" % config['ssl_key'])
         self.local_ip = get_ip()
-        self.http_port = http_port
-        self._https_port = https_port
-        self._ssl_cert = ssl_cert
-        self._ssl_key = ssl_key
-        self._cert_fqdn_host = cert_fqdn_host
-        self.mass.event_loop.create_task(self.setup())
-
-    def stop(self):
-        asyncio.create_task(self.runner.cleanup())
-        asyncio.create_task(self.http_session.close())
+        self.http_port = config['http_port']
+        self.https_port = config['https_port']
+        self._enable_ssl = config['ssl_certificate'] and config['ssl_key']
+        self.config = config
 
     async def setup(self):
         ''' perform async setup '''
@@ -119,15 +69,16 @@ class Web():
         app.add_routes([web.get('/api/{media_type}/{media_id}/{action}', self.get_item)])
         app.add_routes([web.get('/api/{media_type}/{media_id}', self.get_item)])
         app.add_routes([web.get('/', self.index)])
-        app.router.add_static("/", "./web/")  
+        webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web/')
+        app.router.add_static("/", webdir)
         self.runner = web.AppRunner(app, access_log=None)
         await self.runner.setup()
         http_site = web.TCPSite(self.runner, '0.0.0.0', self.http_port)
         await http_site.start()
-        if self._ssl_cert and self._ssl_key:
+        if self._enable_ssl:
             ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
-            ssl_context.load_cert_chain(self._ssl_cert, self._ssl_key)
-            https_site = web.TCPSite(self.runner, '0.0.0.0', self._https_port, ssl_context=ssl_context)
+            ssl_context.load_cert_chain(self.config['ssl_certificate'], self.config['ssl_key'])
+            https_site = web.TCPSite(self.runner, '0.0.0.0', self.https_port, ssl_context=ssl_context)
             await https_site.start()
 
     async def get_items(self, request):
@@ -211,21 +162,21 @@ class Web():
 
     async def players(self, request):
         ''' get all players '''
-        players = list(self.mass.player.players)
+        players = list(self.mass.players.players)
         players.sort(key=lambda x: x.name, reverse=False)
         return web.json_response(players, dumps=json_serializer)
 
     async def player(self, request):
         ''' get single player '''
         player_id = request.match_info.get('player_id')
-        player = await self.mass.player.get_player(player_id)
+        player = await self.mass.players.get_player(player_id)
         return web.json_response(player, dumps=json_serializer)
 
     async def player_command(self, request):
         ''' issue player command'''
         result = False
         player_id = request.match_info.get('player_id')
-        player = await self.mass.player.get_player(player_id)
+        player = await self.mass.players.get_player(player_id)
         if player:
             cmd = request.match_info.get('cmd')
             cmd_args = request.match_info.get('cmd_args')
@@ -249,7 +200,7 @@ class Web():
         queue_opt = request.match_info.get('queue_opt','')
         provider = request.rel_url.query.get('provider')
         media_item = await self.mass.music.item(media_id, media_type, provider, lazy=True)
-        result = await self.mass.player.play_media(player_id, media_item, queue_opt)
+        result = await self.mass.players.play_media(player_id, media_item, queue_opt)
         return web.json_response(result, dumps=json_serializer) 
     
     async def player_queue(self, request):
@@ -257,15 +208,17 @@ class Web():
         player_id = request.match_info.get('player_id')
         limit = int(request.query.get('limit', 50))
         offset = int(request.query.get('offset', 0))
-        player = await self.mass.player.get_player(player_id)
+        player = await self.mass.players.get_player(player_id)
         # queue_items = player.queue.items
         # queue_items = [item.__dict__ for item in queue_items]
         # print(queue_items)
         # result = queue_items[offset:limit]
         return web.json_response(player.queue.items[offset:limit], dumps=json_serializer) 
     
-    async def index(self, request):  
-        return web.FileResponse("./web/index.html")
+    async def index(self, request):
+        index_file = os.path.join(
+                os.path.dirname(os.path.abspath(__file__)), 'web/index.html')
+        return web.FileResponse(index_file)
 
     async def websocket_handler(self, request):
         ''' websockets handler '''
@@ -285,7 +238,7 @@ class Web():
                     continue
                 # for now we only use WS for (simple) player commands
                 if msg.data == 'players':
-                    players = list(self.mass.player.players)
+                    players = list(self.mass.players.players)
                     players.sort(key=lambda x: x.name, reverse=False)
                     ws_msg = {'message': 'players', 'message_details': players}
                     await ws.send_json(ws_msg, dumps=json_serializer)
@@ -295,14 +248,12 @@ class Web():
                     player_id = msg_data_parts[1]
                     cmd = msg_data_parts[3]
                     cmd_args = msg_data_parts[4] if len(msg_data_parts) == 5 else None
-                    player = await self.mass.player.get_player(player_id)
+                    player = await self.mass.players.get_player(player_id)
                     player_cmd = getattr(player, cmd, None)
                     if player_cmd and cmd_args:
                         result = await player_cmd(cmd_args)
                     elif player_cmd:
                         result = await player_cmd()
-        except Exception as exc:
-            LOGGER.exception(exc)
         finally:
             await self.mass.remove_event_listener(cb_id)
         LOGGER.debug('websocket connection closed')
@@ -345,7 +296,7 @@ class Web():
         player_id = params[0]
         cmds = params[1]
         cmd_str = " ".join(cmds)
-        player = await self.mass.player.get_player(player_id)
+        player = await self.mass.players.get_player(player_id)
         if cmd_str == 'play':
             await player.play()
         elif cmd_str == 'pause':
diff --git a/music_assistant/web/components/headermenu.vue.js b/music_assistant/web/components/headermenu.vue.js
new file mode 100755 (executable)
index 0000000..f0e8566
--- /dev/null
@@ -0,0 +1,68 @@
+Vue.component("headermenu", {
+  template: `<div>
+    <v-navigation-drawer dark app clipped temporary v-model="menu">
+        <v-list >
+            <v-list-tile
+               v-for="item in items" :key="item.title" @click="$router.push(item.path)">
+                <v-list-tile-action>
+                    <v-icon>{{ item.icon }}</v-icon>
+                </v-list-tile-action>
+                <v-list-tile-content>
+                    <v-list-tile-title>{{ item.title }}</v-list-tile-title>
+                </v-list-tile-content>
+            </v-list-tile>
+        </v-list>
+    </v-navigation-drawer>
+    
+
+    <v-toolbar app flat dense dark v-if="$globals.windowtitle" > 
+        <div class="title justify-center" style="text-align:center;position:absolute;width:100%;margin-left:-16px;margin-right:0">
+          {{ $globals.windowtitle }}
+        </div>
+        <v-layout align-center>
+            <v-btn icon v-on:click="menu=!menu">
+              <v-icon>menu</v-icon>
+            </v-btn>
+            <v-btn @click="$router.go(-1)" icon v-if="$route.path != '/'">
+              <v-icon>arrow_back</v-icon>
+            </v-btn>
+        </v-layout>
+    </v-toolbar>
+    <v-toolbar flat fixed dense dark scroll-off-screen color="transparent" v-if="!$globals.windowtitle" > 
+        <v-layout align-center>
+            <v-btn icon v-on:click="menu=!menu">
+              <v-icon>menu</v-icon>
+            </v-btn>
+            <v-btn @click="$router.go(-1)" icon>
+              <v-icon>arrow_back</v-icon>
+            </v-btn>
+            <v-spacer></v-spacer>
+            <v-spacer></v-spacer>
+            <v-btn icon v-on:click="$router.push({path: '/search'})">
+                <v-icon>search</v-icon>
+              </v-btn>
+        </v-layout>
+    </v-toolbar>
+</div>`,
+  props: [],
+  $_veeValidate: {
+    validator: "new"
+  },
+  data() {
+    return {
+      menu: false,
+      items: [
+        { title: this.$t('home'), icon: "home", path: "/" },
+        { title: this.$t('artists'), icon: "person", path: "/artists" },
+        { title: this.$t('albums'), icon: "album", path: "/albums" },
+        { title: this.$t('tracks'), icon: "audiotrack", path: "/tracks" },
+        { title: this.$t('playlists'), icon: "playlist_play", path: "/playlists" },
+        { title: this.$t('radios'), icon: "radio", path: "/radios" },
+        { title: this.$t('search'), icon: "search", path: "/search" },
+        { title: this.$t('settings'), icon: "settings", path: "/config" }
+      ]
+    }
+  },
+  mounted() { },
+  methods: { }
+})
diff --git a/music_assistant/web/components/infoheader.vue.js b/music_assistant/web/components/infoheader.vue.js
new file mode 100644 (file)
index 0000000..9d8bc8c
--- /dev/null
@@ -0,0 +1,135 @@
+Vue.component("infoheader", {\r
+       template: `\r
+               <v-flex xs12>\r
+          <v-card color="cyan darken-2" class="white--text" img="../images/info_gradient.jpg">\r
+            <v-img\r
+              class="white--text"\r
+              width="100%"\r
+              :height="isMobile() ? '230' : '370'"\r
+              position="center top" \r
+              :src="getFanartImage()"\r
+              gradient="to bottom, rgba(0,0,0,.65), rgba(0,0,0,.35)"\r
+            >\r
+            <div class="text-xs-center" style="height:40px" id="whitespace_top"/>\r
+\r
+            <v-layout style="margin-left:5px;margin-right:5px">\r
+              \r
+              <!-- left side: cover image -->\r
+              <v-flex xs5 pa-4 v-if="!isMobile()">\r
+                                                               <v-img :src="getThumb()" lazy-src="/images/default_artist.png" width="250px" height="250px" style="border: 4px solid grey;border-radius: 15px;"></v-img>\r
+                                                               \r
+                                                               <!-- tech specs and provider icons -->\r
+                                                               <div style="margin-top:10px;">\r
+                                                                       <providericons v-bind:item="info" :height="30" :compact="false"/>\r
+                                                               </div>\r
+              </v-flex>\r
+              \r
+              <v-flex>\r
+                  <!-- Main title -->\r
+                  <v-card-title class="display-1" style="text-shadow: 1px 1px #000000;padding-bottom:0px;">\r
+                                                                                       {{ info.name }} \r
+                                                                                       <span class="subheading" v-if="!!info.version" style="padding-left:10px;"> ({{ info.version }})</span>\r
+                                                                       </v-card-title>\r
+                                                                       \r
+                                                                       <!-- item artists -->\r
+                                                                       <v-card-title style="text-shadow: 1px 1px #000000;padding-top:0px;padding-bottom:10px;">\r
+                                                                                       <span v-if="!!info.artists" v-for="(artist, artistindex) in info.artists" class="headline" :key="artist.db_id">\r
+                                                                                                       <a style="color:#2196f3" v-on:click="clickItem(artist)">{{ artist.name }}</a>\r
+                                                                                                       <label style="color:#2196f3" v-if="artistindex + 1 < info.artists.length" :key="artistindex"> / </label>\r
+                                                                                       </span>\r
+                                                                                       <span v-if="info.artist" class="headline">\r
+                                                                                                       <a style="color:#2196f3" v-on:click="clickItem(info.artist)">{{ info.artist.name }}</a>\r
+                                                                                       </span>\r
+                                                                                       <span v-if="info.owner" class="headline">\r
+                                                                                                       <a style="color:#2196f3" v-on:click="">{{ info.owner }}</a>\r
+                                                                                       </span>\r
+                                                                       </v-card-title>\r
+\r
+                                                                       <v-card-title v-if="info.album" style="color:#ffffff;text-shadow: 1px 1px #000000;padding-top:0px;padding-bottom:10px;">\r
+                                                                                       <a class="headline" style="color:#ffffff" v-on:click="clickItem(info.album)">{{ info.album.name }}</a>\r
+                                                                       </v-card-title>\r
+\r
+                  <!-- play/info buttons -->\r
+                  <div style="margin-left:8px;">\r
+                      <v-btn color="blue-grey" @click="showPlayMenu(info)"  class="white--text"><v-icon v-if="!isMobile()" left dark>play_circle_outline</v-icon>{{ $t('play') }}</v-btn>\r
+                      <v-btn v-if="!!info.in_library && info.in_library.length == 0" color="blue-grey" @click="toggleLibrary(info)"  class="white--text"><v-icon v-if="!isMobile()" left dark>favorite_border</v-icon>{{ $t('add_library') }}</v-btn>\r
+                      <v-btn v-if="!!info.in_library && info.in_library.length > 0" color="blue-grey" @click="toggleLibrary(info)"  class="white--text"><v-icon v-if="!isMobile()" left dark>favorite</v-icon>{{ $t('remove_library') }}</v-btn>\r
+                  </div>\r
+\r
+                  <!-- Description/metadata -->\r
+                  <v-card-title class="subheading">\r
+                      <div class="justify-left" style="text-shadow: 1px 1px #000000;">\r
+                          <read-more :text="getDescription()" :max-chars="isMobile() ? 60 : 350"></read-more>\r
+                      </div>\r
+                  </v-card-title>\r
+\r
+              </v-flex>\r
+            </v-layout>\r
+              \r
+            </v-img>\r
+            <div class="text-xs-center" v-if="info.tags">\r
+                <v-chip small color="white"  outline v-for="(tag, index) in info.tags" :key="tag" >{{ tag }}</v-chip>\r
+            </div>\r
+            \r
+          </v-card>\r
+        </v-flex>\r
+`,\r
+       props: ['info'],\r
+       data (){\r
+               return{}\r
+       },\r
+       mounted() { },\r
+       created() { },\r
+       methods: { \r
+               getFanartImage() {\r
+                       var img = '';\r
+                       if (!this.info)\r
+                               return ''\r
+      if (this.info.metadata && this.info.metadata.fanart)\r
+                               img = this.info.metadata.fanart;\r
+                       else if (this.info.artists)\r
+                                       this.info.artists.forEach(function(artist) {\r
+                                               if (artist.metadata && artist.metadata.fanart)\r
+                                                       img = artist.metadata.fanart;\r
+                                       });\r
+                       else if (this.info.artist && this.info.artist.metadata.fanart)\r
+                               img = this.info.artist.metadata.fanart;\r
+                       return img;\r
+               },\r
+               getThumb() {\r
+                       var img = '';\r
+                       if (!this.info)\r
+                               return ''\r
+      if (this.info.metadata && this.info.metadata.image)\r
+                               img = this.info.metadata.image;\r
+                       else if (this.info.album && this.info.album.metadata && this.info.album.metadata.image)\r
+                               img = this.info.album.metadata.image;\r
+                       else if (this.info.artists)\r
+                                       this.info.artists.forEach(function(artist) {\r
+                                               if (artist.metadata && artist.metadata.image)\r
+                                                       img = artist.metadata.image;\r
+                                       });\r
+                       return img;\r
+               },\r
+               getDescription() {\r
+                       var desc = '';\r
+                       if (!this.info)\r
+                               return ''\r
+      if (this.info.metadata && this.info.metadata.description)\r
+                               return this.info.metadata.description;\r
+                       else if (this.info.metadata && this.info.metadata.biography)\r
+                               return this.info.metadata.biography;\r
+                       else if (this.info.metadata && this.info.metadata.copyright)\r
+                               return this.info.metadata.copyright;\r
+                       else if (this.info.artists)\r
+                       {\r
+                               this.info.artists.forEach(function(artist) {\r
+                                       console.log(artist.metadata.biography);\r
+                                       if (artist.metadata && artist.metadata.biography)\r
+                                                       desc = artist.metadata.biography;\r
+                               });\r
+                       }\r
+                       return desc;\r
+               },\r
+       }\r
+})\r
diff --git a/music_assistant/web/components/listviewItem.vue.js b/music_assistant/web/components/listviewItem.vue.js
new file mode 100755 (executable)
index 0000000..687c69c
--- /dev/null
@@ -0,0 +1,75 @@
+Vue.component("listviewItem", {
+  template: `
+    <div>
+    <v-list-tile
+    avatar
+    ripple
+    @click="clickItem(item)">
+
+          <v-list-tile-avatar color="grey" v-if="!hideavatar">
+              <img v-if="(item.media_type != 3) && item.metadata && item.metadata.image" :src="item.metadata.image"/>
+              <img v-if="(item.media_type == 3) && item.album && item.album.metadata && item.album.metadata.image" :src="item.album.metadata.image"/>
+              <v-icon v-if="(item.media_type == 3) && item.album && item.album.metadata && !item.album.metadata.image">audiotrack</v-icon>
+              <v-icon v-if="(item.media_type != 1 && item.media_type != 3) && (!item.metadata || !item.metadata.image)">album</v-icon>
+              <v-icon v-if="(item.media_type == 1) && (!item.metadata || !item.metadata.image)">person</v-icon>
+              <v-icon v-if="(item.media_type == 3) && (!item.metadata || !item.album.metadata.image)">audiotrack</v-icon>
+          </v-list-tile-avatar>
+          
+          <v-list-tile-content>
+            
+            <v-list-tile-title>
+                {{ item.name }}<span v-if="!!item.version"> ({{ item.version }})</span>
+            </v-list-tile-title>
+            
+            <v-list-tile-sub-title v-if="item.artists">
+                <span v-for="(artist, artistindex) in item.artists">
+                    <a v-on:click="clickItem(artist)" @click.stop="">{{ artist.name }}</a>
+                    <label v-if="artistindex + 1 < item.artists.length" :key="artistindex"> / </label>
+                </span>
+                <a v-if="!!item.album && !!hidetracknum" v-on:click="clickItem(item.album)" @click.stop="" style="color:grey">  -  {{ item.album.name }}</a>
+                <label v-if="!hidetracknum && item.track_number" style="color:grey">  -  disc {{ item.disc_number }} track {{ item.track_number }}</label>
+            </v-list-tile-sub-title>
+            <v-list-tile-sub-title v-if="item.artist">
+                <a v-on:click="clickItem(item.artist)" @click.stop="">{{ item.artist.name }}</a>
+            </v-list-tile-sub-title>
+
+            <v-list-tile-sub-title v-if="!!item.owner">
+                {{ item.owner }}
+            </v-list-tile-sub-title>
+
+          </v-list-tile-content>
+
+          <providericons v-bind:item="item" :height="20" :compact="true" :dark="true" :hiresonly="hideproviders"/>
+
+          <v-list-tile-action v-if="!hidelibrary">
+              <v-tooltip bottom>
+                  <template v-slot:activator="{ on }">
+                      <v-btn icon ripple v-on="on" v-on:click="toggleLibrary(item)" @click.stop="" >
+                          <v-icon height="20" v-if="item.in_library.length > 0">favorite</v-icon>
+                          <v-icon height="20" v-if="item.in_library.length == 0">favorite_border</v-icon>
+                      </v-btn>
+                  </template>
+                  <span v-if="item.in_library.length > 0">{{ $t('remove_library') }}</span>
+                  <span v-if="item.in_library.length == 0">{{ $t('add_library') }}</span>
+              </v-tooltip>
+          </v-list-tile-action>
+
+          <v-list-tile-action v-if="!hideduration && !!item.duration">
+              {{ item.duration.toString().formatDuration() }}
+          </v-list-tile-action> 
+        
+          <!-- menu button/icon -->
+          <v-icon v-if="!hidemenu" @click="showPlayMenu(item)" @click.stop="" color="grey lighten-1" style="margin-right:-10px;padding-left:10px">more_vert</v-icon>
+          
+
+        </v-list-tile>
+        <v-divider v-if="index + 1 < totalitems" :key="index"></v-divider>
+        </div>
+     `,
+props: ['item', 'index', 'totalitems', 'hideavatar', 'hidetracknum', 'hideproviders', 'hidemenu', 'hidelibrary', 'hideduration'],
+data() {
+  return {}
+  },
+methods: {
+  }
+})
diff --git a/music_assistant/web/components/player.vue.js b/music_assistant/web/components/player.vue.js
new file mode 100755 (executable)
index 0000000..d8b9859
--- /dev/null
@@ -0,0 +1,321 @@
+Vue.component("player", {
+  template: `
+  <div>
+
+    <!-- player bar in footer -->
+    <v-footer app light height="auto">
+      
+      <v-card class="flex" tile style="background-color:#e8eaed;">
+        <!-- divider -->
+        <v-list-tile avatar ripple style="height:1px;background-color:#cccccc;"/>
+
+        <!-- now playing media -->
+        <v-list-tile avatar ripple>
+
+              <v-list-tile-avatar v-if="active_player.cur_item" style="align-items:center;padding-top:15px;">
+                  <img v-if="active_player.cur_item.metadata && active_player.cur_item.metadata.image" :src="active_player.cur_item.metadata.image"/>
+                  <img v-if="!active_player.cur_item.metadata.image && active_player.cur_item.album && active_player.cur_item.album.metadata && active_player.cur_item.album.metadata.image" :src="active_player.cur_item.album.metadata.image"/>
+              </v-list-tile-avatar>
+
+              <v-list-tile-content style="align-items:center;padding-top:15px;">
+                  <v-list-tile-title class="title">{{ active_player.cur_item ? active_player.cur_item.name : active_player.name }}</v-list-tile-title>
+                  <v-list-tile-sub-title v-if="active_player.cur_item && active_player.cur_item.artists">
+                      <span v-for="(artist, artistindex) in active_player.cur_item.artists">
+                          <a v-on:click="clickItem(artist)" @click.stop="">{{ artist.name }}</a>
+                          <label v-if="artistindex + 1 < active_player.cur_item.artists.length" :key="artistindex"> / </label>
+                      </span>
+                  </v-list-tile-sub-title>
+              </v-list-tile-content>
+
+          </v-list-tile>
+
+          <!-- progress bar -->
+          <div style="color:rgba(0,0,0,.65); height:30px;width:100%; vertical-align: middle; left:15px; right:0; margin-bottom:5px; margin-top:5px">
+            <v-layout row style="vertical-align: middle" v-if="active_player.cur_item">
+              <span style="text-align:left; width:60px; margin-top:7px; margin-left:15px;">{{ player_time_str_cur }}</span>
+              <v-progress-linear v-model="progress"></v-progress-linear>
+              <span style="text-align:right; width:60px; margin-top:7px; margin-right: 15px;">{{ player_time_str_total }}</span>
+            </v-layout>
+        </div>
+
+        <!-- divider -->
+        <v-list-tile avatar ripple style="height:1px;background-color:#cccccc;"/>
+
+          <!-- Control buttons -->
+          <v-list-tile light avatar ripple style="margin-bottom:5px;">
+              
+          <!-- player controls -->
+              <v-list-tile-content>
+                  <v-layout row style="content-align: left;vertical-align: middle; margin-top:10px;margin-left:-15px">
+                    <v-btn small icon style="padding:5px;" @click="playerCommand('previous')"><v-icon color="rgba(0,0,0,.54)">skip_previous</v-icon></v-btn>
+                    <v-btn small icon style="padding:5px;" v-if="active_player.state == 'playing'" @click="playerCommand('pause')"><v-icon size="45" color="rgba(0,0,0,.65)" style="margin-top:-9px;">pause</v-icon></v-btn>
+                    <v-btn small icon style="padding:5px;" v-if="active_player.state != 'playing'" @click="playerCommand('play')"><v-icon size="45" color="rgba(0,0,0,.65)" style="margin-top:-9px;">play_arrow</v-icon></v-btn>
+                    <v-btn small icon style="padding:5px;" @click="playerCommand('next')"><v-icon color="rgba(0,0,0,.54)">skip_next</v-icon></v-btn>
+                  </v-layout>
+              </v-list-tile-content>
+
+              <!-- active player queue button -->
+              <v-list-tile-action style="padding:20px;" v-if="active_player_id">
+                  <v-btn x-small flat icon @click="$router.push('/queue/' + active_player_id)">
+                      <v-flex xs12 class="vertical-btn">
+                      <v-icon>queue_music</v-icon>
+                      <span class="caption">{{ $t('queue') }}</span>
+                    </v-flex>    
+                  </v-btn>
+              </v-list-tile-action> 
+
+              <!-- active player volume -->
+              <v-list-tile-action style="padding:20px;" v-if="active_player_id">
+                  <v-menu :close-on-content-click="false" :nudge-width="250" offset-x top>
+                    <template v-slot:activator="{ on }">
+                        <v-btn x-small flat icon v-on="on">
+                            <v-flex xs12 class="vertical-btn">
+                            <v-icon>volume_up</v-icon>
+                            <span class="caption">{{ Math.round(players[active_player_id].volume_level) }}</span>
+                          </v-flex>    
+                        </v-btn>
+                    </template>
+                    <volumecontrol v-bind:players="players" v-bind:player_id="active_player_id" v-on:setPlayerVolume="setPlayerVolume" v-on:togglePlayerPower="togglePlayerPower"/>
+                  </v-menu>
+              </v-list-tile-action> 
+
+              <!-- active player btn -->
+              <v-list-tile-action style="padding:30px;margin-right:-13px;">
+                  <v-btn x-small flat icon @click="menu = !menu">
+                      <v-flex xs12 class="vertical-btn">
+                      <v-icon>speaker</v-icon>
+                      <span class="caption">{{ active_player_id ? players[active_player_id].name : '' }}</span>
+                    </v-flex>    
+                  </v-btn>
+              </v-list-tile-action>
+          </v-list-tile>
+
+          <!-- add some additional whitespace in standalone mode only -->
+          <v-list-tile avatar ripple style="height:14px" v-if="isInStandaloneMode()"/>
+
+          
+
+      </v-card>
+    </v-footer>
+
+    <!-- players side menu -->
+    <v-navigation-drawer right app clipped temporary v-model="menu">
+        <v-card-title class="headline">
+            <b>{{ $t('players') }}</b>
+        </v-card-title>
+        <v-list two-line>
+            <v-divider></v-divider>
+            <div v-for="(player, player_id, index) in players" :key="player_id" v-if="player.enabled && !player.group_parent">
+              <v-list-tile avatar ripple style="margin-left: -5px; margin-right: -15px" @click="switchPlayer(player.player_id)" :style="active_player_id == player.player_id ? 'background-color: rgba(50, 115, 220, 0.3);' : ''">
+                  <v-list-tile-avatar>
+                      <v-icon size="45">{{ isGroup(player.player_id) ? 'speaker_group' : 'speaker' }}</v-icon>
+                  </v-list-tile-avatar>
+                  <v-list-tile-content>
+                      <v-list-tile-title class="title">{{ player.name }}</v-list-tile-title>
+
+                      <v-list-tile-sub-title v-if="player.cur_item" class="body-1" :key="player.state">
+                          {{ $t('state.' + player.state) }}
+                      </v-list-tile-sub-title>
+
+                  </v-list-tile-content>
+
+                  <v-list-tile-action style="padding:30px;" v-if="active_player_id">
+                      <v-menu :close-on-content-click="false" :nudge-width="250" offset-x right>
+                        <template v-slot:activator="{ on }">
+                            <v-btn flat icon style="color:rgba(0,0,0,.54);" v-on="on">
+                                <v-flex xs12 class="vertical-btn">
+                                <v-icon>volume_up</v-icon>
+                                <span class="caption">{{ Math.round(player.volume_level) }}</span>
+                              </v-flex>    
+                            </v-btn>
+                        </template>
+                        <volumecontrol v-bind:players="players" v-bind:player_id="player.player_id" v-on:setPlayerVolume="setPlayerVolume" v-on:togglePlayerPower="togglePlayerPower"/>
+                      </v-menu>
+                  </v-list-tile-action> 
+              </v-list-tile>
+            <v-divider></v-divider>
+            </div>
+        </v-list>
+    </v-navigation-drawer>
+    <playmenu v-model="$globals.showplaymenu" v-on:playItem="playItem" :active_player="active_player" />
+  </div>
+  
+  `,
+  props: [],
+  $_veeValidate: {
+    validator: "new"
+  },
+  watch: {},
+  data() {
+    return {
+      menu: false,
+      players: {},
+      active_player_id: "",
+      ws: null
+    }
+  },
+  mounted() { },
+  created() {
+    this.connectWS();
+    this.updateProgress();
+  },
+  computed: {
+
+    active_player() {
+      if (this.players && this.active_player_id && this.active_player_id in this.players)
+          return this.players[this.active_player_id];
+      else
+          return {
+            name: 'no player selected',
+            cur_item: null,
+            cur_time: 0,
+            player_id: '',
+            volume_level: 0,
+            state: 'stopped'
+          };
+    },
+    progress() {
+      if (!this.active_player.cur_item)
+        return 0;
+      var total_sec = this.active_player.cur_item.duration;
+      var cur_sec = this.active_player.cur_time;
+      var cur_percent = cur_sec/total_sec*100;
+      return cur_percent;
+    },
+    player_time_str_cur() {
+      if (!this.active_player.cur_item || !this.active_player.cur_time)
+        return "0:00";
+      var cur_sec = this.active_player.cur_time;
+      return cur_sec.toString().formatDuration();
+    },
+    player_time_str_total() {
+      if (!this.active_player.cur_item)
+        return "0:00";
+      var total_sec = this.active_player.cur_item.duration;
+      return total_sec.toString().formatDuration();
+    }
+  },
+  methods: { 
+    playerCommand (cmd, cmd_opt=null, player_id=this.active_player_id) {
+      if (cmd_opt)
+        cmd = cmd + '/' + cmd_opt
+      cmd = 'players/' + player_id + '/cmd/' + cmd;
+      this.ws.send(cmd);
+    },
+    playItem(item, queueopt) {
+      console.log('playItem: ' + item);
+      this.$globals.loading = true;
+      var api_url = 'api/players/' + this.active_player_id + '/play_media/' + item.media_type + '/' + item.item_id + '/' + queueopt;
+      axios
+      .get(api_url, {
+        params: {
+          provider: item.provider
+        }
+      })
+      .then(result => {
+        console.log(result.data);
+        this.$globals.loading = false;
+      })
+      .catch(error => {
+        console.log("error", error);
+        this.$globals.loading = false;
+      });
+    },
+    switchPlayer (new_player_id) {
+      this.active_player_id = new_player_id;
+    },
+    isGroup(player_id) {
+                       for (var item in this.players)
+                               if (this.players[item].group_parent == player_id && this.players[item].enabled)
+                                       return true;
+                       return false;
+    },
+    updateProgress: function(){           
+      this.intervalid2 = setInterval(function(){
+          if (this.active_player.state == 'playing')
+              this.active_player.cur_time +=1;
+      }.bind(this), 1000);
+    },
+    setPlayerVolume: function(player_id, new_volume) {
+      this.players[player_id].volume_level = new_volume;
+      if (new_volume == 'up')
+        this.playerCommand('volume_up', null, player_id);
+      else if (new_volume == 'down')
+        this.playerCommand('volume_down', null, player_id);
+      else
+        this.playerCommand('volume_set', new_volume, player_id);
+    },
+    togglePlayerPower: function(player_id) {
+      if (this.players[player_id].powered)
+        this.playerCommand('power_off', null, player_id);
+      else
+        this.playerCommand('power_on', null, player_id);
+    },
+    connectWS() {
+      var loc = window.location, new_uri;
+      if (loc.protocol === "https:") {
+          new_uri = "wss:";
+      } else {
+          new_uri = "ws:";
+      }
+      new_uri += "/" + loc.host;
+      new_uri += loc.pathname + "ws";
+      this.ws = new WebSocket(new_uri);
+
+      this.ws.onopen = function() {
+        console.log('websocket connected!');
+        this.ws.send('players');
+      }.bind(this);
+    
+      this.ws.onmessage = function(e) {
+        var msg = JSON.parse(e.data);
+        if (msg.message == 'player changed')
+          {
+            Vue.set(this.players, msg.message_details.player_id, msg.message_details);
+        }
+        else if (msg.message == 'player removed') {
+          this.players[msg.message_details.player_id].enabled = false;
+        }
+        else if (msg.message == 'players') {
+          for (var item of msg.message_details) {
+              console.log("new player: " + item.player_id);
+              Vue.set(this.players, item.player_id, item);
+          }
+        }
+        else
+          console.log(msg);
+
+        // select new active player
+        // TODO: store previous player in local storage
+        if (!this.active_player_id || !this.players[this.active_player_id].enabled)
+          for (var player_id in this.players)
+            if (this.players[player_id].state == 'playing' && this.players[player_id].enabled && !this.players[player_id].group_parent) {
+              // prefer the first playing player
+              this.active_player_id = player_id;
+              break; 
+            }
+            if (!this.active_player_id || !this.players[this.active_player_id].enabled)
+          for (var player_id in this.players) {
+            // fallback to just the first player
+            if (this.players[player_id].enabled && !this.players[player_id].group_parent)
+            {
+              this.active_player_id = player_id;
+              break; 
+            }
+          }
+      }.bind(this);
+    
+      this.ws.onclose = function(e) {
+        console.log('Socket is closed. Reconnect will be attempted in 5 seconds.', e.reason);
+        setTimeout(function() {
+          this.connectWS();
+        }.bind(this), 5000);
+      }.bind(this);
+    
+      this.ws.onerror = function(err) {
+        console.error('Socket encountered error: ', err.message, 'Closing socket');
+        this.ws.close();
+      }.bind(this);
+    }
+  }
+})
diff --git a/music_assistant/web/components/playmenu.vue.js b/music_assistant/web/components/playmenu.vue.js
new file mode 100644 (file)
index 0000000..611ecc3
--- /dev/null
@@ -0,0 +1,93 @@
+Vue.component("playmenu", {\r
+       template: `\r
+       <v-dialog :value="value" @input="$emit('input', $event)" max-width="500px" v-if="$globals.playmenuitem">\r
+        <v-card>\r
+               <v-list>\r
+               <v-subheader class="title">{{ !!$globals.playmenuitem ? $globals.playmenuitem.name : '' }}</v-subheader>\r
+                       <v-subheader>{{ $t('play_on') }} {{ active_player.name }}</v-subheader>\r
+                       \r
+                       <v-list-tile avatar @click="itemClick('play')">\r
+                               <v-list-tile-avatar>\r
+                                       <v-icon>play_circle_outline</v-icon>\r
+                               </v-list-tile-avatar>\r
+                               <v-list-tile-content>\r
+                                       <v-list-tile-title>{{ $t('play_now') }}</v-list-tile-title>\r
+                               </v-list-tile-content>\r
+                       </v-list-tile>\r
+                       <v-divider></v-divider>\r
+\r
+                       <v-list-tile avatar @click="itemClick('next')">\r
+                               <v-list-tile-avatar>\r
+                                       <v-icon>queue_play_next</v-icon>\r
+                               </v-list-tile-avatar>\r
+                               <v-list-tile-content>\r
+                                       <v-list-tile-title>{{ $t('play_next') }}</v-list-tile-title>\r
+                               </v-list-tile-content>\r
+                       </v-list-tile>\r
+                       <v-divider></v-divider>\r
+\r
+                       <v-list-tile avatar @click="itemClick('add')">\r
+                               <v-list-tile-avatar>\r
+                                       <v-icon>playlist_add</v-icon>\r
+                               </v-list-tile-avatar>\r
+                               <v-list-tile-content>\r
+                                       <v-list-tile-title>{{ $t('add_queue') }}</v-list-tile-title>\r
+                               </v-list-tile-content>\r
+                       </v-list-tile>\r
+                       <v-divider></v-divider>\r
+\r
+                       <v-list-tile avatar @click="itemClick('info')" v-if="$globals.playmenuitem.media_type == 3">\r
+                               <v-list-tile-avatar>\r
+                                       <v-icon>info</v-icon>\r
+                               </v-list-tile-avatar>\r
+                               <v-list-tile-content>\r
+                                       <v-list-tile-title>{{ $t('show_info') }}</v-list-tile-title>\r
+                               </v-list-tile-content>\r
+                       </v-list-tile>\r
+                       <v-divider v-if="$globals.playmenuitem.media_type == 3"/>\r
+\r
+                       <v-list-tile avatar @click="itemClick('add_playlist')" v-if="$globals.playmenuitem.media_type == 3">\r
+                               <v-list-tile-avatar>\r
+                                       <v-icon>add_circle_outline</v-icon>\r
+                               </v-list-tile-avatar>\r
+                               <v-list-tile-content>\r
+                                       <v-list-tile-title>{{ $t('add_playlist') }}</v-list-tile-title>\r
+                               </v-list-tile-content>\r
+                       </v-list-tile>\r
+                       <v-divider v-if="$globals.playmenuitem.media_type == 3"/>\r
+\r
+                       <v-list-tile avatar @click="itemClick('remove_playlist')" v-if="$globals.playmenuitem.media_type == 3 && this.$route.path.startsWith('/playlists/')">\r
+                               <v-list-tile-avatar>\r
+                                       <v-icon>remove_circle_outline</v-icon>\r
+                               </v-list-tile-avatar>\r
+                               <v-list-tile-content>\r
+                                       <v-list-tile-title>{{ $t('remove_playlist') }}</v-list-tile-title>\r
+                               </v-list-tile-content>\r
+                       </v-list-tile>\r
+                       <v-divider v-if="$globals.playmenuitem.media_type == 3  && this.$route.path.startsWith('/playlists/')"/>\r
+                       \r
+               </v-list>\r
+        </v-card>\r
+      </v-dialog>\r
+`,\r
+       props: ['value', 'active_player'],\r
+       data (){\r
+               return{\r
+                       fav: true,\r
+                       message: false,\r
+                       hints: true,\r
+                       }\r
+       },\r
+       mounted() { },\r
+       created() { },\r
+       methods: { \r
+               itemClick(cmd) {\r
+               if (cmd == 'info')\r
+                               this.$router.push({ path: '/tracks/' + this.$globals.playmenuitem.item_id, query: {provider: this.$globals.playmenuitem.provider}})\r
+                       else\r
+                               this.$emit('playItem', this.$globals.playmenuitem, cmd)\r
+                       // close dialog\r
+                       this.$globals.showplaymenu = false;\r
+       },\r
+       }\r
+  })\r
diff --git a/music_assistant/web/components/providericons.vue.js b/music_assistant/web/components/providericons.vue.js
new file mode 100644 (file)
index 0000000..0e0124d
--- /dev/null
@@ -0,0 +1,68 @@
+Vue.component("providericons", {\r
+       template: `\r
+               <div :style="'height:' + height + 'px;'">\r
+                       <span v-for="provider in uniqueProviders" :key="provider.item_id" style="padding:5px;vertical-align: middle;" v-if="!hiresonly || provider.quality > 6">\r
+                               <v-tooltip bottom>\r
+                                       <template v-slot:activator="{ on }">\r
+                                               <img v-on="on" :height="height" src="images/icons/hires.png" v-if="provider.quality > 6" style="margin-right:9px"/>\r
+                                               <img v-on="on" :height="height" :src="'images/icons/' + provider.provider + '.png'" v-if="!hiresonly"/>\r
+                                       </template>\r
+                                       <div align="center" v-if="item.media_type == 3">\r
+                                               <img height="35px" :src="getFileFormatLogo(provider)"/>\r
+                                               <span><br>{{ getFileFormatDesc(provider) }}</span>\r
+                                       </div>\r
+                                       <span v-if="item.media_type != 3">{{ provider.provider }}</span>\r
+                               </v-tooltip> \r
+                       </span>         \r
+               </div>\r
+`,\r
+       props: ['item','height','compact', 'dark', 'hiresonly'],\r
+       data (){\r
+               return{}\r
+       },\r
+       mounted() { },\r
+       created() { },\r
+       computed: {\r
+               uniqueProviders() {\r
+                       var keys = [];\r
+                       var qualities = [];\r
+                       if (!this.item || !this.item.provider_ids)\r
+                               return []\r
+                       let sorted_item_ids = this.item.provider_ids.sort((a,b) => (a.quality < b.quality) ? 1 : ((b.quality < a.quality) ? -1 : 0));\r
+                       if (!this.compact)\r
+                               return sorted_item_ids;\r
+                       for (provider of sorted_item_ids) {\r
+                               if (!keys.includes(provider.provider)){\r
+                                       qualities.push(provider);\r
+                                       keys.push(provider.provider);\r
+                               }\r
+                       }\r
+                       return qualities;\r
+               }\r
+       },\r
+       methods: { \r
+\r
+               getFileFormatLogo(provider) {\r
+                       if (provider.quality == 0)\r
+                               return 'images/icons/mp3.png'\r
+                       else if (provider.quality == 1)\r
+                               return 'images/icons/vorbis.png'\r
+                       else if (provider.quality == 2)\r
+                               return 'images/icons/aac.png'\r
+                       else if (provider.quality > 2)\r
+                               return 'images/icons/flac.png'\r
+                       },\r
+               getFileFormatDesc(provider) {\r
+                       var desc = '';\r
+                       if (provider.details)\r
+                               desc += ' ' + provider.details;\r
+                       return desc;\r
+               },\r
+               getMaxQualityFormatDesc() {\r
+                       var desc = '';\r
+                       if (provider.details)\r
+                               desc += ' ' + provider.details;\r
+                       return desc;\r
+               }\r
+    }\r
+  })\r
diff --git a/music_assistant/web/components/readmore.vue.js b/music_assistant/web/components/readmore.vue.js
new file mode 100644 (file)
index 0000000..6af2fd3
--- /dev/null
@@ -0,0 +1,63 @@
+Vue.component("read-more", {\r
+       template: `\r
+       <div>\r
+               <span v-html="formattedString"/> <a style="color:white" :href="link" id="readmore" v-if="text.length > maxChars" v-on:click="triggerReadMore($event, true)">{{moreStr}}</a></p>\r
+               <v-dialog v-model="isReadMore" width="80%">\r
+                       <v-card>\r
+                               <v-card-text class="subheading"><span v-html="text"/></v-card-text>\r
+                       </v-card>\r
+                       </v-dialog>\r
+       </div>`,\r
+       props: {\r
+               moreStr: {\r
+                       type: String,\r
+                       default: 'read more'\r
+               },\r
+               lessStr: {\r
+                       type: String,\r
+                       default: ''\r
+               },\r
+               text: {\r
+                       type: String,\r
+                       required: true\r
+               },\r
+               link: {\r
+                       type: String,\r
+                       default: '#'\r
+               },\r
+               maxChars: {\r
+                       type: Number,\r
+                       default: 100\r
+               }\r
+       },\r
+       $_veeValidate: {\r
+         validator: "new"\r
+       },\r
+       data (){\r
+               return{\r
+                       isReadMore: false\r
+               }\r
+       },\r
+       mounted() { },\r
+       computed: {\r
+               formattedString(){\r
+                       var val_container = this.text;\r
+                       if(this.text.length > this.maxChars){\r
+                               val_container = val_container.substring(0,this.maxChars) + '...';\r
+                       }\r
+                       return(val_container);\r
+               }\r
+       },\r
+\r
+       methods: {\r
+               triggerReadMore(e, b){\r
+                       if(this.link == '#'){\r
+                               e.preventDefault();\r
+                       }\r
+                       if(this.lessStr !== null || this.lessStr !== '')\r
+                       {\r
+                               this.isReadMore = b;\r
+                       }\r
+               }\r
+       }\r
+  })\r
diff --git a/music_assistant/web/components/searchbox.vue.js b/music_assistant/web/components/searchbox.vue.js
new file mode 100644 (file)
index 0000000..1570ab6
--- /dev/null
@@ -0,0 +1,50 @@
+Vue.component("searchbox", {
+  template: `
+  <v-dialog :value="$globals.showsearchbox" @input="$emit('input', $event)" max-width="500px">
+      <v-text-field
+            solo
+            clearable
+            :label="$t('type_to_search')"
+            prepend-inner-icon="search"
+            v-model="searchQuery">
+          </v-text-field>
+      </v-dialog>
+  `,
+  data () {
+    return {
+      searchQuery: "",
+    }
+  },
+  props: ['value'],
+  mounted () {
+    this.searchQuery = "" // TODO: set to last searchquery ?
+  },
+  watch: {
+    searchQuery: {
+      handler: _.debounce(function (val) {
+        this.onSearch();
+        // if (this.searchQuery)
+        //     this.$globals.showsearchbox = false;
+      }, 1000)
+    },
+    newSearchQuery (val) {
+      this.searchQuery = val
+    }
+  },
+  computed: {},
+  methods: {
+    onSearch () {
+      //this.$emit('clickSearch', this.searchQuery)
+      console.log(this.searchQuery);
+      router.push({ path: '/search', query: {searchQuery: this.searchQuery}});
+    },
+  }
+})
+/* <style>
+.searchbar {
+  padding: 1rem 1.5rem!important;
+  width: 100%;
+  box-shadow: 0 0 70px 0 rgba(0, 0, 0, 0.3);
+  background: #fff;
+}
+</style> */
\ No newline at end of file
diff --git a/music_assistant/web/components/volumecontrol.vue.js b/music_assistant/web/components/volumecontrol.vue.js
new file mode 100644 (file)
index 0000000..7ef20ab
--- /dev/null
@@ -0,0 +1,76 @@
+Vue.component("volumecontrol", {\r
+       template: `\r
+       <v-card>\r
+                               <v-list>\r
+                                       <v-list-tile avatar>\r
+                                               <v-list-tile-avatar>\r
+                                                               <v-icon large>{{ isGroup ? 'speaker_group' : 'speaker' }}</v-icon>\r
+                                               </v-list-tile-avatar>\r
+                                               <v-list-tile-content>\r
+                                                       <v-list-tile-title>{{ players[player_id].name }}</v-list-tile-title>\r
+                                                       <v-list-tile-sub-title>{{ $t('state.' + players[player_id].state) }}</v-list-tile-sub-title>\r
+                                               </v-list-tile-content>\r
+                                               </v-list-tile-action>\r
+                                       </v-list-tile>\r
+                               </v-list>\r
+\r
+                               <v-divider></v-divider>\r
+\r
+                               <v-list two-line>\r
+\r
+                                       <div v-for="child_id in volumePlayerIds" :key="child_id">\r
+                                                       <v-list-tile>\r
+                                                       \r
+                                                       <v-list-tile-content>\r
+\r
+                                                               <v-list-tile-title>\r
+                                                               </v-list-tile-title>\r
+                                                               <div class="v-list__tile__sub-title" style="position: absolute; left:47px; top:10px; z-index:99;">\r
+                                                                       <span :style="!players[child_id].powered ? 'color:rgba(0,0,0,.38);' : 'color:rgba(0,0,0,.54);'">{{ players[child_id].name }}</span>\r
+                                                               </div>\r
+                                                               <div class="v-list__tile__sub-title" style="position: absolute; left:0px; top:-4px; z-index:99;">\r
+                                                                       <v-btn icon @click="$emit('togglePlayerPower', child_id)">\r
+                                                                               <v-icon :style="!players[child_id].powered ? 'color:rgba(0,0,0,.38);' : 'color:rgba(0,0,0,.54);'">power_settings_new</v-icon>\r
+                                                                       </v-btn>\r
+                                                               </div>\r
+                                                               <v-list-tile-sub-title>\r
+                                                                       <v-slider lazy :disabled="!players[child_id].powered" v-if="!players[child_id].disable_volume"\r
+                                                                               :value="Math.round(players[child_id].volume_level)"\r
+                                                                               prepend-icon="volume_down"\r
+                                                                               append-icon="volume_up"\r
+                                                                               @end="$emit('setPlayerVolume', child_id, $event)"\r
+                                                                               @click:append="$emit('setPlayerVolume', child_id, 'up')"\r
+                                                                       @click:prepend="$emit('setPlayerVolume', child_id, 'down')"\r
+                                                                       ></v-slider>\r
+                                                               </v-list-tile-sub-title>\r
+                                                       </v-list-tile-content>\r
+                                               </v-list-tile>\r
+                                               <v-divider></v-divider>\r
+                                       </div>\r
+                                       \r
+                               </v-list>\r
+\r
+                               <v-spacer></v-spacer>\r
+                       </v-card>\r
+`,\r
+       props: ['value', 'players', 'player_id'],\r
+       data (){\r
+               return{\r
+                       }\r
+       },\r
+       computed: {\r
+                       volumePlayerIds() {\r
+                       var volume_ids = [this.player_id];\r
+                       for (var player_id in this.players)\r
+                               if (this.players[player_id].group_parent == this.player_id && this.players[player_id].enabled)\r
+                                       volume_ids.push(player_id);\r
+                       return volume_ids;\r
+               },\r
+               isGroup() {\r
+                       return this.volumePlayerIds.length > 1;\r
+               }\r
+  },\r
+       mounted() { },\r
+       created() { },\r
+       methods: {}\r
+  })\r
diff --git a/music_assistant/web/css/nprogress.css b/music_assistant/web/css/nprogress.css
new file mode 100644 (file)
index 0000000..e4cb811
--- /dev/null
@@ -0,0 +1,74 @@
+/* Make clicks pass-through */
+#nprogress {
+    pointer-events: none;
+  }
+  
+  #nprogress .bar {
+    background: rgb(119, 205, 255);
+  
+    position: fixed;
+    z-index: 1031;
+    top: 0;
+    left: 0;
+  
+    width: 100%;
+    height: 10px;
+  }
+  
+  /* Fancy blur effect */
+  #nprogress .peg {
+    display: block;
+    position: absolute;
+    right: 0px;
+    width: 100px;
+    height: 100%;
+    box-shadow: 0 0 10px #29d, 0 0 5px #29d;
+    opacity: 1.0;
+  
+    -webkit-transform: rotate(3deg) translate(0px, -4px);
+        -ms-transform: rotate(3deg) translate(0px, -4px);
+            transform: rotate(3deg) translate(0px, -4px);
+  }
+  
+  /* Remove these to get rid of the spinner */
+  #nprogress .spinner {
+    display: block;
+    position: fixed;
+    z-index: 1031;
+    top: 15px;
+    right: 15px;
+  }
+  
+  #nprogress .spinner-icon {
+    width: 18px;
+    height: 18px;
+    box-sizing: border-box;
+  
+    border: solid 2px transparent;
+    border-top-color: #29d;
+    border-left-color: #29d;
+    border-radius: 50%;
+  
+    -webkit-animation: nprogress-spinner 400ms linear infinite;
+            animation: nprogress-spinner 400ms linear infinite;
+  }
+  
+  .nprogress-custom-parent {
+    overflow: hidden;
+    position: relative;
+  }
+  
+  .nprogress-custom-parent #nprogress .spinner,
+  .nprogress-custom-parent #nprogress .bar {
+    position: absolute;
+  }
+  
+  @-webkit-keyframes nprogress-spinner {
+    0%   { -webkit-transform: rotate(0deg); }
+    100% { -webkit-transform: rotate(360deg); }
+  }
+  @keyframes nprogress-spinner {
+    0%   { transform: rotate(0deg); }
+    100% { transform: rotate(360deg); }
+  }
+  
\ No newline at end of file
diff --git a/music_assistant/web/css/site.css b/music_assistant/web/css/site.css
new file mode 100755 (executable)
index 0000000..2071f04
--- /dev/null
@@ -0,0 +1,73 @@
+[v-cloak] {
+    display: none;
+}
+
+.navbar {
+    margin-bottom: 20px;
+}
+
+/*.body-content {
+    padding-left: 25px;
+    padding-right: 25px;
+}*/
+
+input,
+select {
+    max-width: 30em;
+}
+
+.fade-enter-active,
+.fade-leave-active {
+    transition: opacity .5s;
+}
+
+.fade-enter,
+.fade-leave-to
+/* .fade-leave-active below version 2.1.8 */
+
+    {
+    opacity: 0;
+}
+
+.bounce-enter-active {
+    animation: bounce-in .5s;
+}
+
+.bounce-leave-active {
+    animation: bounce-in .5s reverse;
+}
+
+@keyframes bounce-in {
+    0% {
+        transform: scale(0);
+    }
+    50% {
+        transform: scale(1.5);
+    }
+    100% {
+        transform: scale(1);
+    }
+}
+
+.slide-fade-enter-active {
+    transition: all .3s ease;
+}
+
+.slide-fade-leave-active {
+    transition: all .8s cubic-bezier(1.0, 0.5, 0.8, 1.0);
+}
+
+.slide-fade-enter,
+.slide-fade-leave-to
+/* .slide-fade-leave-active below version 2.1.8 */
+
+    {
+    transform: translateX(10px);
+    opacity: 0;
+}
+
+.vertical-btn {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+  }
\ No newline at end of file
diff --git a/music_assistant/web/css/vue-loading.css b/music_assistant/web/css/vue-loading.css
new file mode 100644 (file)
index 0000000..6d62f80
--- /dev/null
@@ -0,0 +1,36 @@
+.vld-overlay {
+  bottom: 0;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+  align-items: center;
+  display: none;
+  justify-content: center;
+  overflow: hidden;
+  z-index: 1
+}
+
+.vld-overlay.is-active {
+  display: flex
+}
+
+.vld-overlay.is-full-page {
+  z-index: 999;
+  position: fixed
+}
+
+.vld-overlay .vld-background {
+  bottom: 0;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+  background: #000;
+  opacity: 0.7
+}
+
+.vld-overlay .vld-icon, .vld-parent {
+  position: relative
+}
+
diff --git a/music_assistant/web/images/default_artist.png b/music_assistant/web/images/default_artist.png
new file mode 100644 (file)
index 0000000..a530d5b
Binary files /dev/null and b/music_assistant/web/images/default_artist.png differ
diff --git a/music_assistant/web/images/icons/aac.png b/music_assistant/web/images/icons/aac.png
new file mode 100644 (file)
index 0000000..7dafab2
Binary files /dev/null and b/music_assistant/web/images/icons/aac.png differ
diff --git a/music_assistant/web/images/icons/chromecast.png b/music_assistant/web/images/icons/chromecast.png
new file mode 100644 (file)
index 0000000..f7d2a46
Binary files /dev/null and b/music_assistant/web/images/icons/chromecast.png differ
diff --git a/music_assistant/web/images/icons/file.png b/music_assistant/web/images/icons/file.png
new file mode 100644 (file)
index 0000000..bd2df04
Binary files /dev/null and b/music_assistant/web/images/icons/file.png differ
diff --git a/music_assistant/web/images/icons/flac.png b/music_assistant/web/images/icons/flac.png
new file mode 100644 (file)
index 0000000..33e1f17
Binary files /dev/null and b/music_assistant/web/images/icons/flac.png differ
diff --git a/music_assistant/web/images/icons/hires.png b/music_assistant/web/images/icons/hires.png
new file mode 100644 (file)
index 0000000..a398c6e
Binary files /dev/null and b/music_assistant/web/images/icons/hires.png differ
diff --git a/music_assistant/web/images/icons/homeassistant.png b/music_assistant/web/images/icons/homeassistant.png
new file mode 100644 (file)
index 0000000..5f28d69
Binary files /dev/null and b/music_assistant/web/images/icons/homeassistant.png differ
diff --git a/music_assistant/web/images/icons/http_streamer.png b/music_assistant/web/images/icons/http_streamer.png
new file mode 100644 (file)
index 0000000..c35c983
Binary files /dev/null and b/music_assistant/web/images/icons/http_streamer.png differ
diff --git a/music_assistant/web/images/icons/icon-128x128.png b/music_assistant/web/images/icons/icon-128x128.png
new file mode 100644 (file)
index 0000000..01363c8
Binary files /dev/null and b/music_assistant/web/images/icons/icon-128x128.png differ
diff --git a/music_assistant/web/images/icons/icon-256x256.png b/music_assistant/web/images/icons/icon-256x256.png
new file mode 100644 (file)
index 0000000..4c36796
Binary files /dev/null and b/music_assistant/web/images/icons/icon-256x256.png differ
diff --git a/music_assistant/web/images/icons/icon-apple.png b/music_assistant/web/images/icons/icon-apple.png
new file mode 100644 (file)
index 0000000..67d26d5
Binary files /dev/null and b/music_assistant/web/images/icons/icon-apple.png differ
diff --git a/music_assistant/web/images/icons/info_gradient.jpg b/music_assistant/web/images/icons/info_gradient.jpg
new file mode 100644 (file)
index 0000000..9d0c0e3
Binary files /dev/null and b/music_assistant/web/images/icons/info_gradient.jpg differ
diff --git a/music_assistant/web/images/icons/lms.png b/music_assistant/web/images/icons/lms.png
new file mode 100644 (file)
index 0000000..6dd9b06
Binary files /dev/null and b/music_assistant/web/images/icons/lms.png differ
diff --git a/music_assistant/web/images/icons/mp3.png b/music_assistant/web/images/icons/mp3.png
new file mode 100644 (file)
index 0000000..b894bda
Binary files /dev/null and b/music_assistant/web/images/icons/mp3.png differ
diff --git a/music_assistant/web/images/icons/qobuz.png b/music_assistant/web/images/icons/qobuz.png
new file mode 100644 (file)
index 0000000..9d7b726
Binary files /dev/null and b/music_assistant/web/images/icons/qobuz.png differ
diff --git a/music_assistant/web/images/icons/spotify.png b/music_assistant/web/images/icons/spotify.png
new file mode 100644 (file)
index 0000000..805f5c7
Binary files /dev/null and b/music_assistant/web/images/icons/spotify.png differ
diff --git a/music_assistant/web/images/icons/squeezebox.png b/music_assistant/web/images/icons/squeezebox.png
new file mode 100644 (file)
index 0000000..18531d7
Binary files /dev/null and b/music_assistant/web/images/icons/squeezebox.png differ
diff --git a/music_assistant/web/images/icons/tunein.png b/music_assistant/web/images/icons/tunein.png
new file mode 100644 (file)
index 0000000..3352c29
Binary files /dev/null and b/music_assistant/web/images/icons/tunein.png differ
diff --git a/music_assistant/web/images/icons/vorbis.png b/music_assistant/web/images/icons/vorbis.png
new file mode 100644 (file)
index 0000000..c6d6914
Binary files /dev/null and b/music_assistant/web/images/icons/vorbis.png differ
diff --git a/music_assistant/web/images/icons/web.png b/music_assistant/web/images/icons/web.png
new file mode 100644 (file)
index 0000000..d3b5724
Binary files /dev/null and b/music_assistant/web/images/icons/web.png differ
diff --git a/music_assistant/web/images/info_gradient.jpg b/music_assistant/web/images/info_gradient.jpg
new file mode 100644 (file)
index 0000000..9d0c0e3
Binary files /dev/null and b/music_assistant/web/images/info_gradient.jpg differ
diff --git a/music_assistant/web/index.html b/music_assistant/web/index.html
new file mode 100755 (executable)
index 0000000..dcef414
--- /dev/null
@@ -0,0 +1,249 @@
+<!DOCTYPE html>
+<html>
+
+    <head>
+        <meta charset="utf-8" />
+        <title>Music Assistant</title>
+        <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet">
+        <link href="https://cdn.jsdelivr.net/npm/vuetify@1.5.16/dist/vuetify.min.css" rel="stylesheet">
+        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
+        <link rel="icon" href="./images/icons/icon-256x256.png">
+        <link rel="manifest" href="./manifest.json">
+        <link rel="apple-touch-icon" href="./images/icons/icon-apple.png">
+        <meta name="apple-mobile-web-app-capable" content="yes">
+        <link href="./css/site.css" rel="stylesheet">
+        <link href="./css/vue-loading.css" rel="stylesheet">
+    </head>
+
+    <body>
+
+        <div id="app">
+            <v-app light>
+                <v-content>
+                    <headermenu></headermenu>
+                    <player></player>
+                    <router-view app :key="$route.path"></router-view>      
+                    <searchbox/>             
+                </v-content>
+                <loading :active.sync="$globals.loading" :can-cancel="true" color="#2196f3" loader="dots"></loading>
+            </v-app>
+        </div>
+
+
+        <script src="https://unpkg.com/vue/dist/vue.js"></script>
+        <script src="https://unpkg.com/vue-i18n/dist/vue-i18n.js"></script>
+        <script src="https://cdn.jsdelivr.net/npm/vuetify@1.5.16/dist/vuetify.min.js"></script>
+        <script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
+        <script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js"></script>
+        <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
+        <script src="https://cdn.jsdelivr.net/npm/moment@2.20.1/moment.min.js"></script>
+        <script src="https://unpkg.com/vee-validate@2.0.0-rc.25/dist/vee-validate.js"></script>
+        <script src="./lib/vue-loading-overlay.js"></script>
+        <script src="https://unpkg.com/vue-toasted"></script>
+
+
+        <script>
+            const isMobile = () => (document.body.clientWidth < 800);
+            const isInStandaloneMode = () => ('standalone' in window.navigator) && (window.navigator.standalone);
+
+            function showPlayMenu (item) {
+                this.$globals.playmenuitem = item;
+                this.$globals.showplaymenu = !this.$globals.showplaymenu;
+                }
+
+            function clickItem (item) {
+                var endpoint = "";
+                if (item.media_type == 1)
+                    endpoint = "/artists/"
+                else if (item.media_type == 2)
+                    endpoint = "/albums/"
+                else if (item.media_type == 3 || item.media_type == 5)
+                    {
+                    this.showPlayMenu(item);
+                    return;
+                    }
+                else if (item.media_type == 4)
+                    endpoint = "/playlists/"
+                item_id = item.item_id.toString();
+                var url = endpoint + item_id;
+                router.push({ path: url, query: {provider: item.provider}});
+            }
+
+            String.prototype.formatDuration = function () {
+                var sec_num = parseInt(this, 10); // don't forget the second param
+                var hours   = Math.floor(sec_num / 3600);
+                var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
+                var seconds = sec_num - (hours * 3600) - (minutes * 60);
+
+                if (hours   < 10) {hours   = "0"+hours;}
+                if (minutes < 10) {minutes = "0"+minutes;}
+                if (seconds < 10) {seconds = "0"+seconds;}
+                if (hours == '00')
+                    return minutes+':'+seconds;
+                else
+                    return hours+':'+minutes+':'+seconds;
+            }
+            function toggleLibrary (item) {
+                var endpoint = "/api/" + item.media_type + "/";
+                item_id = item.item_id.toString();
+                var action = "/library_remove"
+                if (item.in_library.length == 0)
+                    action = "/library_add"
+                var url = endpoint + item_id + action;
+                console.log('loading ' + url);
+                axios
+                    .get(url, { params: { provider: item.provider }})
+                    .then(result => {
+                        data = result.data;
+                        console.log(data);
+                        if (action == "/library_remove")
+                            item.in_library = []
+                        else
+                        item.in_library = [provider]
+                        })
+                    .catch(error => {
+                        console.log("error", error);
+                    });
+
+            };
+        </script>
+
+        <!-- Vue Pages and Components here -->
+        <script src='./pages/home.vue.js'></script>
+        <script src='./pages/browse.vue.js'></script>
+
+        <script src='./pages/artistdetails.vue.js'></script>
+        <script src='./pages/albumdetails.vue.js'></script>
+        <script src='./pages/trackdetails.vue.js'></script>
+        <script src='./pages/playlistdetails.vue.js'></script>
+        <script src='./pages/search.vue.js'></script>
+        <script src='./pages/queue.vue.js'></script>
+        <script src='./pages/config.vue.js'></script>
+        
+
+        <script src='./components/headermenu.vue.js'></script>
+        <script src='./components/player.vue.js'></script>
+        <script src='./components/listviewItem.vue.js'></script>
+        <script src='./components/readmore.vue.js'></script>
+        <script src='./components/playmenu.vue.js'></script>
+        <script src='./components/volumecontrol.vue.js'></script>
+        <script src='./components/infoheader.vue.js'></script>
+        <script src='./components/providericons.vue.js'></script>
+        <script src='./components/searchbox.vue.js'></script>
+        
+        <script src='./strings.js'></script>
+        
+        <script>
+        Vue.use(VueRouter);
+        Vue.use(VeeValidate);
+        Vue.use(Vuetify);
+        Vue.use(VueI18n);
+        Vue.use(VueLoading);
+        Vue.use(Toasted, {duration: 5000, fullWidth: true});
+
+
+        const routes = [
+            {
+            path: '/',
+            component: home
+            },
+            {
+                path: '/config',
+                component: Config,
+            },
+            {
+                path: '/queue/:player_id',
+                component: Queue,
+                props: route => ({ ...route.params, ...route.query })
+            },
+            {
+                path: '/artists/:media_id',
+                component: ArtistDetails,
+                props: route => ({ ...route.params, ...route.query })
+            },
+            {
+                path: '/albums/:media_id',
+                component: AlbumDetails,
+                props: route => ({ ...route.params, ...route.query })
+            },
+            {
+                path: '/tracks/:media_id',
+                component: TrackDetails,
+                props: route => ({ ...route.params, ...route.query })
+            },
+            {
+                path: '/playlists/:media_id',
+                component: PlaylistDetails,
+                props: route => ({ ...route.params, ...route.query })
+            },
+            {
+                path: '/search',
+                component: Search,
+                props: route => ({ ...route.params, ...route.query })
+            },
+            {
+                path: '/:mediatype',
+                component: Browse,
+                props: route => ({ ...route.params, ...route.query })
+            },
+        ]
+
+        let router = new VueRouter({
+            //mode: 'history',
+            routes // short for `routes: routes`
+        })
+
+        router.beforeEach((to, from, next) => {
+            next()
+        })
+
+        const globalStore = new Vue({
+            data: {
+                windowtitle: 'Home',
+                loading: false,
+                showplaymenu: false,
+                showsearchbox: false,
+                playmenuitem: null
+            }
+        })
+        Vue.prototype.$globals = globalStore;
+        Vue.prototype.isMobile = isMobile;
+        Vue.prototype.isInStandaloneMode = isInStandaloneMode;
+        Vue.prototype.toggleLibrary = toggleLibrary;
+        Vue.prototype.showPlayMenu = showPlayMenu;
+        Vue.prototype.clickItem= clickItem;
+        
+        const i18n = new VueI18n({
+            locale: navigator.language.split('-')[0],
+            fallbackLocale: 'en',
+            enableInSFC: true,
+            messages
+            })
+
+        var app = new Vue({
+            i18n,
+            el: '#app',
+            watch: {},
+            mounted() {
+            },
+            components: {
+                Loading: VueLoading
+            },
+            created() {
+                // little hack to force refresh PWA on iOS by simple reloading it every hour
+                var d = new Date();
+                var cur_update = d.getDay() + d.getHours();
+                if (localStorage.getItem('last_update') != cur_update)
+                {
+                    localStorage.setItem('last_update', cur_update);
+                    window.location.reload(true);
+                }
+            },
+            data: { },
+            methods: {},
+            router
+        })
+    </script>
+    </body>
+
+</html>
\ No newline at end of file
diff --git a/music_assistant/web/lib/vue-loading-overlay.js b/music_assistant/web/lib/vue-loading-overlay.js
new file mode 100644 (file)
index 0000000..b3b9da1
--- /dev/null
@@ -0,0 +1 @@
+!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define("VueLoading",[],e):"object"==typeof exports?exports.VueLoading=e():t.VueLoading=e()}("undefined"!=typeof self?self:this,function(){return function(t){var e={};function i(n){if(e[n])return e[n].exports;var r=e[n]={i:n,l:!1,exports:{}};return t[n].call(r.exports,r,r.exports,i),r.l=!0,r.exports}return i.m=t,i.c=e,i.d=function(t,e,n){i.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},i.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},i.t=function(t,e){if(1&e&&(t=i(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)i.d(n,r,function(e){return t[e]}.bind(null,r));return n},i.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(e,"a",e),e},i.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},i.p="",i(i.s=1)}([function(t,e,i){},function(t,e,i){"use strict";i.r(e);var n="undefined"!=typeof window?window.HTMLElement:Object,r={mounted:function(){document.addEventListener("focusin",this.focusIn)},methods:{focusIn:function(t){if(this.isActive&&t.target!==this.$el&&!this.$el.contains(t.target)){var e=this.container?this.container:this.isFullPage?null:this.$el.parentElement;(this.isFullPage||e&&e.contains(t.target))&&(t.preventDefault(),this.$el.focus())}}},beforeDestroy:function(){document.removeEventListener("focusin",this.focusIn)}};function a(t,e,i,n,r,a,o,s){var u,l="function"==typeof t?t.options:t;if(e&&(l.render=e,l.staticRenderFns=i,l._compiled=!0),n&&(l.functional=!0),a&&(l._scopeId="data-v-"+a),o?(u=function(t){(t=t||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext)||"undefined"==typeof __VUE_SSR_CONTEXT__||(t=__VUE_SSR_CONTEXT__),r&&r.call(this,t),t&&t._registeredComponents&&t._registeredComponents.add(o)},l._ssrRegister=u):r&&(u=s?function(){r.call(this,this.$root.$options.shadowRoot)}:r),u)if(l.functional){l._injectStyles=u;var c=l.render;l.render=function(t,e){return u.call(e),c(t,e)}}else{var d=l.beforeCreate;l.beforeCreate=d?[].concat(d,u):[u]}return{exports:t,options:l}}var o=a({name:"spinner",props:{color:{type:String,default:"#000"},height:{type:Number,default:64},width:{type:Number,default:64}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{viewBox:"0 0 38 38",xmlns:"http://www.w3.org/2000/svg",width:this.width,height:this.height,stroke:this.color}},[e("g",{attrs:{fill:"none","fill-rule":"evenodd"}},[e("g",{attrs:{transform:"translate(1 1)","stroke-width":"2"}},[e("circle",{attrs:{"stroke-opacity":".25",cx:"18",cy:"18",r:"18"}}),e("path",{attrs:{d:"M36 18c0-9.94-8.06-18-18-18"}},[e("animateTransform",{attrs:{attributeName:"transform",type:"rotate",from:"0 18 18",to:"360 18 18",dur:"0.8s",repeatCount:"indefinite"}})],1)])])])},[],!1,null,null,null).exports,s=a({name:"dots",props:{color:{type:String,default:"#000"},height:{type:Number,default:240},width:{type:Number,default:60}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{viewBox:"0 0 120 30",xmlns:"http://www.w3.org/2000/svg",fill:this.color,width:this.width,height:this.height}},[e("circle",{attrs:{cx:"15",cy:"15",r:"15"}},[e("animate",{attrs:{attributeName:"r",from:"15",to:"15",begin:"0s",dur:"0.8s",values:"15;9;15",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"1",to:"1",begin:"0s",dur:"0.8s",values:"1;.5;1",calcMode:"linear",repeatCount:"indefinite"}})]),e("circle",{attrs:{cx:"60",cy:"15",r:"9","fill-opacity":"0.3"}},[e("animate",{attrs:{attributeName:"r",from:"9",to:"9",begin:"0s",dur:"0.8s",values:"9;15;9",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"0.5",to:"0.5",begin:"0s",dur:"0.8s",values:".5;1;.5",calcMode:"linear",repeatCount:"indefinite"}})]),e("circle",{attrs:{cx:"105",cy:"15",r:"15"}},[e("animate",{attrs:{attributeName:"r",from:"15",to:"15",begin:"0s",dur:"0.8s",values:"15;9;15",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"1",to:"1",begin:"0s",dur:"0.8s",values:"1;.5;1",calcMode:"linear",repeatCount:"indefinite"}})])])},[],!1,null,null,null).exports,u=a({name:"bars",props:{color:{type:String,default:"#000"},height:{type:Number,default:40},width:{type:Number,default:40}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 30 30",height:this.height,width:this.width,fill:this.color}},[e("rect",{attrs:{x:"0",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0s",dur:"0.6s",repeatCount:"indefinite"}})]),e("rect",{attrs:{x:"10",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0.15s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0.15s",dur:"0.6s",repeatCount:"indefinite"}})]),e("rect",{attrs:{x:"20",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0.3s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0.3s",dur:"0.6s",repeatCount:"indefinite"}})])])},[],!1,null,null,null).exports,l=a({name:"vue-loading",mixins:[r],props:{active:Boolean,programmatic:Boolean,container:[Object,Function,n],isFullPage:{type:Boolean,default:!0},transition:{type:String,default:"fade"},canCancel:Boolean,onCancel:{type:Function,default:function(){}},color:String,backgroundColor:String,opacity:Number,width:Number,height:Number,zIndex:Number,loader:{type:String,default:"spinner"}},data:function(){return{isActive:this.active}},components:{Spinner:o,Dots:s,Bars:u},beforeMount:function(){this.programmatic&&(this.container?(this.isFullPage=!1,this.container.appendChild(this.$el)):document.body.appendChild(this.$el))},mounted:function(){this.programmatic&&(this.isActive=!0),document.addEventListener("keyup",this.keyPress)},methods:{cancel:function(){this.canCancel&&this.isActive&&(this.hide(),this.onCancel.apply(null,arguments))},hide:function(){var t=this;this.$emit("hide"),this.$emit("update:active",!1),this.programmatic&&(this.isActive=!1,setTimeout(function(){var e;t.$destroy(),void 0!==(e=t.$el).remove?e.remove():e.parentNode.removeChild(e)},150))},keyPress:function(t){27===t.keyCode&&this.cancel()}},watch:{active:function(t){this.isActive=t}},beforeDestroy:function(){document.removeEventListener("keyup",this.keyPress)}},function(){var t=this,e=t.$createElement,i=t._self._c||e;return i("transition",{attrs:{name:t.transition}},[i("div",{directives:[{name:"show",rawName:"v-show",value:t.isActive,expression:"isActive"}],staticClass:"vld-overlay is-active",class:{"is-full-page":t.isFullPage},style:{zIndex:this.zIndex},attrs:{tabindex:"0","aria-busy":t.isActive,"aria-label":"Loading"}},[i("div",{staticClass:"vld-background",style:{background:this.backgroundColor,opacity:this.opacity},on:{click:function(e){return e.preventDefault(),t.cancel(e)}}}),i("div",{staticClass:"vld-icon"},[t._t("before"),t._t("default",[i(t.loader,{tag:"component",attrs:{color:t.color,width:t.width,height:t.height}})]),t._t("after")],2)])])},[],!1,null,null,null).exports,c=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return{show:function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:e,r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:i,a=Object.assign({},e,n,{programmatic:!0}),o=new(t.extend(l))({el:document.createElement("div"),propsData:a}),s=Object.assign({},i,r);return Object.keys(s).map(function(t){o.$slots[t]=s[t]}),o}}};i(0);l.install=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},n=c(t,e,i);t.$loading=n,t.prototype.$loading=n};e.default=l}]).default});
\ No newline at end of file
diff --git a/music_assistant/web/manifest.json b/music_assistant/web/manifest.json
new file mode 100755 (executable)
index 0000000..6a3c4b9
--- /dev/null
@@ -0,0 +1,22 @@
+{
+  "name": "Music Assistant",
+  "short_name": "MusicAssistant",
+  "theme_color": "#2196f3",
+  "background_color": "#2196f3",
+  "display": "standalone",
+  "Scope": "/",
+  "start_url": "/",
+  "icons": [
+    {
+      "src": "images/icons/icon-128x128.png",
+      "sizes": "128x128",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-256x256.png",
+      "sizes": "512x512",
+      "type": "image/png"
+    }
+  ],
+  "splash_pages": null
+}
\ No newline at end of file
diff --git a/music_assistant/web/pages/albumdetails.vue.js b/music_assistant/web/pages/albumdetails.vue.js
new file mode 100755 (executable)
index 0000000..4f60a91
--- /dev/null
@@ -0,0 +1,107 @@
+var AlbumDetails = Vue.component('AlbumDetails', {
+  template: `
+  <section>
+      <infoheader v-bind:info="info"/>
+      <v-tabs
+          v-model="active"
+          color="transparent"
+          light
+          slider-color="black"
+        >
+          <v-tab ripple>Album tracks</v-tab>
+          <v-tab-item>
+            <v-card flat>
+            <v-list two-line>
+                <listviewItem 
+                    v-for="(item, index) in albumtracks" 
+                    v-bind:item="item"
+                    :key="item.db_id"
+                    v-bind:totalitems="albumtracks.length"
+                    v-bind:index="index"
+                    :hideavatar="true"
+                    :hideproviders="isMobile()"
+                    >
+                </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+
+          <v-tab ripple>Versions</v-tab>
+          <v-tab-item>
+            <v-card flat>
+                <v-list two-line>
+                  <listviewItem 
+                      v-for="(item, index) in albumversions" 
+                      v-bind:item="item"
+                      :key="item.db_id"
+                      v-bind:totalitems="albumversions.length"
+                      v-bind:index="index"
+                      >
+                  </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+        </v-tabs>
+
+      </section>`,
+  props: ['provider', 'media_id'],
+  data() {
+    return {
+      selected: [2],
+      info: {},
+      albumtracks: [],
+      albumversions: [],
+      offset: 0,
+      active: null,
+    }
+  },
+  created() {
+    this.$globals.windowtitle = ""
+    this.getInfo();
+    this.getAlbumTracks();
+  },
+  methods: {
+    getInfo () {
+      this.$globals.loading = true;
+      const api_url = '/api/albums/' + this.media_id
+      axios
+        .get(api_url, { params: { provider: this.provider }})
+        .then(result => {
+          data = result.data;
+          this.info = data;
+          this.getAlbumVersions()
+          this.$globals.loading = false;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+    getAlbumTracks () {
+      const api_url = '/api/albums/' + this.media_id + '/tracks'
+      axios
+        .get(api_url, { params: { offset: this.offset, limit: 50, provider: this.provider}})
+        .then(result => {
+          data = result.data;
+          this.albumtracks.push(...data);
+          this.offset += 50;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+    getAlbumVersions () {
+      const api_url = '/api/search';
+      var searchstr = this.info.artist.name + " - " + this.info.name
+      axios
+        .get(api_url, { params: { query: searchstr, limit: 50, media_types: 'albums', online: true}})
+        .then(result => {
+          data = result.data;
+          this.albumversions.push(...data.albums);
+          this.offset += 50;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+  }
+})
diff --git a/music_assistant/web/pages/artistdetails.vue.js b/music_assistant/web/pages/artistdetails.vue.js
new file mode 100755 (executable)
index 0000000..4660030
--- /dev/null
@@ -0,0 +1,127 @@
+var ArtistDetails = Vue.component('ArtistDetails', {
+  template: `
+  <section>
+      <infoheader v-bind:info="info"/>
+      <v-tabs
+          v-model="active"
+          color="transparent"
+          light
+          slider-color="black"
+        >
+          <v-tab ripple>Top tracks</v-tab>
+          <v-tab-item>
+            <v-card flat>
+            <v-list two-line>
+                  <listviewItem 
+                      v-for="(item, index) in toptracks" 
+                      v-bind:item="item"
+                      v-bind:totalitems="toptracks.length"
+                      v-bind:index="index"
+                      :key="item.db_id"
+                      :hideavatar="isMobile()"
+                      :hidetracknum="true"
+                      :hideproviders="isMobile()"
+                      :hidelibrary="isMobile()">
+                  </listviewItem>
+                </v-list>
+            </v-card>
+          </v-tab-item>
+
+          <v-tab ripple>Albums</v-tab>
+          <v-tab-item>
+            <v-card flat>
+                <v-list two-line>
+                    <listviewItem 
+                        v-for="(item, index) in artistalbums" 
+                        v-bind:item="item"
+                        :key="item.db_id"
+                        v-bind:totalitems="artistalbums.length"
+                        v-bind:index="index"
+                        :hideproviders="isMobile()"
+                        >
+                    </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+        </v-tabs>
+      </section>`,
+  props: ['media_id', 'provider'],
+  data() {
+    return {
+      selected: [2],
+      info: {},
+      toptracks: [],
+      artistalbums: [],
+      bg_image: "../images/info_gradient.jpg",
+      active: null,
+      playmenu: false,
+      playmenuitem: null
+    }
+  },
+  created() {
+    this.$globals.windowtitle = ""
+    this.getInfo();
+  },
+  methods: {
+    getFanartImage() {
+      if (this.info.metadata && this.info.metadata.fanart)
+        return this.info.metadata.fanart;
+      else if (this.info.artists)
+        for (artist in this.info.artists)
+          if (artist.info.metadata && artist.data.metadata.fanart)
+              return artist.metadata.fanart;
+    },
+    getInfo (lazy=true) {
+      this.$globals.loading = true;
+      const api_url = '/api/artists/' + this.media_id;
+      console.log(api_url + ' - ' + this.provider);
+      axios
+        .get(api_url, { params: { lazy: lazy, provider: this.provider }})
+        .then(result => {
+          data = result.data;
+          this.info = data;
+          this.$globals.loading = false;
+          if (data.is_lazy == true)
+              // refresh the info if we got a lazy object
+              this.timeout1 = setTimeout(function(){
+                  this.getInfo(false);
+              }.bind(this), 1000);
+          else {
+            this.getArtistTopTracks();
+            this.getArtistAlbums();
+          }
+        })
+        .catch(error => {
+          console.log("error", error);
+          this.$globals.loading = false;
+        });
+    },
+    getArtistTopTracks () {
+      
+      const api_url = '/api/artists/' + this.media_id + '/toptracks'
+      axios
+      .get(api_url, { params: { provider: this.provider }})
+        .then(result => {
+          data = result.data;
+          this.toptracks = data;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+        
+    },
+    getArtistAlbums () {
+      const api_url = '/api/artists/' + this.media_id + '/albums'
+      console.log('loading ' + api_url);
+      axios
+      .get(api_url, { params: { provider: this.provider }})
+        .then(result => {
+          data = result.data;
+          this.artistalbums = data;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+  }
+})
diff --git a/music_assistant/web/pages/browse.vue.js b/music_assistant/web/pages/browse.vue.js
new file mode 100755 (executable)
index 0000000..49bcdc8
--- /dev/null
@@ -0,0 +1,61 @@
+var Browse = Vue.component('Browse', {
+  template: `
+    <section>
+      <v-list two-line>
+        <listviewItem 
+            v-for="(item, index) in items"
+            :key="item.db_id"
+            v-bind:item="item"
+            v-bind:totalitems="items.length"
+            v-bind:index="index"
+            :hideavatar="item.media_type == 3 ? isMobile() : false"
+            :hidetracknum="true"
+            :hideproviders="isMobile()"
+            :hidelibrary="isMobile() ? true : item.media_type != 3">
+        </listviewItem>
+      </v-list>
+    </section>
+  `,
+  props: ['mediatype', 'provider'],
+  data() {
+    return {
+      selected: [2],
+      items: [],
+      offset: 0
+    }
+  },
+  created() {
+    this.showavatar = true;
+    mediatitle = 
+    this.$globals.windowtitle = this.$t(this.mediatype)
+    this.scroll(this.Browse);
+    this.getItems();
+  },
+  methods: {
+    getItems () {
+      this.$globals.loading = true
+      const api_url = '/api/' + this.mediatype;
+      axios
+        .get(api_url, { params: { offset: this.offset, limit: 50, provider: this.provider }})
+        .then(result => {
+          data = result.data;
+          this.items.push(...data);
+          this.offset += 50;
+          this.$globals.loading = false;
+        })
+        .catch(error => {
+          console.log("error", error);
+          this.showProgress = false;
+        });
+    },
+    scroll (Browse) {
+      window.onscroll = () => {
+        let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight;
+
+        if (bottomOfWindow) {
+          this.getItems();
+        }
+      };
+    }
+  }
+})
diff --git a/music_assistant/web/pages/config.vue.js b/music_assistant/web/pages/config.vue.js
new file mode 100755 (executable)
index 0000000..c4164db
--- /dev/null
@@ -0,0 +1,152 @@
+var Config = Vue.component('Config', {
+  template: `
+    <section>
+
+        <v-tabs v-model="active" color="transparent" light slider-color="black">
+            <v-tab ripple v-for="(conf_value, conf_key) in conf" :key="conf_key">{{ $t('conf.'+conf_key) }}</v-tab>
+                  <v-tab-item v-for="(conf_value, conf_key) in conf" :key="conf_key">
+
+                      <!-- generic and module settings -->
+                      <v-list two-line v-if="conf_key != 'player_settings'">
+                          <v-list-group no-action v-for="(conf_subvalue, conf_subkey) in conf[conf_key]" :key="conf_key+conf_subkey">
+                            <template v-slot:activator>
+                                <v-list-tile>
+                                  <v-list-tile-avatar>
+                                      <img :src="'images/icons/' + conf_subkey + '.png'"/>
+                                  </v-list-tile-avatar>
+                                  <v-list-tile-content>
+                                      <v-list-tile-title>{{ $t('conf.'+conf_subkey) }}</v-list-tile-title>
+                                  </v-list-tile-content>
+                                </v-list-tile>
+                              </template>
+                              <div v-for="conf_item_key in conf[conf_key][conf_subkey].__desc__">
+                                    <v-list-tile>
+                                          <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])"></v-switch>
+                                          <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-text-field>
+                                          <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-select>
+                                          <v-text-field v-else v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box></v-text-field>
+                                    </v-list-tile>
+                                </div>
+                                <v-divider></v-divider>
+                            </v-list-group>
+                      </v-list two-line>
+
+                      <!-- player settings -->
+                      <v-list two-line v-if="conf_key == 'player_settings'">
+                          <v-list-group no-action v-for="(player, key) in players" v-if="key != '__desc__' && key in players" :key="key">
+                                <template v-slot:activator>
+                                    <v-list-tile>
+                                      <v-list-tile-avatar>
+                                          <img :src="'images/icons/' + players[key].player_provider + '.png'"/>
+                                      </v-list-tile-avatar>
+                                      <v-list-tile-content>
+                                        <v-list-tile-title class="title">{{ players[key].name }}</v-list-tile-title>
+                                        <v-list-tile-sub-title class="title">{{ key }}</v-list-tile-sub-title>
+                                      </v-list-tile-content>
+                                  </v-list-tile>
+                              </template>
+                              <div v-for="conf_item_key in conf.player_settings[key].__desc__" v-if="conf.player_settings[key].enabled">
+                                  <v-list-tile>
+                                        <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])"></v-switch>
+                                        <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-text-field>
+                                        <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" 
+                                          :items="playersLst"
+                                          item-text="name"
+                                          item-value="id" box>
+                                        </v-select>
+                                        <v-select v-else-if="conf_item_key[0] == 'max_sample_rate'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" :items="sample_rates" box></v-select>
+                                        <v-slider v-else-if="conf_item_key[0] == 'crossfade_duration'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" min=0 max=10 box thumb-label></v-slider>
+                                        <v-text-field v-else v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box></v-text-field>
+                                  </v-list-tile>
+                                  <v-list-tile v-if="!conf.player_settings[key].enabled">
+                                        <v-switch v-model="conf.player_settings[key].enabled" :label="$t('conf.'+'enabled')"></v-switch>
+                                  </v-list-tile>
+                              </div>
+                              <div v-if="!conf.player_settings[key].enabled">
+                                  <v-list-tile>
+                                      <v-switch v-model="conf.player_settings[key].enabled" :label="$t('conf.'+'enabled')"></v-switch>
+                                  </v-list-tile>
+                              </div>
+                                <v-divider></v-divider>
+                            </v-list-group>
+                      </v-list two-line>
+                  </v-tab-item>
+            </v-tab>
+        </v-tabs>
+
+
+    </section>
+  `,
+  props: [],
+  data() {
+    return {
+      conf: {},
+      players: {},
+      active: 0,
+      sample_rates: [44100, 48000, 88200, 96000, 192000, 384000]
+    }
+  },
+  computed: {
+    playersLst()
+    {
+      var playersLst = [];
+      playersLst.push({id: null, name: this.$t('conf.'+'not_grouped')})
+      for (player_id in this.players)
+        playersLst.push({id: player_id, name: this.conf.player_settings[player_id].name})
+      return playersLst;
+    }
+  },
+  watch: {
+    'conf': {
+        handler: _.debounce(function (val, oldVal) {
+          if (oldVal.base) {
+            console.log("save config needed!");
+            this.saveConfig();
+            this.$toasted.show(this.$t('conf.conf_saved'))
+          }
+        }, 5000),
+        deep: true
+    }
+  },
+  created() {
+    this.$globals.windowtitle = this.$t('settings');
+    this.getPlayers();
+    this.getConfig();
+    console.log(this.$globals.all_players);
+  },
+  methods: {
+    getConfig () {
+      axios
+        .get('/api/config')
+        .then(result => {
+          this.conf = result.data;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+    saveConfig () {
+      axios
+        .post('/api/config', this.conf)
+        .then(result => {
+          console.log(result);
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+    getPlayers () {
+      const api_url = '/api/players';
+      axios
+        .get(api_url)
+        .then(result => {
+          for (var item of result.data)
+            this.$set(this.players, item.player_id, item)
+        })
+        .catch(error => {
+          console.log("error", error);
+          this.showProgress = false;
+        });
+    },
+  }
+})
diff --git a/music_assistant/web/pages/home.vue.js b/music_assistant/web/pages/home.vue.js
new file mode 100755 (executable)
index 0000000..91c0b33
--- /dev/null
@@ -0,0 +1,43 @@
+var home = Vue.component("Home", {
+  template: `
+  <section>
+      <v-list>
+        <v-list-tile 
+          v-for="item in items" :key="item.title" @click="$router.push(item.path)">
+            <v-list-tile-action style="margin-left:15px">
+                <v-icon>{{ item.icon }}</v-icon>
+            </v-list-tile-action>
+            <v-list-tile-content>
+                <v-list-tile-title>{{ item.title }}</v-list-tile-title>
+            </v-list-tile-content>
+        </v-list-tile>
+      </v-list>
+  </section>
+`,
+  props: ["title"],
+  $_veeValidate: {
+    validator: "new"
+  },
+  data() {
+    return {
+      result: null,
+      showProgress: false
+    };
+  },
+  created() {
+    this.$globals.windowtitle = this.$t('musicassistant');
+    this.items= [
+        { title: this.$t('artists'), icon: "person", path: "/artists" },
+        { title: this.$t('albums'), icon: "album", path: "/albums" },
+        { title: this.$t('tracks'), icon: "audiotrack", path: "/tracks" },
+        { title: this.$t('playlists'), icon: "playlist_play", path: "/playlists" },
+        { title: this.$t('search'), icon: "search", path: "/search" }
+    ]
+  },
+  methods: {
+    click (item) {
+      console.log("selected: "+ item.path);
+      router.push({path: item.path})
+    }
+  }
+});
diff --git a/music_assistant/web/pages/playlistdetails.vue.js b/music_assistant/web/pages/playlistdetails.vue.js
new file mode 100755 (executable)
index 0000000..b9c617d
--- /dev/null
@@ -0,0 +1,83 @@
+var PlaylistDetails = Vue.component('PlaylistDetails', {
+  template: `
+  <section>
+      <infoheader v-bind:info="info"/>
+      <v-tabs
+          v-model="active"
+          color="transparent"
+          light
+          slider-color="black"
+        >
+          <v-tab ripple>Playlist tracks</v-tab>
+          <v-tab-item>
+            <v-card flat>
+            <v-list two-line>
+                  <listviewItem 
+                      v-for="(item, index) in items" 
+                      v-bind:item="item"
+                      :key="item.db_id"
+                      :hideavatar="isMobile()"
+                      :hidetracknum="true"
+                      :hideproviders="isMobile()"
+                      :hidelibrary="isMobile()">
+                  </listviewItem>
+                </v-list>
+            </v-card>
+          </v-tab-item>
+        </v-tabs>
+      </section>`,
+  props: ['provider', 'media_id'],
+  data() {
+    return {
+      selected: [2],
+      info: {},
+      items: [],
+      offset: 0,
+      active: 0
+    }
+  },
+  created() {
+    this.$globals.windowtitle = ""
+    this.getInfo();
+    this.getPlaylistTracks();
+    this.scroll(this.Browse);
+  },
+  methods: {
+    getInfo () {
+      const api_url = '/api/playlists/' + this.media_id
+      axios
+      .get(api_url, { params: { provider: this.provider }})
+        .then(result => {
+          data = result.data;
+          this.info = data;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+    getPlaylistTracks () {
+      this.$globals.loading = true
+      const api_url = '/api/playlists/' + this.media_id + '/tracks'
+      axios
+        .get(api_url, { params: { offset: this.offset, limit: 25, provider: this.provider}})
+        .then(result => {
+          data = result.data;
+          this.items.push(...data);
+          this.offset += 25;
+          this.$globals.loading = false;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+        
+    },
+    scroll (Browse) {
+      window.onscroll = () => {
+        let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight;
+        if (bottomOfWindow) {
+          this.getPlaylistTracks();
+        }
+      };
+    }
+  }
+})
diff --git a/music_assistant/web/pages/queue.vue.js b/music_assistant/web/pages/queue.vue.js
new file mode 100755 (executable)
index 0000000..9bc25a9
--- /dev/null
@@ -0,0 +1,42 @@
+var Queue = Vue.component('Queue', {
+  template: `
+  <section>
+        <v-list two-line>
+        <listviewItem 
+            v-for="(item, index) in items" 
+            v-bind:item="item"
+            :key="item.db_id"
+            :hideavatar="isMobile()"
+            :hidetracknum="true"
+            :hideproviders="isMobile()"
+            :hidelibrary="isMobile()">
+        </listviewItem>
+      </v-list>
+      </section>`,
+  props: ['player_id'],
+  data() {
+    return {
+      selected: [0],
+      info: {},
+      items: [],
+      offset: 0,
+    }
+  },
+  created() {
+    this.$globals.windowtitle = this.$t('queue')
+    this.getQueueTracks(0, 25);
+  },
+  methods: {
+
+    getQueueTracks (offset, limit) {
+      const api_url = '/api/players/' + this.player_id + '/queue'
+      return axios.get(api_url, { params: { offset: offset, limit: limit}})
+        .then(response => {
+            if (response.data.length < 1 )
+              return;
+            this.items.push(...response.data)
+            return this.getQueueTracks(offset+limit, 100)
+        })
+    }
+  }
+})
diff --git a/music_assistant/web/pages/search.vue.js b/music_assistant/web/pages/search.vue.js
new file mode 100755 (executable)
index 0000000..996c01e
--- /dev/null
@@ -0,0 +1,154 @@
+var Search = Vue.component('Search', {
+  template: `
+  <section>
+
+    <v-text-field
+        solo
+        clearable
+        :label="$t('type_to_search')"
+        append-icon="search"
+        v-model="searchQuery" v-on:keyup.enter="Search" @click:append="Search" style="margin-left:30px; margin-right:30px; margin-top:10px">
+      </v-text-field>
+
+      <v-tabs
+          v-model="active"
+          color="transparent"
+          light
+          slider-color="black"
+        >
+
+        <v-tab ripple v-if="tracks.length">{{ $t('tracks') }}</v-tab>
+          <v-tab-item v-if="tracks.length">
+            <v-card flat>
+                <v-list two-line style="margin-left:15px; margin-right:15px">
+                    <listviewItem 
+                        v-for="(item, index) in tracks" 
+                        v-bind:item="item"
+                        :key="item.db_id"
+                        v-bind:totalitems="tracks.length"
+                        v-bind:index="index"
+                        :hideavatar="isMobile()"
+                        :hidetracknum="true"
+                        :hideproviders="isMobile()"
+                        :hideduration="isMobile()"
+                        :showlibrary="true">
+                    </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+
+          <v-tab ripple v-if="artists.length">{{ $t('artists') }}</v-tab>
+          <v-tab-item v-if="artists.length">
+            <v-card flat>
+            <v-list two-line>
+                  <listviewItem 
+                      v-for="(item, index) in artists" 
+                      v-bind:item="item"
+                      :key="item.db_id"
+                      v-bind:totalitems="artists.length"
+                      v-bind:index="index"
+                      :hideproviders="isMobile()"
+                      >
+                  </listviewItem>
+                </v-list>
+            </v-card>
+          </v-tab-item>
+
+          <v-tab ripple v-if="albums.length">{{ $t('albums') }}</v-tab>
+          <v-tab-item v-if="albums.length">
+            <v-card flat>
+                <v-list two-line>
+                    <listviewItem 
+                        v-for="(item, index) in albums" 
+                        v-bind:item="item"
+                        :key="item.db_id"
+                        v-bind:totalitems="albums.length"
+                        v-bind:index="index"
+                        :hideproviders="isMobile()"
+                        >
+                    </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+
+          <v-tab ripple v-if="playlists.length">{{ $t('playlists') }}</v-tab>
+          <v-tab-item v-if="playlists.length">
+            <v-card flat>
+                <v-list two-line>
+                    <listviewItem 
+                        v-for="(item, index) in playlists" 
+                        v-bind:item="item"
+                        :key="item.db_id"
+                        v-bind:totalitems="playlists.length"
+                        v-bind:index="index"
+                        :hidelibrary="true">
+                    </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+
+        </v-tabs>
+
+      </section>`,
+  props: [],
+  data() {
+    return {
+      selected: [2],
+      artists: [],
+      albums: [],
+      tracks: [],
+      playlists: [],
+      timeout: null,
+      active: 0,
+      searchQuery: ""
+    }
+  },
+  created() {
+    this.$globals.windowtitle = this.$t('search');
+  },
+  watch: {
+  },
+  methods: {
+    toggle (index) {
+      const i = this.selected.indexOf(index)
+      if (i > -1) {
+        this.selected.splice(i, 1)
+      } else {
+        this.selected.push(index)
+        console.log("selected: "+ this.items[index].name);
+      }
+    },
+    Search () {
+      this.artists = [];
+      this.albums = [];
+      this.tracks = [];
+      this.playlists = [];
+      if (this.searchQuery) {
+        this.$globals.loading = true;
+        console.log(this.searchQuery);
+        const api_url = '/api/search'
+        console.log('loading ' + api_url);
+          axios
+            .get(api_url, {
+              params: {
+                query: this.searchQuery,
+                online: true,
+                limit: 3
+              }
+            })
+            .then(result => {
+              data = result.data;
+              this.artists = data.artists;
+              this.albums = data.albums;
+              this.tracks = data.tracks;
+              this.playlists = data.playlists;
+              this.$globals.loading = false;
+            })
+            .catch(error => {
+              console.log("error", error);
+            });
+        } 
+        
+    },
+  }
+})
diff --git a/music_assistant/web/pages/trackdetails.vue.js b/music_assistant/web/pages/trackdetails.vue.js
new file mode 100755 (executable)
index 0000000..e8f0896
--- /dev/null
@@ -0,0 +1,77 @@
+var TrackDetails = Vue.component('TrackDetails', {
+  template: `
+  <section>
+      <infoheader v-bind:info="info"/>
+      <v-tabs
+          v-model="active"
+          color="transparent"
+          light
+          slider-color="black"
+        >
+          <v-tab ripple>Other versions</v-tab>
+          <v-tab-item>
+            <v-card flat>
+                <v-list two-line>
+                  <listviewItem 
+                      v-for="(item, index) in trackversions" 
+                      v-bind:item="item"
+                      :key="item.db_id"
+                      v-bind:totalitems="trackversions.length"
+                      v-bind:index="index"
+                      :hideavatar="isMobile()"
+                      :hidetracknum="true"
+                      :hideproviders="isMobile()"
+                      :hidelibrary="isMobile()">
+                  </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+        </v-tabs>
+
+      </section>`,
+  props: ['provider', 'media_id'],
+  data() {
+    return {
+      selected: [2],
+      info: {},
+      trackversions: [],
+      offset: 0,
+      active: null,
+    }
+  },
+  created() {
+    this.$globals.windowtitle = ""
+    this.getInfo();
+  },
+  methods: {
+    getInfo () {
+      this.$globals.loading = true;
+      const api_url = '/api/tracks/' + this.media_id
+      axios
+        .get(api_url, { params: { provider: this.provider }})
+        .then(result => {
+          data = result.data;
+          this.info = data;
+          this.getTrackVersions()
+          this.$globals.loading = false;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+    getTrackVersions () {
+      const api_url = '/api/search';
+      var searchstr = this.info.artists[0].name + " - " + this.info.name
+      axios
+        .get(api_url, { params: { query: searchstr, limit: 50, media_types: 'tracks', online: true}})
+        .then(result => {
+          data = result.data;
+          this.trackversions.push(...data.tracks);
+          this.offset += 50;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+  }
+})
diff --git a/music_assistant/web/strings.js b/music_assistant/web/strings.js
new file mode 100644 (file)
index 0000000..5c1df07
--- /dev/null
@@ -0,0 +1,179 @@
+const messages = {
+    
+    
+    en: {
+        // generic strings
+        musicassistant: "Music Assistant",
+        home: "Home",
+        artists: "Artists",
+        albums: "Albums",
+        tracks: "Tracks",
+        playlists: "Playlists",
+        radios: "Radio",
+        search: "Search",
+        settings: "Settings",
+        queue: "Queue",
+        type_to_search: "Type here to search...",
+        add_library: "Add to library",
+        remove_library: "Remove from library",
+        add_playlist: "Add to playlist...",
+        remove_playlist: "Remove from playlist",
+        // settings strings
+        conf: {
+            enabled: "Enabled",
+            base: "Generic settings",
+            musicproviders: "Music providers",
+            playerproviders: "Player providers",
+            player_settings: "Player settings",
+            homeassistant: "Home Assistant integration",
+            web: "Webserver",
+            http_streamer: "Built-in (sox based) streamer",
+            qobuz: "Qobuz",
+            spotify: "Spotify",
+            tunein: "TuneIn",
+            file: "Filesystem",
+            chromecast: "Chromecast",
+            lms: "Logitech Media Server",
+            pylms: "Emulated (built-in) Squeezebox support",
+            username: "Username",
+            password: "Password",
+            hostname: "Hostname (or IP)",
+            port: "Port",
+            hass_url: "URL to homeassistant (e.g. https://homeassistant:8123)",
+            hass_token: "Long Lived Access Token",
+            hass_publish: "Publish players to Home Assistant",
+            hass_player_power: "Attach player power to homeassistant entity",
+            hass_player_source: "Source on the homeassistant entity (optional)",
+            hass_player_volume: "Attach player volume to homeassistant entity",
+            web_ssl_cert: "Path to ssl certificate file",
+            web_ssl_key: "Path to ssl keyfile",
+            player_enabled: "Enable player",
+            player_name: "Custom name for this player",
+            player_group_with: "Group this player to another (parent)player",
+            player_mute_power: "Use muting as power control",
+            player_disable_vol: "Disable volume controls",
+            player_group_vol: "Apply group volume to childs (for group players only)",
+            player_group_pow: "Apply group power based on childs (for group players only)",
+            player_power_play: "Issue play command on power on",
+            file_prov_music_path: "Path to music files",
+            file_prov_playlists_path: "Path to playlists (.m3u)",
+            web_http_port: "HTTP port",
+            web_https_port: "HTTPS port",
+            cert_fqdn_host: "FQDN of hostname in certificate",
+            enable_r128_volume_normalisation: "Enable R128 volume normalization",
+            target_volume_lufs: "Target volume (R128 default is -23 LUFS)",
+            fallback_gain_correct: "Fallback gain correction if R128 readings not (yet) available",
+            enable_audio_cache: "Allow caching of audio to temp files",
+            trim_silence: "Strip silence from beginning and end of audio (temp files only!)",
+            http_streamer_sox_effects: "Custom sox effects to apply to audio (built-in streamer only!) See http://sox.sourceforge.net/sox.html#EFFECTS",
+            max_sample_rate: "Maximum sample rate this player supports, higher will be downsampled",
+            force_http_streamer: "Force use of built-in streamer, even if the player can handle the music provider directly",
+            not_grouped: "Not grouped",
+            conf_saved: "Configuration saved, restart app to make effective",
+            audio_cache_folder: "Directory to use for cache files",
+            audio_cache_max_size_gb: "Maximum size of the cache folder (GB)"
+        },
+        // player strings
+        players: "Players",
+        play: "Play",
+        play_on: "Play on:",
+        play_now: "Play Now",
+        play_next: "Play Next",
+        add_queue: "Add to Queue",
+        show_info: "Show info",
+        state: {        
+            playing: "playing",
+            stopped: "stopped",
+            paused: "paused",
+            off: "off"
+        }
+    },
+
+    nl: {
+        // generic strings
+        musicassistant: "Music Assistant",
+        home: "Home",
+        artists: "Artiesten",
+        albums: "Albums",
+        tracks: "Nummers",
+        playlists: "Afspeellijsten",
+        radios: "Radio",
+        search: "Zoeken",
+        settings: "Instellingen",
+        queue: "Wachtrij",
+        type_to_search: "Type hier om te zoeken...",
+        add_library: "Voeg toe aan bibliotheek",
+        remove_library: "Verwijder uit bibliotheek",
+        add_playlist: "Aan playlist toevoegen...",
+        remove_playlist: "Verwijder uit playlist",
+        // settings strings
+        conf: {
+            enabled: "Ingeschakeld",
+            base: "Algemene instellingen",
+            musicproviders: "Muziek providers",
+            playerproviders: "Speler providers",
+            player_settings: "Speler instellingen",
+            homeassistant: "Home Assistant integratie",
+            web: "Webserver",
+            http_streamer: "Ingebouwde (sox gebaseerde) streamer",
+            qobuz: "Qobuz",
+            spotify: "Spotify",
+            tunein: "TuneIn",
+            file: "Bestandssysteem",
+            chromecast: "Chromecast",
+            lms: "Logitech Media Server",
+            pylms: "Geemuleerde (ingebouwde) Squeezebox ondersteuning",
+            username: "Gebruikersnaam",
+            password: "Wachtwoord",
+            hostname: "Hostnaam (of IP)",
+            port: "Poort",
+            hass_url: "URL naar homeassistant (b.v. https://homeassistant:8123)",
+            hass_token: "Token met lange levensduur",
+            hass_publish: "Publiceer spelers naar Home Assistant",
+            hass_player_power: "Verbind speler aan/uit met homeassistant entity",
+            hass_player_source: "Benodigde bron op de verbonden homeassistant entity (optioneel)",
+            hass_player_volume: "Verbind volume van speler aan een homeassistant entity",
+            web_ssl_cert: "Pad naar ssl certificaat bestand",
+            web_ssl_key: "Pad naar ssl certificaat key bestand",
+            player_enabled: "Speler inschakelen",
+            player_name: "Aangepaste naam voor deze speler",
+            player_group_with: "Groupeer deze speler met een andere (hoofd)speler",
+            player_mute_power: "Gebruik mute als aan/uit",
+            player_disable_vol: "Schakel volume bediening helemaal uit",
+            player_group_vol: "Pas groep volume toe op onderliggende spelers (alleen groep spelers)",
+            player_group_pow: "Pas groep aan/uit toe op onderliggende spelers (alleen groep spelers)",
+            player_power_play: "Automatisch afspelen bij inschakelen",
+            file_prov_music_path: "Pad naar muziek bestanden",
+            file_prov_playlists_path: "Pad naar playlist bestanden (.m3u)",
+            web_http_port: "HTTP poort",
+            web_https_port: "HTTPS poort",
+            cert_fqdn_host: "Hostname (FQDN van certificaat)",
+            enable_r128_volume_normalisation: "Schakel R128 volume normalisatie in",
+            target_volume_lufs: "Doelvolume (R128 standaard is -23 LUFS)",
+            fallback_gain_correct: "Fallback gain correctie indien R128 meting (nog) niet beschikbaar is",
+            enable_audio_cache: "Sta het cachen van audio toe naar temp map",
+            trim_silence: "Strip stilte van begin en eind van audio (in temp bestanden)",
+            http_streamer_sox_effects: "Eigen sox effects toepassen op audio (alleen voor ingebouwde streamer). Zie http://sox.sourceforge.net/sox.html#EFFECTS",
+            max_sample_rate: "Maximale sample rate welke deze speler ondersteund, hoger wordt gedownsampled.",
+            force_http_streamer: "Forceer het gebruik van de ingebouwde streamer, ook al heeft de speler directe ondersteuning voor de muziek provider",
+            not_grouped: "Niet gegroepeerd",
+            conf_saved: "Configuratie is opgeslagen, herstart om actief te maken",
+            audio_cache_folder: "Map om te gebruiken voor cache bestanden",
+            audio_cache_max_size_gb: "Maximale grootte van de cache map in GB."
+        },
+        // player strings
+        players: "Spelers",
+        play: "Afspelen",
+        play_on: "Afspelen op:",
+        play_now: "Nu afspelen",
+        play_next: "Speel als volgende af",
+        add_queue: "Voeg toe aan wachtrij",
+        show_info: "Bekijk informatie",
+        state: {        
+            playing: "afspelen",
+            stopped: "gestopt",
+            paused: "gepauzeerd",
+            off: "uitgeschakeld"
+        }
+    }
+}
\ No newline at end of file
index 397d210781d695cb3af8429e1c745bab81b45253..d2b71b3146c6bc889bdababaf1cf5b3e3777ca37 100755 (executable)
@@ -1,6 +1,7 @@
 cytoolz
 aiohttp
 spotify_token
+protobuf
 pychromecast
 uvloop
 asyncio_throttle
@@ -10,5 +11,7 @@ pytaglib
 python-slugify
 netaddr
 memory-tempfile
-soundfile
-pyloudnorm
\ No newline at end of file
+aiohttp
+pyloudnorm
+SoundFile
+aiorun
\ No newline at end of file
diff --git a/run.sh b/run.sh
deleted file mode 100755 (executable)
index 2248eb3..0000000
--- a/run.sh
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/bin/sh
-set -e
-
-# auto update to latest git version if update environmental variable is set
-if [ "$autoupdate" == "true" ]; then
-    echo "Auto updating to latest (unstable) git version!"
-    cd /tmp
-    curl -LOks "https://github.com/marcelveldt/musicassistant/archive/master.zip"
-    unzip -q master.zip
-    rm -R /usr/src/app/
-    mkdir /usr/src/app/
-    cp -rf musicassistant-master/. /usr/src/app/
-    rm -R /tmp/musicassistant-master
-fi
-
-# run program
-cd /usr/src/app
-exec python3 main.py /data > /proc/1/fd/1 2>/proc/1/fd/2
\ No newline at end of file
diff --git a/web/components/headermenu.vue.js b/web/components/headermenu.vue.js
deleted file mode 100755 (executable)
index f0e8566..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-Vue.component("headermenu", {
-  template: `<div>
-    <v-navigation-drawer dark app clipped temporary v-model="menu">
-        <v-list >
-            <v-list-tile
-               v-for="item in items" :key="item.title" @click="$router.push(item.path)">
-                <v-list-tile-action>
-                    <v-icon>{{ item.icon }}</v-icon>
-                </v-list-tile-action>
-                <v-list-tile-content>
-                    <v-list-tile-title>{{ item.title }}</v-list-tile-title>
-                </v-list-tile-content>
-            </v-list-tile>
-        </v-list>
-    </v-navigation-drawer>
-    
-
-    <v-toolbar app flat dense dark v-if="$globals.windowtitle" > 
-        <div class="title justify-center" style="text-align:center;position:absolute;width:100%;margin-left:-16px;margin-right:0">
-          {{ $globals.windowtitle }}
-        </div>
-        <v-layout align-center>
-            <v-btn icon v-on:click="menu=!menu">
-              <v-icon>menu</v-icon>
-            </v-btn>
-            <v-btn @click="$router.go(-1)" icon v-if="$route.path != '/'">
-              <v-icon>arrow_back</v-icon>
-            </v-btn>
-        </v-layout>
-    </v-toolbar>
-    <v-toolbar flat fixed dense dark scroll-off-screen color="transparent" v-if="!$globals.windowtitle" > 
-        <v-layout align-center>
-            <v-btn icon v-on:click="menu=!menu">
-              <v-icon>menu</v-icon>
-            </v-btn>
-            <v-btn @click="$router.go(-1)" icon>
-              <v-icon>arrow_back</v-icon>
-            </v-btn>
-            <v-spacer></v-spacer>
-            <v-spacer></v-spacer>
-            <v-btn icon v-on:click="$router.push({path: '/search'})">
-                <v-icon>search</v-icon>
-              </v-btn>
-        </v-layout>
-    </v-toolbar>
-</div>`,
-  props: [],
-  $_veeValidate: {
-    validator: "new"
-  },
-  data() {
-    return {
-      menu: false,
-      items: [
-        { title: this.$t('home'), icon: "home", path: "/" },
-        { title: this.$t('artists'), icon: "person", path: "/artists" },
-        { title: this.$t('albums'), icon: "album", path: "/albums" },
-        { title: this.$t('tracks'), icon: "audiotrack", path: "/tracks" },
-        { title: this.$t('playlists'), icon: "playlist_play", path: "/playlists" },
-        { title: this.$t('radios'), icon: "radio", path: "/radios" },
-        { title: this.$t('search'), icon: "search", path: "/search" },
-        { title: this.$t('settings'), icon: "settings", path: "/config" }
-      ]
-    }
-  },
-  mounted() { },
-  methods: { }
-})
diff --git a/web/components/infoheader.vue.js b/web/components/infoheader.vue.js
deleted file mode 100644 (file)
index 9d8bc8c..0000000
+++ /dev/null
@@ -1,135 +0,0 @@
-Vue.component("infoheader", {\r
-       template: `\r
-               <v-flex xs12>\r
-          <v-card color="cyan darken-2" class="white--text" img="../images/info_gradient.jpg">\r
-            <v-img\r
-              class="white--text"\r
-              width="100%"\r
-              :height="isMobile() ? '230' : '370'"\r
-              position="center top" \r
-              :src="getFanartImage()"\r
-              gradient="to bottom, rgba(0,0,0,.65), rgba(0,0,0,.35)"\r
-            >\r
-            <div class="text-xs-center" style="height:40px" id="whitespace_top"/>\r
-\r
-            <v-layout style="margin-left:5px;margin-right:5px">\r
-              \r
-              <!-- left side: cover image -->\r
-              <v-flex xs5 pa-4 v-if="!isMobile()">\r
-                                                               <v-img :src="getThumb()" lazy-src="/images/default_artist.png" width="250px" height="250px" style="border: 4px solid grey;border-radius: 15px;"></v-img>\r
-                                                               \r
-                                                               <!-- tech specs and provider icons -->\r
-                                                               <div style="margin-top:10px;">\r
-                                                                       <providericons v-bind:item="info" :height="30" :compact="false"/>\r
-                                                               </div>\r
-              </v-flex>\r
-              \r
-              <v-flex>\r
-                  <!-- Main title -->\r
-                  <v-card-title class="display-1" style="text-shadow: 1px 1px #000000;padding-bottom:0px;">\r
-                                                                                       {{ info.name }} \r
-                                                                                       <span class="subheading" v-if="!!info.version" style="padding-left:10px;"> ({{ info.version }})</span>\r
-                                                                       </v-card-title>\r
-                                                                       \r
-                                                                       <!-- item artists -->\r
-                                                                       <v-card-title style="text-shadow: 1px 1px #000000;padding-top:0px;padding-bottom:10px;">\r
-                                                                                       <span v-if="!!info.artists" v-for="(artist, artistindex) in info.artists" class="headline" :key="artist.db_id">\r
-                                                                                                       <a style="color:#2196f3" v-on:click="clickItem(artist)">{{ artist.name }}</a>\r
-                                                                                                       <label style="color:#2196f3" v-if="artistindex + 1 < info.artists.length" :key="artistindex"> / </label>\r
-                                                                                       </span>\r
-                                                                                       <span v-if="info.artist" class="headline">\r
-                                                                                                       <a style="color:#2196f3" v-on:click="clickItem(info.artist)">{{ info.artist.name }}</a>\r
-                                                                                       </span>\r
-                                                                                       <span v-if="info.owner" class="headline">\r
-                                                                                                       <a style="color:#2196f3" v-on:click="">{{ info.owner }}</a>\r
-                                                                                       </span>\r
-                                                                       </v-card-title>\r
-\r
-                                                                       <v-card-title v-if="info.album" style="color:#ffffff;text-shadow: 1px 1px #000000;padding-top:0px;padding-bottom:10px;">\r
-                                                                                       <a class="headline" style="color:#ffffff" v-on:click="clickItem(info.album)">{{ info.album.name }}</a>\r
-                                                                       </v-card-title>\r
-\r
-                  <!-- play/info buttons -->\r
-                  <div style="margin-left:8px;">\r
-                      <v-btn color="blue-grey" @click="showPlayMenu(info)"  class="white--text"><v-icon v-if="!isMobile()" left dark>play_circle_outline</v-icon>{{ $t('play') }}</v-btn>\r
-                      <v-btn v-if="!!info.in_library && info.in_library.length == 0" color="blue-grey" @click="toggleLibrary(info)"  class="white--text"><v-icon v-if="!isMobile()" left dark>favorite_border</v-icon>{{ $t('add_library') }}</v-btn>\r
-                      <v-btn v-if="!!info.in_library && info.in_library.length > 0" color="blue-grey" @click="toggleLibrary(info)"  class="white--text"><v-icon v-if="!isMobile()" left dark>favorite</v-icon>{{ $t('remove_library') }}</v-btn>\r
-                  </div>\r
-\r
-                  <!-- Description/metadata -->\r
-                  <v-card-title class="subheading">\r
-                      <div class="justify-left" style="text-shadow: 1px 1px #000000;">\r
-                          <read-more :text="getDescription()" :max-chars="isMobile() ? 60 : 350"></read-more>\r
-                      </div>\r
-                  </v-card-title>\r
-\r
-              </v-flex>\r
-            </v-layout>\r
-              \r
-            </v-img>\r
-            <div class="text-xs-center" v-if="info.tags">\r
-                <v-chip small color="white"  outline v-for="(tag, index) in info.tags" :key="tag" >{{ tag }}</v-chip>\r
-            </div>\r
-            \r
-          </v-card>\r
-        </v-flex>\r
-`,\r
-       props: ['info'],\r
-       data (){\r
-               return{}\r
-       },\r
-       mounted() { },\r
-       created() { },\r
-       methods: { \r
-               getFanartImage() {\r
-                       var img = '';\r
-                       if (!this.info)\r
-                               return ''\r
-      if (this.info.metadata && this.info.metadata.fanart)\r
-                               img = this.info.metadata.fanart;\r
-                       else if (this.info.artists)\r
-                                       this.info.artists.forEach(function(artist) {\r
-                                               if (artist.metadata && artist.metadata.fanart)\r
-                                                       img = artist.metadata.fanart;\r
-                                       });\r
-                       else if (this.info.artist && this.info.artist.metadata.fanart)\r
-                               img = this.info.artist.metadata.fanart;\r
-                       return img;\r
-               },\r
-               getThumb() {\r
-                       var img = '';\r
-                       if (!this.info)\r
-                               return ''\r
-      if (this.info.metadata && this.info.metadata.image)\r
-                               img = this.info.metadata.image;\r
-                       else if (this.info.album && this.info.album.metadata && this.info.album.metadata.image)\r
-                               img = this.info.album.metadata.image;\r
-                       else if (this.info.artists)\r
-                                       this.info.artists.forEach(function(artist) {\r
-                                               if (artist.metadata && artist.metadata.image)\r
-                                                       img = artist.metadata.image;\r
-                                       });\r
-                       return img;\r
-               },\r
-               getDescription() {\r
-                       var desc = '';\r
-                       if (!this.info)\r
-                               return ''\r
-      if (this.info.metadata && this.info.metadata.description)\r
-                               return this.info.metadata.description;\r
-                       else if (this.info.metadata && this.info.metadata.biography)\r
-                               return this.info.metadata.biography;\r
-                       else if (this.info.metadata && this.info.metadata.copyright)\r
-                               return this.info.metadata.copyright;\r
-                       else if (this.info.artists)\r
-                       {\r
-                               this.info.artists.forEach(function(artist) {\r
-                                       console.log(artist.metadata.biography);\r
-                                       if (artist.metadata && artist.metadata.biography)\r
-                                                       desc = artist.metadata.biography;\r
-                               });\r
-                       }\r
-                       return desc;\r
-               },\r
-       }\r
-})\r
diff --git a/web/components/listviewItem.vue.js b/web/components/listviewItem.vue.js
deleted file mode 100755 (executable)
index 687c69c..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-Vue.component("listviewItem", {
-  template: `
-    <div>
-    <v-list-tile
-    avatar
-    ripple
-    @click="clickItem(item)">
-
-          <v-list-tile-avatar color="grey" v-if="!hideavatar">
-              <img v-if="(item.media_type != 3) && item.metadata && item.metadata.image" :src="item.metadata.image"/>
-              <img v-if="(item.media_type == 3) && item.album && item.album.metadata && item.album.metadata.image" :src="item.album.metadata.image"/>
-              <v-icon v-if="(item.media_type == 3) && item.album && item.album.metadata && !item.album.metadata.image">audiotrack</v-icon>
-              <v-icon v-if="(item.media_type != 1 && item.media_type != 3) && (!item.metadata || !item.metadata.image)">album</v-icon>
-              <v-icon v-if="(item.media_type == 1) && (!item.metadata || !item.metadata.image)">person</v-icon>
-              <v-icon v-if="(item.media_type == 3) && (!item.metadata || !item.album.metadata.image)">audiotrack</v-icon>
-          </v-list-tile-avatar>
-          
-          <v-list-tile-content>
-            
-            <v-list-tile-title>
-                {{ item.name }}<span v-if="!!item.version"> ({{ item.version }})</span>
-            </v-list-tile-title>
-            
-            <v-list-tile-sub-title v-if="item.artists">
-                <span v-for="(artist, artistindex) in item.artists">
-                    <a v-on:click="clickItem(artist)" @click.stop="">{{ artist.name }}</a>
-                    <label v-if="artistindex + 1 < item.artists.length" :key="artistindex"> / </label>
-                </span>
-                <a v-if="!!item.album && !!hidetracknum" v-on:click="clickItem(item.album)" @click.stop="" style="color:grey">  -  {{ item.album.name }}</a>
-                <label v-if="!hidetracknum && item.track_number" style="color:grey">  -  disc {{ item.disc_number }} track {{ item.track_number }}</label>
-            </v-list-tile-sub-title>
-            <v-list-tile-sub-title v-if="item.artist">
-                <a v-on:click="clickItem(item.artist)" @click.stop="">{{ item.artist.name }}</a>
-            </v-list-tile-sub-title>
-
-            <v-list-tile-sub-title v-if="!!item.owner">
-                {{ item.owner }}
-            </v-list-tile-sub-title>
-
-          </v-list-tile-content>
-
-          <providericons v-bind:item="item" :height="20" :compact="true" :dark="true" :hiresonly="hideproviders"/>
-
-          <v-list-tile-action v-if="!hidelibrary">
-              <v-tooltip bottom>
-                  <template v-slot:activator="{ on }">
-                      <v-btn icon ripple v-on="on" v-on:click="toggleLibrary(item)" @click.stop="" >
-                          <v-icon height="20" v-if="item.in_library.length > 0">favorite</v-icon>
-                          <v-icon height="20" v-if="item.in_library.length == 0">favorite_border</v-icon>
-                      </v-btn>
-                  </template>
-                  <span v-if="item.in_library.length > 0">{{ $t('remove_library') }}</span>
-                  <span v-if="item.in_library.length == 0">{{ $t('add_library') }}</span>
-              </v-tooltip>
-          </v-list-tile-action>
-
-          <v-list-tile-action v-if="!hideduration && !!item.duration">
-              {{ item.duration.toString().formatDuration() }}
-          </v-list-tile-action> 
-        
-          <!-- menu button/icon -->
-          <v-icon v-if="!hidemenu" @click="showPlayMenu(item)" @click.stop="" color="grey lighten-1" style="margin-right:-10px;padding-left:10px">more_vert</v-icon>
-          
-
-        </v-list-tile>
-        <v-divider v-if="index + 1 < totalitems" :key="index"></v-divider>
-        </div>
-     `,
-props: ['item', 'index', 'totalitems', 'hideavatar', 'hidetracknum', 'hideproviders', 'hidemenu', 'hidelibrary', 'hideduration'],
-data() {
-  return {}
-  },
-methods: {
-  }
-})
diff --git a/web/components/player.vue.js b/web/components/player.vue.js
deleted file mode 100755 (executable)
index d8b9859..0000000
+++ /dev/null
@@ -1,321 +0,0 @@
-Vue.component("player", {
-  template: `
-  <div>
-
-    <!-- player bar in footer -->
-    <v-footer app light height="auto">
-      
-      <v-card class="flex" tile style="background-color:#e8eaed;">
-        <!-- divider -->
-        <v-list-tile avatar ripple style="height:1px;background-color:#cccccc;"/>
-
-        <!-- now playing media -->
-        <v-list-tile avatar ripple>
-
-              <v-list-tile-avatar v-if="active_player.cur_item" style="align-items:center;padding-top:15px;">
-                  <img v-if="active_player.cur_item.metadata && active_player.cur_item.metadata.image" :src="active_player.cur_item.metadata.image"/>
-                  <img v-if="!active_player.cur_item.metadata.image && active_player.cur_item.album && active_player.cur_item.album.metadata && active_player.cur_item.album.metadata.image" :src="active_player.cur_item.album.metadata.image"/>
-              </v-list-tile-avatar>
-
-              <v-list-tile-content style="align-items:center;padding-top:15px;">
-                  <v-list-tile-title class="title">{{ active_player.cur_item ? active_player.cur_item.name : active_player.name }}</v-list-tile-title>
-                  <v-list-tile-sub-title v-if="active_player.cur_item && active_player.cur_item.artists">
-                      <span v-for="(artist, artistindex) in active_player.cur_item.artists">
-                          <a v-on:click="clickItem(artist)" @click.stop="">{{ artist.name }}</a>
-                          <label v-if="artistindex + 1 < active_player.cur_item.artists.length" :key="artistindex"> / </label>
-                      </span>
-                  </v-list-tile-sub-title>
-              </v-list-tile-content>
-
-          </v-list-tile>
-
-          <!-- progress bar -->
-          <div style="color:rgba(0,0,0,.65); height:30px;width:100%; vertical-align: middle; left:15px; right:0; margin-bottom:5px; margin-top:5px">
-            <v-layout row style="vertical-align: middle" v-if="active_player.cur_item">
-              <span style="text-align:left; width:60px; margin-top:7px; margin-left:15px;">{{ player_time_str_cur }}</span>
-              <v-progress-linear v-model="progress"></v-progress-linear>
-              <span style="text-align:right; width:60px; margin-top:7px; margin-right: 15px;">{{ player_time_str_total }}</span>
-            </v-layout>
-        </div>
-
-        <!-- divider -->
-        <v-list-tile avatar ripple style="height:1px;background-color:#cccccc;"/>
-
-          <!-- Control buttons -->
-          <v-list-tile light avatar ripple style="margin-bottom:5px;">
-              
-          <!-- player controls -->
-              <v-list-tile-content>
-                  <v-layout row style="content-align: left;vertical-align: middle; margin-top:10px;margin-left:-15px">
-                    <v-btn small icon style="padding:5px;" @click="playerCommand('previous')"><v-icon color="rgba(0,0,0,.54)">skip_previous</v-icon></v-btn>
-                    <v-btn small icon style="padding:5px;" v-if="active_player.state == 'playing'" @click="playerCommand('pause')"><v-icon size="45" color="rgba(0,0,0,.65)" style="margin-top:-9px;">pause</v-icon></v-btn>
-                    <v-btn small icon style="padding:5px;" v-if="active_player.state != 'playing'" @click="playerCommand('play')"><v-icon size="45" color="rgba(0,0,0,.65)" style="margin-top:-9px;">play_arrow</v-icon></v-btn>
-                    <v-btn small icon style="padding:5px;" @click="playerCommand('next')"><v-icon color="rgba(0,0,0,.54)">skip_next</v-icon></v-btn>
-                  </v-layout>
-              </v-list-tile-content>
-
-              <!-- active player queue button -->
-              <v-list-tile-action style="padding:20px;" v-if="active_player_id">
-                  <v-btn x-small flat icon @click="$router.push('/queue/' + active_player_id)">
-                      <v-flex xs12 class="vertical-btn">
-                      <v-icon>queue_music</v-icon>
-                      <span class="caption">{{ $t('queue') }}</span>
-                    </v-flex>    
-                  </v-btn>
-              </v-list-tile-action> 
-
-              <!-- active player volume -->
-              <v-list-tile-action style="padding:20px;" v-if="active_player_id">
-                  <v-menu :close-on-content-click="false" :nudge-width="250" offset-x top>
-                    <template v-slot:activator="{ on }">
-                        <v-btn x-small flat icon v-on="on">
-                            <v-flex xs12 class="vertical-btn">
-                            <v-icon>volume_up</v-icon>
-                            <span class="caption">{{ Math.round(players[active_player_id].volume_level) }}</span>
-                          </v-flex>    
-                        </v-btn>
-                    </template>
-                    <volumecontrol v-bind:players="players" v-bind:player_id="active_player_id" v-on:setPlayerVolume="setPlayerVolume" v-on:togglePlayerPower="togglePlayerPower"/>
-                  </v-menu>
-              </v-list-tile-action> 
-
-              <!-- active player btn -->
-              <v-list-tile-action style="padding:30px;margin-right:-13px;">
-                  <v-btn x-small flat icon @click="menu = !menu">
-                      <v-flex xs12 class="vertical-btn">
-                      <v-icon>speaker</v-icon>
-                      <span class="caption">{{ active_player_id ? players[active_player_id].name : '' }}</span>
-                    </v-flex>    
-                  </v-btn>
-              </v-list-tile-action>
-          </v-list-tile>
-
-          <!-- add some additional whitespace in standalone mode only -->
-          <v-list-tile avatar ripple style="height:14px" v-if="isInStandaloneMode()"/>
-
-          
-
-      </v-card>
-    </v-footer>
-
-    <!-- players side menu -->
-    <v-navigation-drawer right app clipped temporary v-model="menu">
-        <v-card-title class="headline">
-            <b>{{ $t('players') }}</b>
-        </v-card-title>
-        <v-list two-line>
-            <v-divider></v-divider>
-            <div v-for="(player, player_id, index) in players" :key="player_id" v-if="player.enabled && !player.group_parent">
-              <v-list-tile avatar ripple style="margin-left: -5px; margin-right: -15px" @click="switchPlayer(player.player_id)" :style="active_player_id == player.player_id ? 'background-color: rgba(50, 115, 220, 0.3);' : ''">
-                  <v-list-tile-avatar>
-                      <v-icon size="45">{{ isGroup(player.player_id) ? 'speaker_group' : 'speaker' }}</v-icon>
-                  </v-list-tile-avatar>
-                  <v-list-tile-content>
-                      <v-list-tile-title class="title">{{ player.name }}</v-list-tile-title>
-
-                      <v-list-tile-sub-title v-if="player.cur_item" class="body-1" :key="player.state">
-                          {{ $t('state.' + player.state) }}
-                      </v-list-tile-sub-title>
-
-                  </v-list-tile-content>
-
-                  <v-list-tile-action style="padding:30px;" v-if="active_player_id">
-                      <v-menu :close-on-content-click="false" :nudge-width="250" offset-x right>
-                        <template v-slot:activator="{ on }">
-                            <v-btn flat icon style="color:rgba(0,0,0,.54);" v-on="on">
-                                <v-flex xs12 class="vertical-btn">
-                                <v-icon>volume_up</v-icon>
-                                <span class="caption">{{ Math.round(player.volume_level) }}</span>
-                              </v-flex>    
-                            </v-btn>
-                        </template>
-                        <volumecontrol v-bind:players="players" v-bind:player_id="player.player_id" v-on:setPlayerVolume="setPlayerVolume" v-on:togglePlayerPower="togglePlayerPower"/>
-                      </v-menu>
-                  </v-list-tile-action> 
-              </v-list-tile>
-            <v-divider></v-divider>
-            </div>
-        </v-list>
-    </v-navigation-drawer>
-    <playmenu v-model="$globals.showplaymenu" v-on:playItem="playItem" :active_player="active_player" />
-  </div>
-  
-  `,
-  props: [],
-  $_veeValidate: {
-    validator: "new"
-  },
-  watch: {},
-  data() {
-    return {
-      menu: false,
-      players: {},
-      active_player_id: "",
-      ws: null
-    }
-  },
-  mounted() { },
-  created() {
-    this.connectWS();
-    this.updateProgress();
-  },
-  computed: {
-
-    active_player() {
-      if (this.players && this.active_player_id && this.active_player_id in this.players)
-          return this.players[this.active_player_id];
-      else
-          return {
-            name: 'no player selected',
-            cur_item: null,
-            cur_time: 0,
-            player_id: '',
-            volume_level: 0,
-            state: 'stopped'
-          };
-    },
-    progress() {
-      if (!this.active_player.cur_item)
-        return 0;
-      var total_sec = this.active_player.cur_item.duration;
-      var cur_sec = this.active_player.cur_time;
-      var cur_percent = cur_sec/total_sec*100;
-      return cur_percent;
-    },
-    player_time_str_cur() {
-      if (!this.active_player.cur_item || !this.active_player.cur_time)
-        return "0:00";
-      var cur_sec = this.active_player.cur_time;
-      return cur_sec.toString().formatDuration();
-    },
-    player_time_str_total() {
-      if (!this.active_player.cur_item)
-        return "0:00";
-      var total_sec = this.active_player.cur_item.duration;
-      return total_sec.toString().formatDuration();
-    }
-  },
-  methods: { 
-    playerCommand (cmd, cmd_opt=null, player_id=this.active_player_id) {
-      if (cmd_opt)
-        cmd = cmd + '/' + cmd_opt
-      cmd = 'players/' + player_id + '/cmd/' + cmd;
-      this.ws.send(cmd);
-    },
-    playItem(item, queueopt) {
-      console.log('playItem: ' + item);
-      this.$globals.loading = true;
-      var api_url = 'api/players/' + this.active_player_id + '/play_media/' + item.media_type + '/' + item.item_id + '/' + queueopt;
-      axios
-      .get(api_url, {
-        params: {
-          provider: item.provider
-        }
-      })
-      .then(result => {
-        console.log(result.data);
-        this.$globals.loading = false;
-      })
-      .catch(error => {
-        console.log("error", error);
-        this.$globals.loading = false;
-      });
-    },
-    switchPlayer (new_player_id) {
-      this.active_player_id = new_player_id;
-    },
-    isGroup(player_id) {
-                       for (var item in this.players)
-                               if (this.players[item].group_parent == player_id && this.players[item].enabled)
-                                       return true;
-                       return false;
-    },
-    updateProgress: function(){           
-      this.intervalid2 = setInterval(function(){
-          if (this.active_player.state == 'playing')
-              this.active_player.cur_time +=1;
-      }.bind(this), 1000);
-    },
-    setPlayerVolume: function(player_id, new_volume) {
-      this.players[player_id].volume_level = new_volume;
-      if (new_volume == 'up')
-        this.playerCommand('volume_up', null, player_id);
-      else if (new_volume == 'down')
-        this.playerCommand('volume_down', null, player_id);
-      else
-        this.playerCommand('volume_set', new_volume, player_id);
-    },
-    togglePlayerPower: function(player_id) {
-      if (this.players[player_id].powered)
-        this.playerCommand('power_off', null, player_id);
-      else
-        this.playerCommand('power_on', null, player_id);
-    },
-    connectWS() {
-      var loc = window.location, new_uri;
-      if (loc.protocol === "https:") {
-          new_uri = "wss:";
-      } else {
-          new_uri = "ws:";
-      }
-      new_uri += "/" + loc.host;
-      new_uri += loc.pathname + "ws";
-      this.ws = new WebSocket(new_uri);
-
-      this.ws.onopen = function() {
-        console.log('websocket connected!');
-        this.ws.send('players');
-      }.bind(this);
-    
-      this.ws.onmessage = function(e) {
-        var msg = JSON.parse(e.data);
-        if (msg.message == 'player changed')
-          {
-            Vue.set(this.players, msg.message_details.player_id, msg.message_details);
-        }
-        else if (msg.message == 'player removed') {
-          this.players[msg.message_details.player_id].enabled = false;
-        }
-        else if (msg.message == 'players') {
-          for (var item of msg.message_details) {
-              console.log("new player: " + item.player_id);
-              Vue.set(this.players, item.player_id, item);
-          }
-        }
-        else
-          console.log(msg);
-
-        // select new active player
-        // TODO: store previous player in local storage
-        if (!this.active_player_id || !this.players[this.active_player_id].enabled)
-          for (var player_id in this.players)
-            if (this.players[player_id].state == 'playing' && this.players[player_id].enabled && !this.players[player_id].group_parent) {
-              // prefer the first playing player
-              this.active_player_id = player_id;
-              break; 
-            }
-            if (!this.active_player_id || !this.players[this.active_player_id].enabled)
-          for (var player_id in this.players) {
-            // fallback to just the first player
-            if (this.players[player_id].enabled && !this.players[player_id].group_parent)
-            {
-              this.active_player_id = player_id;
-              break; 
-            }
-          }
-      }.bind(this);
-    
-      this.ws.onclose = function(e) {
-        console.log('Socket is closed. Reconnect will be attempted in 5 seconds.', e.reason);
-        setTimeout(function() {
-          this.connectWS();
-        }.bind(this), 5000);
-      }.bind(this);
-    
-      this.ws.onerror = function(err) {
-        console.error('Socket encountered error: ', err.message, 'Closing socket');
-        this.ws.close();
-      }.bind(this);
-    }
-  }
-})
diff --git a/web/components/playmenu.vue.js b/web/components/playmenu.vue.js
deleted file mode 100644 (file)
index 611ecc3..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-Vue.component("playmenu", {\r
-       template: `\r
-       <v-dialog :value="value" @input="$emit('input', $event)" max-width="500px" v-if="$globals.playmenuitem">\r
-        <v-card>\r
-               <v-list>\r
-               <v-subheader class="title">{{ !!$globals.playmenuitem ? $globals.playmenuitem.name : '' }}</v-subheader>\r
-                       <v-subheader>{{ $t('play_on') }} {{ active_player.name }}</v-subheader>\r
-                       \r
-                       <v-list-tile avatar @click="itemClick('play')">\r
-                               <v-list-tile-avatar>\r
-                                       <v-icon>play_circle_outline</v-icon>\r
-                               </v-list-tile-avatar>\r
-                               <v-list-tile-content>\r
-                                       <v-list-tile-title>{{ $t('play_now') }}</v-list-tile-title>\r
-                               </v-list-tile-content>\r
-                       </v-list-tile>\r
-                       <v-divider></v-divider>\r
-\r
-                       <v-list-tile avatar @click="itemClick('next')">\r
-                               <v-list-tile-avatar>\r
-                                       <v-icon>queue_play_next</v-icon>\r
-                               </v-list-tile-avatar>\r
-                               <v-list-tile-content>\r
-                                       <v-list-tile-title>{{ $t('play_next') }}</v-list-tile-title>\r
-                               </v-list-tile-content>\r
-                       </v-list-tile>\r
-                       <v-divider></v-divider>\r
-\r
-                       <v-list-tile avatar @click="itemClick('add')">\r
-                               <v-list-tile-avatar>\r
-                                       <v-icon>playlist_add</v-icon>\r
-                               </v-list-tile-avatar>\r
-                               <v-list-tile-content>\r
-                                       <v-list-tile-title>{{ $t('add_queue') }}</v-list-tile-title>\r
-                               </v-list-tile-content>\r
-                       </v-list-tile>\r
-                       <v-divider></v-divider>\r
-\r
-                       <v-list-tile avatar @click="itemClick('info')" v-if="$globals.playmenuitem.media_type == 3">\r
-                               <v-list-tile-avatar>\r
-                                       <v-icon>info</v-icon>\r
-                               </v-list-tile-avatar>\r
-                               <v-list-tile-content>\r
-                                       <v-list-tile-title>{{ $t('show_info') }}</v-list-tile-title>\r
-                               </v-list-tile-content>\r
-                       </v-list-tile>\r
-                       <v-divider v-if="$globals.playmenuitem.media_type == 3"/>\r
-\r
-                       <v-list-tile avatar @click="itemClick('add_playlist')" v-if="$globals.playmenuitem.media_type == 3">\r
-                               <v-list-tile-avatar>\r
-                                       <v-icon>add_circle_outline</v-icon>\r
-                               </v-list-tile-avatar>\r
-                               <v-list-tile-content>\r
-                                       <v-list-tile-title>{{ $t('add_playlist') }}</v-list-tile-title>\r
-                               </v-list-tile-content>\r
-                       </v-list-tile>\r
-                       <v-divider v-if="$globals.playmenuitem.media_type == 3"/>\r
-\r
-                       <v-list-tile avatar @click="itemClick('remove_playlist')" v-if="$globals.playmenuitem.media_type == 3 && this.$route.path.startsWith('/playlists/')">\r
-                               <v-list-tile-avatar>\r
-                                       <v-icon>remove_circle_outline</v-icon>\r
-                               </v-list-tile-avatar>\r
-                               <v-list-tile-content>\r
-                                       <v-list-tile-title>{{ $t('remove_playlist') }}</v-list-tile-title>\r
-                               </v-list-tile-content>\r
-                       </v-list-tile>\r
-                       <v-divider v-if="$globals.playmenuitem.media_type == 3  && this.$route.path.startsWith('/playlists/')"/>\r
-                       \r
-               </v-list>\r
-        </v-card>\r
-      </v-dialog>\r
-`,\r
-       props: ['value', 'active_player'],\r
-       data (){\r
-               return{\r
-                       fav: true,\r
-                       message: false,\r
-                       hints: true,\r
-                       }\r
-       },\r
-       mounted() { },\r
-       created() { },\r
-       methods: { \r
-               itemClick(cmd) {\r
-               if (cmd == 'info')\r
-                               this.$router.push({ path: '/tracks/' + this.$globals.playmenuitem.item_id, query: {provider: this.$globals.playmenuitem.provider}})\r
-                       else\r
-                               this.$emit('playItem', this.$globals.playmenuitem, cmd)\r
-                       // close dialog\r
-                       this.$globals.showplaymenu = false;\r
-       },\r
-       }\r
-  })\r
diff --git a/web/components/providericons.vue.js b/web/components/providericons.vue.js
deleted file mode 100644 (file)
index 0e0124d..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-Vue.component("providericons", {\r
-       template: `\r
-               <div :style="'height:' + height + 'px;'">\r
-                       <span v-for="provider in uniqueProviders" :key="provider.item_id" style="padding:5px;vertical-align: middle;" v-if="!hiresonly || provider.quality > 6">\r
-                               <v-tooltip bottom>\r
-                                       <template v-slot:activator="{ on }">\r
-                                               <img v-on="on" :height="height" src="images/icons/hires.png" v-if="provider.quality > 6" style="margin-right:9px"/>\r
-                                               <img v-on="on" :height="height" :src="'images/icons/' + provider.provider + '.png'" v-if="!hiresonly"/>\r
-                                       </template>\r
-                                       <div align="center" v-if="item.media_type == 3">\r
-                                               <img height="35px" :src="getFileFormatLogo(provider)"/>\r
-                                               <span><br>{{ getFileFormatDesc(provider) }}</span>\r
-                                       </div>\r
-                                       <span v-if="item.media_type != 3">{{ provider.provider }}</span>\r
-                               </v-tooltip> \r
-                       </span>         \r
-               </div>\r
-`,\r
-       props: ['item','height','compact', 'dark', 'hiresonly'],\r
-       data (){\r
-               return{}\r
-       },\r
-       mounted() { },\r
-       created() { },\r
-       computed: {\r
-               uniqueProviders() {\r
-                       var keys = [];\r
-                       var qualities = [];\r
-                       if (!this.item || !this.item.provider_ids)\r
-                               return []\r
-                       let sorted_item_ids = this.item.provider_ids.sort((a,b) => (a.quality < b.quality) ? 1 : ((b.quality < a.quality) ? -1 : 0));\r
-                       if (!this.compact)\r
-                               return sorted_item_ids;\r
-                       for (provider of sorted_item_ids) {\r
-                               if (!keys.includes(provider.provider)){\r
-                                       qualities.push(provider);\r
-                                       keys.push(provider.provider);\r
-                               }\r
-                       }\r
-                       return qualities;\r
-               }\r
-       },\r
-       methods: { \r
-\r
-               getFileFormatLogo(provider) {\r
-                       if (provider.quality == 0)\r
-                               return 'images/icons/mp3.png'\r
-                       else if (provider.quality == 1)\r
-                               return 'images/icons/vorbis.png'\r
-                       else if (provider.quality == 2)\r
-                               return 'images/icons/aac.png'\r
-                       else if (provider.quality > 2)\r
-                               return 'images/icons/flac.png'\r
-                       },\r
-               getFileFormatDesc(provider) {\r
-                       var desc = '';\r
-                       if (provider.details)\r
-                               desc += ' ' + provider.details;\r
-                       return desc;\r
-               },\r
-               getMaxQualityFormatDesc() {\r
-                       var desc = '';\r
-                       if (provider.details)\r
-                               desc += ' ' + provider.details;\r
-                       return desc;\r
-               }\r
-    }\r
-  })\r
diff --git a/web/components/readmore.vue.js b/web/components/readmore.vue.js
deleted file mode 100644 (file)
index 6af2fd3..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-Vue.component("read-more", {\r
-       template: `\r
-       <div>\r
-               <span v-html="formattedString"/> <a style="color:white" :href="link" id="readmore" v-if="text.length > maxChars" v-on:click="triggerReadMore($event, true)">{{moreStr}}</a></p>\r
-               <v-dialog v-model="isReadMore" width="80%">\r
-                       <v-card>\r
-                               <v-card-text class="subheading"><span v-html="text"/></v-card-text>\r
-                       </v-card>\r
-                       </v-dialog>\r
-       </div>`,\r
-       props: {\r
-               moreStr: {\r
-                       type: String,\r
-                       default: 'read more'\r
-               },\r
-               lessStr: {\r
-                       type: String,\r
-                       default: ''\r
-               },\r
-               text: {\r
-                       type: String,\r
-                       required: true\r
-               },\r
-               link: {\r
-                       type: String,\r
-                       default: '#'\r
-               },\r
-               maxChars: {\r
-                       type: Number,\r
-                       default: 100\r
-               }\r
-       },\r
-       $_veeValidate: {\r
-         validator: "new"\r
-       },\r
-       data (){\r
-               return{\r
-                       isReadMore: false\r
-               }\r
-       },\r
-       mounted() { },\r
-       computed: {\r
-               formattedString(){\r
-                       var val_container = this.text;\r
-                       if(this.text.length > this.maxChars){\r
-                               val_container = val_container.substring(0,this.maxChars) + '...';\r
-                       }\r
-                       return(val_container);\r
-               }\r
-       },\r
-\r
-       methods: {\r
-               triggerReadMore(e, b){\r
-                       if(this.link == '#'){\r
-                               e.preventDefault();\r
-                       }\r
-                       if(this.lessStr !== null || this.lessStr !== '')\r
-                       {\r
-                               this.isReadMore = b;\r
-                       }\r
-               }\r
-       }\r
-  })\r
diff --git a/web/components/searchbox.vue.js b/web/components/searchbox.vue.js
deleted file mode 100644 (file)
index 1570ab6..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-Vue.component("searchbox", {
-  template: `
-  <v-dialog :value="$globals.showsearchbox" @input="$emit('input', $event)" max-width="500px">
-      <v-text-field
-            solo
-            clearable
-            :label="$t('type_to_search')"
-            prepend-inner-icon="search"
-            v-model="searchQuery">
-          </v-text-field>
-      </v-dialog>
-  `,
-  data () {
-    return {
-      searchQuery: "",
-    }
-  },
-  props: ['value'],
-  mounted () {
-    this.searchQuery = "" // TODO: set to last searchquery ?
-  },
-  watch: {
-    searchQuery: {
-      handler: _.debounce(function (val) {
-        this.onSearch();
-        // if (this.searchQuery)
-        //     this.$globals.showsearchbox = false;
-      }, 1000)
-    },
-    newSearchQuery (val) {
-      this.searchQuery = val
-    }
-  },
-  computed: {},
-  methods: {
-    onSearch () {
-      //this.$emit('clickSearch', this.searchQuery)
-      console.log(this.searchQuery);
-      router.push({ path: '/search', query: {searchQuery: this.searchQuery}});
-    },
-  }
-})
-/* <style>
-.searchbar {
-  padding: 1rem 1.5rem!important;
-  width: 100%;
-  box-shadow: 0 0 70px 0 rgba(0, 0, 0, 0.3);
-  background: #fff;
-}
-</style> */
\ No newline at end of file
diff --git a/web/components/volumecontrol.vue.js b/web/components/volumecontrol.vue.js
deleted file mode 100644 (file)
index 7ef20ab..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-Vue.component("volumecontrol", {\r
-       template: `\r
-       <v-card>\r
-                               <v-list>\r
-                                       <v-list-tile avatar>\r
-                                               <v-list-tile-avatar>\r
-                                                               <v-icon large>{{ isGroup ? 'speaker_group' : 'speaker' }}</v-icon>\r
-                                               </v-list-tile-avatar>\r
-                                               <v-list-tile-content>\r
-                                                       <v-list-tile-title>{{ players[player_id].name }}</v-list-tile-title>\r
-                                                       <v-list-tile-sub-title>{{ $t('state.' + players[player_id].state) }}</v-list-tile-sub-title>\r
-                                               </v-list-tile-content>\r
-                                               </v-list-tile-action>\r
-                                       </v-list-tile>\r
-                               </v-list>\r
-\r
-                               <v-divider></v-divider>\r
-\r
-                               <v-list two-line>\r
-\r
-                                       <div v-for="child_id in volumePlayerIds" :key="child_id">\r
-                                                       <v-list-tile>\r
-                                                       \r
-                                                       <v-list-tile-content>\r
-\r
-                                                               <v-list-tile-title>\r
-                                                               </v-list-tile-title>\r
-                                                               <div class="v-list__tile__sub-title" style="position: absolute; left:47px; top:10px; z-index:99;">\r
-                                                                       <span :style="!players[child_id].powered ? 'color:rgba(0,0,0,.38);' : 'color:rgba(0,0,0,.54);'">{{ players[child_id].name }}</span>\r
-                                                               </div>\r
-                                                               <div class="v-list__tile__sub-title" style="position: absolute; left:0px; top:-4px; z-index:99;">\r
-                                                                       <v-btn icon @click="$emit('togglePlayerPower', child_id)">\r
-                                                                               <v-icon :style="!players[child_id].powered ? 'color:rgba(0,0,0,.38);' : 'color:rgba(0,0,0,.54);'">power_settings_new</v-icon>\r
-                                                                       </v-btn>\r
-                                                               </div>\r
-                                                               <v-list-tile-sub-title>\r
-                                                                       <v-slider lazy :disabled="!players[child_id].powered" v-if="!players[child_id].disable_volume"\r
-                                                                               :value="Math.round(players[child_id].volume_level)"\r
-                                                                               prepend-icon="volume_down"\r
-                                                                               append-icon="volume_up"\r
-                                                                               @end="$emit('setPlayerVolume', child_id, $event)"\r
-                                                                               @click:append="$emit('setPlayerVolume', child_id, 'up')"\r
-                                                                       @click:prepend="$emit('setPlayerVolume', child_id, 'down')"\r
-                                                                       ></v-slider>\r
-                                                               </v-list-tile-sub-title>\r
-                                                       </v-list-tile-content>\r
-                                               </v-list-tile>\r
-                                               <v-divider></v-divider>\r
-                                       </div>\r
-                                       \r
-                               </v-list>\r
-\r
-                               <v-spacer></v-spacer>\r
-                       </v-card>\r
-`,\r
-       props: ['value', 'players', 'player_id'],\r
-       data (){\r
-               return{\r
-                       }\r
-       },\r
-       computed: {\r
-                       volumePlayerIds() {\r
-                       var volume_ids = [this.player_id];\r
-                       for (var player_id in this.players)\r
-                               if (this.players[player_id].group_parent == this.player_id && this.players[player_id].enabled)\r
-                                       volume_ids.push(player_id);\r
-                       return volume_ids;\r
-               },\r
-               isGroup() {\r
-                       return this.volumePlayerIds.length > 1;\r
-               }\r
-  },\r
-       mounted() { },\r
-       created() { },\r
-       methods: {}\r
-  })\r
diff --git a/web/css/nprogress.css b/web/css/nprogress.css
deleted file mode 100644 (file)
index e4cb811..0000000
+++ /dev/null
@@ -1,74 +0,0 @@
-/* Make clicks pass-through */
-#nprogress {
-    pointer-events: none;
-  }
-  
-  #nprogress .bar {
-    background: rgb(119, 205, 255);
-  
-    position: fixed;
-    z-index: 1031;
-    top: 0;
-    left: 0;
-  
-    width: 100%;
-    height: 10px;
-  }
-  
-  /* Fancy blur effect */
-  #nprogress .peg {
-    display: block;
-    position: absolute;
-    right: 0px;
-    width: 100px;
-    height: 100%;
-    box-shadow: 0 0 10px #29d, 0 0 5px #29d;
-    opacity: 1.0;
-  
-    -webkit-transform: rotate(3deg) translate(0px, -4px);
-        -ms-transform: rotate(3deg) translate(0px, -4px);
-            transform: rotate(3deg) translate(0px, -4px);
-  }
-  
-  /* Remove these to get rid of the spinner */
-  #nprogress .spinner {
-    display: block;
-    position: fixed;
-    z-index: 1031;
-    top: 15px;
-    right: 15px;
-  }
-  
-  #nprogress .spinner-icon {
-    width: 18px;
-    height: 18px;
-    box-sizing: border-box;
-  
-    border: solid 2px transparent;
-    border-top-color: #29d;
-    border-left-color: #29d;
-    border-radius: 50%;
-  
-    -webkit-animation: nprogress-spinner 400ms linear infinite;
-            animation: nprogress-spinner 400ms linear infinite;
-  }
-  
-  .nprogress-custom-parent {
-    overflow: hidden;
-    position: relative;
-  }
-  
-  .nprogress-custom-parent #nprogress .spinner,
-  .nprogress-custom-parent #nprogress .bar {
-    position: absolute;
-  }
-  
-  @-webkit-keyframes nprogress-spinner {
-    0%   { -webkit-transform: rotate(0deg); }
-    100% { -webkit-transform: rotate(360deg); }
-  }
-  @keyframes nprogress-spinner {
-    0%   { transform: rotate(0deg); }
-    100% { transform: rotate(360deg); }
-  }
-  
\ No newline at end of file
diff --git a/web/css/site.css b/web/css/site.css
deleted file mode 100755 (executable)
index 2071f04..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-[v-cloak] {
-    display: none;
-}
-
-.navbar {
-    margin-bottom: 20px;
-}
-
-/*.body-content {
-    padding-left: 25px;
-    padding-right: 25px;
-}*/
-
-input,
-select {
-    max-width: 30em;
-}
-
-.fade-enter-active,
-.fade-leave-active {
-    transition: opacity .5s;
-}
-
-.fade-enter,
-.fade-leave-to
-/* .fade-leave-active below version 2.1.8 */
-
-    {
-    opacity: 0;
-}
-
-.bounce-enter-active {
-    animation: bounce-in .5s;
-}
-
-.bounce-leave-active {
-    animation: bounce-in .5s reverse;
-}
-
-@keyframes bounce-in {
-    0% {
-        transform: scale(0);
-    }
-    50% {
-        transform: scale(1.5);
-    }
-    100% {
-        transform: scale(1);
-    }
-}
-
-.slide-fade-enter-active {
-    transition: all .3s ease;
-}
-
-.slide-fade-leave-active {
-    transition: all .8s cubic-bezier(1.0, 0.5, 0.8, 1.0);
-}
-
-.slide-fade-enter,
-.slide-fade-leave-to
-/* .slide-fade-leave-active below version 2.1.8 */
-
-    {
-    transform: translateX(10px);
-    opacity: 0;
-}
-
-.vertical-btn {
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-  }
\ No newline at end of file
diff --git a/web/css/vue-loading.css b/web/css/vue-loading.css
deleted file mode 100644 (file)
index 6d62f80..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-.vld-overlay {
-  bottom: 0;
-  left: 0;
-  position: absolute;
-  right: 0;
-  top: 0;
-  align-items: center;
-  display: none;
-  justify-content: center;
-  overflow: hidden;
-  z-index: 1
-}
-
-.vld-overlay.is-active {
-  display: flex
-}
-
-.vld-overlay.is-full-page {
-  z-index: 999;
-  position: fixed
-}
-
-.vld-overlay .vld-background {
-  bottom: 0;
-  left: 0;
-  position: absolute;
-  right: 0;
-  top: 0;
-  background: #000;
-  opacity: 0.7
-}
-
-.vld-overlay .vld-icon, .vld-parent {
-  position: relative
-}
-
diff --git a/web/images/default_artist.png b/web/images/default_artist.png
deleted file mode 100644 (file)
index a530d5b..0000000
Binary files a/web/images/default_artist.png and /dev/null differ
diff --git a/web/images/icons/aac.png b/web/images/icons/aac.png
deleted file mode 100644 (file)
index 7dafab2..0000000
Binary files a/web/images/icons/aac.png and /dev/null differ
diff --git a/web/images/icons/chromecast.png b/web/images/icons/chromecast.png
deleted file mode 100644 (file)
index f7d2a46..0000000
Binary files a/web/images/icons/chromecast.png and /dev/null differ
diff --git a/web/images/icons/file.png b/web/images/icons/file.png
deleted file mode 100644 (file)
index bd2df04..0000000
Binary files a/web/images/icons/file.png and /dev/null differ
diff --git a/web/images/icons/flac.png b/web/images/icons/flac.png
deleted file mode 100644 (file)
index 33e1f17..0000000
Binary files a/web/images/icons/flac.png and /dev/null differ
diff --git a/web/images/icons/hires.png b/web/images/icons/hires.png
deleted file mode 100644 (file)
index a398c6e..0000000
Binary files a/web/images/icons/hires.png and /dev/null differ
diff --git a/web/images/icons/homeassistant.png b/web/images/icons/homeassistant.png
deleted file mode 100644 (file)
index 5f28d69..0000000
Binary files a/web/images/icons/homeassistant.png and /dev/null differ
diff --git a/web/images/icons/http_streamer.png b/web/images/icons/http_streamer.png
deleted file mode 100644 (file)
index c35c983..0000000
Binary files a/web/images/icons/http_streamer.png and /dev/null differ
diff --git a/web/images/icons/icon-128x128.png b/web/images/icons/icon-128x128.png
deleted file mode 100644 (file)
index 01363c8..0000000
Binary files a/web/images/icons/icon-128x128.png and /dev/null differ
diff --git a/web/images/icons/icon-256x256.png b/web/images/icons/icon-256x256.png
deleted file mode 100644 (file)
index 4c36796..0000000
Binary files a/web/images/icons/icon-256x256.png and /dev/null differ
diff --git a/web/images/icons/icon-apple.png b/web/images/icons/icon-apple.png
deleted file mode 100644 (file)
index 67d26d5..0000000
Binary files a/web/images/icons/icon-apple.png and /dev/null differ
diff --git a/web/images/icons/info_gradient.jpg b/web/images/icons/info_gradient.jpg
deleted file mode 100644 (file)
index 9d0c0e3..0000000
Binary files a/web/images/icons/info_gradient.jpg and /dev/null differ
diff --git a/web/images/icons/lms.png b/web/images/icons/lms.png
deleted file mode 100644 (file)
index 6dd9b06..0000000
Binary files a/web/images/icons/lms.png and /dev/null differ
diff --git a/web/images/icons/mp3.png b/web/images/icons/mp3.png
deleted file mode 100644 (file)
index b894bda..0000000
Binary files a/web/images/icons/mp3.png and /dev/null differ
diff --git a/web/images/icons/qobuz.png b/web/images/icons/qobuz.png
deleted file mode 100644 (file)
index 9d7b726..0000000
Binary files a/web/images/icons/qobuz.png and /dev/null differ
diff --git a/web/images/icons/spotify.png b/web/images/icons/spotify.png
deleted file mode 100644 (file)
index 805f5c7..0000000
Binary files a/web/images/icons/spotify.png and /dev/null differ
diff --git a/web/images/icons/squeezebox.png b/web/images/icons/squeezebox.png
deleted file mode 100644 (file)
index 18531d7..0000000
Binary files a/web/images/icons/squeezebox.png and /dev/null differ
diff --git a/web/images/icons/tunein.png b/web/images/icons/tunein.png
deleted file mode 100644 (file)
index 3352c29..0000000
Binary files a/web/images/icons/tunein.png and /dev/null differ
diff --git a/web/images/icons/vorbis.png b/web/images/icons/vorbis.png
deleted file mode 100644 (file)
index c6d6914..0000000
Binary files a/web/images/icons/vorbis.png and /dev/null differ
diff --git a/web/images/icons/web.png b/web/images/icons/web.png
deleted file mode 100644 (file)
index d3b5724..0000000
Binary files a/web/images/icons/web.png and /dev/null differ
diff --git a/web/images/info_gradient.jpg b/web/images/info_gradient.jpg
deleted file mode 100644 (file)
index 9d0c0e3..0000000
Binary files a/web/images/info_gradient.jpg and /dev/null differ
diff --git a/web/index.html b/web/index.html
deleted file mode 100755 (executable)
index dcef414..0000000
+++ /dev/null
@@ -1,249 +0,0 @@
-<!DOCTYPE html>
-<html>
-
-    <head>
-        <meta charset="utf-8" />
-        <title>Music Assistant</title>
-        <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet">
-        <link href="https://cdn.jsdelivr.net/npm/vuetify@1.5.16/dist/vuetify.min.css" rel="stylesheet">
-        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
-        <link rel="icon" href="./images/icons/icon-256x256.png">
-        <link rel="manifest" href="./manifest.json">
-        <link rel="apple-touch-icon" href="./images/icons/icon-apple.png">
-        <meta name="apple-mobile-web-app-capable" content="yes">
-        <link href="./css/site.css" rel="stylesheet">
-        <link href="./css/vue-loading.css" rel="stylesheet">
-    </head>
-
-    <body>
-
-        <div id="app">
-            <v-app light>
-                <v-content>
-                    <headermenu></headermenu>
-                    <player></player>
-                    <router-view app :key="$route.path"></router-view>      
-                    <searchbox/>             
-                </v-content>
-                <loading :active.sync="$globals.loading" :can-cancel="true" color="#2196f3" loader="dots"></loading>
-            </v-app>
-        </div>
-
-
-        <script src="https://unpkg.com/vue/dist/vue.js"></script>
-        <script src="https://unpkg.com/vue-i18n/dist/vue-i18n.js"></script>
-        <script src="https://cdn.jsdelivr.net/npm/vuetify@1.5.16/dist/vuetify.min.js"></script>
-        <script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
-        <script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js"></script>
-        <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
-        <script src="https://cdn.jsdelivr.net/npm/moment@2.20.1/moment.min.js"></script>
-        <script src="https://unpkg.com/vee-validate@2.0.0-rc.25/dist/vee-validate.js"></script>
-        <script src="./lib/vue-loading-overlay.js"></script>
-        <script src="https://unpkg.com/vue-toasted"></script>
-
-
-        <script>
-            const isMobile = () => (document.body.clientWidth < 800);
-            const isInStandaloneMode = () => ('standalone' in window.navigator) && (window.navigator.standalone);
-
-            function showPlayMenu (item) {
-                this.$globals.playmenuitem = item;
-                this.$globals.showplaymenu = !this.$globals.showplaymenu;
-                }
-
-            function clickItem (item) {
-                var endpoint = "";
-                if (item.media_type == 1)
-                    endpoint = "/artists/"
-                else if (item.media_type == 2)
-                    endpoint = "/albums/"
-                else if (item.media_type == 3 || item.media_type == 5)
-                    {
-                    this.showPlayMenu(item);
-                    return;
-                    }
-                else if (item.media_type == 4)
-                    endpoint = "/playlists/"
-                item_id = item.item_id.toString();
-                var url = endpoint + item_id;
-                router.push({ path: url, query: {provider: item.provider}});
-            }
-
-            String.prototype.formatDuration = function () {
-                var sec_num = parseInt(this, 10); // don't forget the second param
-                var hours   = Math.floor(sec_num / 3600);
-                var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
-                var seconds = sec_num - (hours * 3600) - (minutes * 60);
-
-                if (hours   < 10) {hours   = "0"+hours;}
-                if (minutes < 10) {minutes = "0"+minutes;}
-                if (seconds < 10) {seconds = "0"+seconds;}
-                if (hours == '00')
-                    return minutes+':'+seconds;
-                else
-                    return hours+':'+minutes+':'+seconds;
-            }
-            function toggleLibrary (item) {
-                var endpoint = "/api/" + item.media_type + "/";
-                item_id = item.item_id.toString();
-                var action = "/library_remove"
-                if (item.in_library.length == 0)
-                    action = "/library_add"
-                var url = endpoint + item_id + action;
-                console.log('loading ' + url);
-                axios
-                    .get(url, { params: { provider: item.provider }})
-                    .then(result => {
-                        data = result.data;
-                        console.log(data);
-                        if (action == "/library_remove")
-                            item.in_library = []
-                        else
-                        item.in_library = [provider]
-                        })
-                    .catch(error => {
-                        console.log("error", error);
-                    });
-
-            };
-        </script>
-
-        <!-- Vue Pages and Components here -->
-        <script src='./pages/home.vue.js'></script>
-        <script src='./pages/browse.vue.js'></script>
-
-        <script src='./pages/artistdetails.vue.js'></script>
-        <script src='./pages/albumdetails.vue.js'></script>
-        <script src='./pages/trackdetails.vue.js'></script>
-        <script src='./pages/playlistdetails.vue.js'></script>
-        <script src='./pages/search.vue.js'></script>
-        <script src='./pages/queue.vue.js'></script>
-        <script src='./pages/config.vue.js'></script>
-        
-
-        <script src='./components/headermenu.vue.js'></script>
-        <script src='./components/player.vue.js'></script>
-        <script src='./components/listviewItem.vue.js'></script>
-        <script src='./components/readmore.vue.js'></script>
-        <script src='./components/playmenu.vue.js'></script>
-        <script src='./components/volumecontrol.vue.js'></script>
-        <script src='./components/infoheader.vue.js'></script>
-        <script src='./components/providericons.vue.js'></script>
-        <script src='./components/searchbox.vue.js'></script>
-        
-        <script src='./strings.js'></script>
-        
-        <script>
-        Vue.use(VueRouter);
-        Vue.use(VeeValidate);
-        Vue.use(Vuetify);
-        Vue.use(VueI18n);
-        Vue.use(VueLoading);
-        Vue.use(Toasted, {duration: 5000, fullWidth: true});
-
-
-        const routes = [
-            {
-            path: '/',
-            component: home
-            },
-            {
-                path: '/config',
-                component: Config,
-            },
-            {
-                path: '/queue/:player_id',
-                component: Queue,
-                props: route => ({ ...route.params, ...route.query })
-            },
-            {
-                path: '/artists/:media_id',
-                component: ArtistDetails,
-                props: route => ({ ...route.params, ...route.query })
-            },
-            {
-                path: '/albums/:media_id',
-                component: AlbumDetails,
-                props: route => ({ ...route.params, ...route.query })
-            },
-            {
-                path: '/tracks/:media_id',
-                component: TrackDetails,
-                props: route => ({ ...route.params, ...route.query })
-            },
-            {
-                path: '/playlists/:media_id',
-                component: PlaylistDetails,
-                props: route => ({ ...route.params, ...route.query })
-            },
-            {
-                path: '/search',
-                component: Search,
-                props: route => ({ ...route.params, ...route.query })
-            },
-            {
-                path: '/:mediatype',
-                component: Browse,
-                props: route => ({ ...route.params, ...route.query })
-            },
-        ]
-
-        let router = new VueRouter({
-            //mode: 'history',
-            routes // short for `routes: routes`
-        })
-
-        router.beforeEach((to, from, next) => {
-            next()
-        })
-
-        const globalStore = new Vue({
-            data: {
-                windowtitle: 'Home',
-                loading: false,
-                showplaymenu: false,
-                showsearchbox: false,
-                playmenuitem: null
-            }
-        })
-        Vue.prototype.$globals = globalStore;
-        Vue.prototype.isMobile = isMobile;
-        Vue.prototype.isInStandaloneMode = isInStandaloneMode;
-        Vue.prototype.toggleLibrary = toggleLibrary;
-        Vue.prototype.showPlayMenu = showPlayMenu;
-        Vue.prototype.clickItem= clickItem;
-        
-        const i18n = new VueI18n({
-            locale: navigator.language.split('-')[0],
-            fallbackLocale: 'en',
-            enableInSFC: true,
-            messages
-            })
-
-        var app = new Vue({
-            i18n,
-            el: '#app',
-            watch: {},
-            mounted() {
-            },
-            components: {
-                Loading: VueLoading
-            },
-            created() {
-                // little hack to force refresh PWA on iOS by simple reloading it every hour
-                var d = new Date();
-                var cur_update = d.getDay() + d.getHours();
-                if (localStorage.getItem('last_update') != cur_update)
-                {
-                    localStorage.setItem('last_update', cur_update);
-                    window.location.reload(true);
-                }
-            },
-            data: { },
-            methods: {},
-            router
-        })
-    </script>
-    </body>
-
-</html>
\ No newline at end of file
diff --git a/web/lib/vue-loading-overlay.js b/web/lib/vue-loading-overlay.js
deleted file mode 100644 (file)
index b3b9da1..0000000
+++ /dev/null
@@ -1 +0,0 @@
-!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define("VueLoading",[],e):"object"==typeof exports?exports.VueLoading=e():t.VueLoading=e()}("undefined"!=typeof self?self:this,function(){return function(t){var e={};function i(n){if(e[n])return e[n].exports;var r=e[n]={i:n,l:!1,exports:{}};return t[n].call(r.exports,r,r.exports,i),r.l=!0,r.exports}return i.m=t,i.c=e,i.d=function(t,e,n){i.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},i.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},i.t=function(t,e){if(1&e&&(t=i(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)i.d(n,r,function(e){return t[e]}.bind(null,r));return n},i.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(e,"a",e),e},i.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},i.p="",i(i.s=1)}([function(t,e,i){},function(t,e,i){"use strict";i.r(e);var n="undefined"!=typeof window?window.HTMLElement:Object,r={mounted:function(){document.addEventListener("focusin",this.focusIn)},methods:{focusIn:function(t){if(this.isActive&&t.target!==this.$el&&!this.$el.contains(t.target)){var e=this.container?this.container:this.isFullPage?null:this.$el.parentElement;(this.isFullPage||e&&e.contains(t.target))&&(t.preventDefault(),this.$el.focus())}}},beforeDestroy:function(){document.removeEventListener("focusin",this.focusIn)}};function a(t,e,i,n,r,a,o,s){var u,l="function"==typeof t?t.options:t;if(e&&(l.render=e,l.staticRenderFns=i,l._compiled=!0),n&&(l.functional=!0),a&&(l._scopeId="data-v-"+a),o?(u=function(t){(t=t||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext)||"undefined"==typeof __VUE_SSR_CONTEXT__||(t=__VUE_SSR_CONTEXT__),r&&r.call(this,t),t&&t._registeredComponents&&t._registeredComponents.add(o)},l._ssrRegister=u):r&&(u=s?function(){r.call(this,this.$root.$options.shadowRoot)}:r),u)if(l.functional){l._injectStyles=u;var c=l.render;l.render=function(t,e){return u.call(e),c(t,e)}}else{var d=l.beforeCreate;l.beforeCreate=d?[].concat(d,u):[u]}return{exports:t,options:l}}var o=a({name:"spinner",props:{color:{type:String,default:"#000"},height:{type:Number,default:64},width:{type:Number,default:64}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{viewBox:"0 0 38 38",xmlns:"http://www.w3.org/2000/svg",width:this.width,height:this.height,stroke:this.color}},[e("g",{attrs:{fill:"none","fill-rule":"evenodd"}},[e("g",{attrs:{transform:"translate(1 1)","stroke-width":"2"}},[e("circle",{attrs:{"stroke-opacity":".25",cx:"18",cy:"18",r:"18"}}),e("path",{attrs:{d:"M36 18c0-9.94-8.06-18-18-18"}},[e("animateTransform",{attrs:{attributeName:"transform",type:"rotate",from:"0 18 18",to:"360 18 18",dur:"0.8s",repeatCount:"indefinite"}})],1)])])])},[],!1,null,null,null).exports,s=a({name:"dots",props:{color:{type:String,default:"#000"},height:{type:Number,default:240},width:{type:Number,default:60}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{viewBox:"0 0 120 30",xmlns:"http://www.w3.org/2000/svg",fill:this.color,width:this.width,height:this.height}},[e("circle",{attrs:{cx:"15",cy:"15",r:"15"}},[e("animate",{attrs:{attributeName:"r",from:"15",to:"15",begin:"0s",dur:"0.8s",values:"15;9;15",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"1",to:"1",begin:"0s",dur:"0.8s",values:"1;.5;1",calcMode:"linear",repeatCount:"indefinite"}})]),e("circle",{attrs:{cx:"60",cy:"15",r:"9","fill-opacity":"0.3"}},[e("animate",{attrs:{attributeName:"r",from:"9",to:"9",begin:"0s",dur:"0.8s",values:"9;15;9",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"0.5",to:"0.5",begin:"0s",dur:"0.8s",values:".5;1;.5",calcMode:"linear",repeatCount:"indefinite"}})]),e("circle",{attrs:{cx:"105",cy:"15",r:"15"}},[e("animate",{attrs:{attributeName:"r",from:"15",to:"15",begin:"0s",dur:"0.8s",values:"15;9;15",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"1",to:"1",begin:"0s",dur:"0.8s",values:"1;.5;1",calcMode:"linear",repeatCount:"indefinite"}})])])},[],!1,null,null,null).exports,u=a({name:"bars",props:{color:{type:String,default:"#000"},height:{type:Number,default:40},width:{type:Number,default:40}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 30 30",height:this.height,width:this.width,fill:this.color}},[e("rect",{attrs:{x:"0",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0s",dur:"0.6s",repeatCount:"indefinite"}})]),e("rect",{attrs:{x:"10",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0.15s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0.15s",dur:"0.6s",repeatCount:"indefinite"}})]),e("rect",{attrs:{x:"20",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0.3s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0.3s",dur:"0.6s",repeatCount:"indefinite"}})])])},[],!1,null,null,null).exports,l=a({name:"vue-loading",mixins:[r],props:{active:Boolean,programmatic:Boolean,container:[Object,Function,n],isFullPage:{type:Boolean,default:!0},transition:{type:String,default:"fade"},canCancel:Boolean,onCancel:{type:Function,default:function(){}},color:String,backgroundColor:String,opacity:Number,width:Number,height:Number,zIndex:Number,loader:{type:String,default:"spinner"}},data:function(){return{isActive:this.active}},components:{Spinner:o,Dots:s,Bars:u},beforeMount:function(){this.programmatic&&(this.container?(this.isFullPage=!1,this.container.appendChild(this.$el)):document.body.appendChild(this.$el))},mounted:function(){this.programmatic&&(this.isActive=!0),document.addEventListener("keyup",this.keyPress)},methods:{cancel:function(){this.canCancel&&this.isActive&&(this.hide(),this.onCancel.apply(null,arguments))},hide:function(){var t=this;this.$emit("hide"),this.$emit("update:active",!1),this.programmatic&&(this.isActive=!1,setTimeout(function(){var e;t.$destroy(),void 0!==(e=t.$el).remove?e.remove():e.parentNode.removeChild(e)},150))},keyPress:function(t){27===t.keyCode&&this.cancel()}},watch:{active:function(t){this.isActive=t}},beforeDestroy:function(){document.removeEventListener("keyup",this.keyPress)}},function(){var t=this,e=t.$createElement,i=t._self._c||e;return i("transition",{attrs:{name:t.transition}},[i("div",{directives:[{name:"show",rawName:"v-show",value:t.isActive,expression:"isActive"}],staticClass:"vld-overlay is-active",class:{"is-full-page":t.isFullPage},style:{zIndex:this.zIndex},attrs:{tabindex:"0","aria-busy":t.isActive,"aria-label":"Loading"}},[i("div",{staticClass:"vld-background",style:{background:this.backgroundColor,opacity:this.opacity},on:{click:function(e){return e.preventDefault(),t.cancel(e)}}}),i("div",{staticClass:"vld-icon"},[t._t("before"),t._t("default",[i(t.loader,{tag:"component",attrs:{color:t.color,width:t.width,height:t.height}})]),t._t("after")],2)])])},[],!1,null,null,null).exports,c=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return{show:function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:e,r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:i,a=Object.assign({},e,n,{programmatic:!0}),o=new(t.extend(l))({el:document.createElement("div"),propsData:a}),s=Object.assign({},i,r);return Object.keys(s).map(function(t){o.$slots[t]=s[t]}),o}}};i(0);l.install=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},n=c(t,e,i);t.$loading=n,t.prototype.$loading=n};e.default=l}]).default});
\ No newline at end of file
diff --git a/web/manifest.json b/web/manifest.json
deleted file mode 100755 (executable)
index 6a3c4b9..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-{
-  "name": "Music Assistant",
-  "short_name": "MusicAssistant",
-  "theme_color": "#2196f3",
-  "background_color": "#2196f3",
-  "display": "standalone",
-  "Scope": "/",
-  "start_url": "/",
-  "icons": [
-    {
-      "src": "images/icons/icon-128x128.png",
-      "sizes": "128x128",
-      "type": "image/png"
-    },
-    {
-      "src": "images/icons/icon-256x256.png",
-      "sizes": "512x512",
-      "type": "image/png"
-    }
-  ],
-  "splash_pages": null
-}
\ No newline at end of file
diff --git a/web/pages/albumdetails.vue.js b/web/pages/albumdetails.vue.js
deleted file mode 100755 (executable)
index 4f60a91..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-var AlbumDetails = Vue.component('AlbumDetails', {
-  template: `
-  <section>
-      <infoheader v-bind:info="info"/>
-      <v-tabs
-          v-model="active"
-          color="transparent"
-          light
-          slider-color="black"
-        >
-          <v-tab ripple>Album tracks</v-tab>
-          <v-tab-item>
-            <v-card flat>
-            <v-list two-line>
-                <listviewItem 
-                    v-for="(item, index) in albumtracks" 
-                    v-bind:item="item"
-                    :key="item.db_id"
-                    v-bind:totalitems="albumtracks.length"
-                    v-bind:index="index"
-                    :hideavatar="true"
-                    :hideproviders="isMobile()"
-                    >
-                </listviewItem>
-              </v-list>
-            </v-card>
-          </v-tab-item>
-
-          <v-tab ripple>Versions</v-tab>
-          <v-tab-item>
-            <v-card flat>
-                <v-list two-line>
-                  <listviewItem 
-                      v-for="(item, index) in albumversions" 
-                      v-bind:item="item"
-                      :key="item.db_id"
-                      v-bind:totalitems="albumversions.length"
-                      v-bind:index="index"
-                      >
-                  </listviewItem>
-              </v-list>
-            </v-card>
-          </v-tab-item>
-        </v-tabs>
-
-      </section>`,
-  props: ['provider', 'media_id'],
-  data() {
-    return {
-      selected: [2],
-      info: {},
-      albumtracks: [],
-      albumversions: [],
-      offset: 0,
-      active: null,
-    }
-  },
-  created() {
-    this.$globals.windowtitle = ""
-    this.getInfo();
-    this.getAlbumTracks();
-  },
-  methods: {
-    getInfo () {
-      this.$globals.loading = true;
-      const api_url = '/api/albums/' + this.media_id
-      axios
-        .get(api_url, { params: { provider: this.provider }})
-        .then(result => {
-          data = result.data;
-          this.info = data;
-          this.getAlbumVersions()
-          this.$globals.loading = false;
-        })
-        .catch(error => {
-          console.log("error", error);
-        });
-    },
-    getAlbumTracks () {
-      const api_url = '/api/albums/' + this.media_id + '/tracks'
-      axios
-        .get(api_url, { params: { offset: this.offset, limit: 50, provider: this.provider}})
-        .then(result => {
-          data = result.data;
-          this.albumtracks.push(...data);
-          this.offset += 50;
-        })
-        .catch(error => {
-          console.log("error", error);
-        });
-    },
-    getAlbumVersions () {
-      const api_url = '/api/search';
-      var searchstr = this.info.artist.name + " - " + this.info.name
-      axios
-        .get(api_url, { params: { query: searchstr, limit: 50, media_types: 'albums', online: true}})
-        .then(result => {
-          data = result.data;
-          this.albumversions.push(...data.albums);
-          this.offset += 50;
-        })
-        .catch(error => {
-          console.log("error", error);
-        });
-    },
-  }
-})
diff --git a/web/pages/artistdetails.vue.js b/web/pages/artistdetails.vue.js
deleted file mode 100755 (executable)
index 4660030..0000000
+++ /dev/null
@@ -1,127 +0,0 @@
-var ArtistDetails = Vue.component('ArtistDetails', {
-  template: `
-  <section>
-      <infoheader v-bind:info="info"/>
-      <v-tabs
-          v-model="active"
-          color="transparent"
-          light
-          slider-color="black"
-        >
-          <v-tab ripple>Top tracks</v-tab>
-          <v-tab-item>
-            <v-card flat>
-            <v-list two-line>
-                  <listviewItem 
-                      v-for="(item, index) in toptracks" 
-                      v-bind:item="item"
-                      v-bind:totalitems="toptracks.length"
-                      v-bind:index="index"
-                      :key="item.db_id"
-                      :hideavatar="isMobile()"
-                      :hidetracknum="true"
-                      :hideproviders="isMobile()"
-                      :hidelibrary="isMobile()">
-                  </listviewItem>
-                </v-list>
-            </v-card>
-          </v-tab-item>
-
-          <v-tab ripple>Albums</v-tab>
-          <v-tab-item>
-            <v-card flat>
-                <v-list two-line>
-                    <listviewItem 
-                        v-for="(item, index) in artistalbums" 
-                        v-bind:item="item"
-                        :key="item.db_id"
-                        v-bind:totalitems="artistalbums.length"
-                        v-bind:index="index"
-                        :hideproviders="isMobile()"
-                        >
-                    </listviewItem>
-              </v-list>
-            </v-card>
-          </v-tab-item>
-        </v-tabs>
-      </section>`,
-  props: ['media_id', 'provider'],
-  data() {
-    return {
-      selected: [2],
-      info: {},
-      toptracks: [],
-      artistalbums: [],
-      bg_image: "../images/info_gradient.jpg",
-      active: null,
-      playmenu: false,
-      playmenuitem: null
-    }
-  },
-  created() {
-    this.$globals.windowtitle = ""
-    this.getInfo();
-  },
-  methods: {
-    getFanartImage() {
-      if (this.info.metadata && this.info.metadata.fanart)
-        return this.info.metadata.fanart;
-      else if (this.info.artists)
-        for (artist in this.info.artists)
-          if (artist.info.metadata && artist.data.metadata.fanart)
-              return artist.metadata.fanart;
-    },
-    getInfo (lazy=true) {
-      this.$globals.loading = true;
-      const api_url = '/api/artists/' + this.media_id;
-      console.log(api_url + ' - ' + this.provider);
-      axios
-        .get(api_url, { params: { lazy: lazy, provider: this.provider }})
-        .then(result => {
-          data = result.data;
-          this.info = data;
-          this.$globals.loading = false;
-          if (data.is_lazy == true)
-              // refresh the info if we got a lazy object
-              this.timeout1 = setTimeout(function(){
-                  this.getInfo(false);
-              }.bind(this), 1000);
-          else {
-            this.getArtistTopTracks();
-            this.getArtistAlbums();
-          }
-        })
-        .catch(error => {
-          console.log("error", error);
-          this.$globals.loading = false;
-        });
-    },
-    getArtistTopTracks () {
-      
-      const api_url = '/api/artists/' + this.media_id + '/toptracks'
-      axios
-      .get(api_url, { params: { provider: this.provider }})
-        .then(result => {
-          data = result.data;
-          this.toptracks = data;
-        })
-        .catch(error => {
-          console.log("error", error);
-        });
-        
-    },
-    getArtistAlbums () {
-      const api_url = '/api/artists/' + this.media_id + '/albums'
-      console.log('loading ' + api_url);
-      axios
-      .get(api_url, { params: { provider: this.provider }})
-        .then(result => {
-          data = result.data;
-          this.artistalbums = data;
-        })
-        .catch(error => {
-          console.log("error", error);
-        });
-    },
-  }
-})
diff --git a/web/pages/browse.vue.js b/web/pages/browse.vue.js
deleted file mode 100755 (executable)
index 49bcdc8..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-var Browse = Vue.component('Browse', {
-  template: `
-    <section>
-      <v-list two-line>
-        <listviewItem 
-            v-for="(item, index) in items"
-            :key="item.db_id"
-            v-bind:item="item"
-            v-bind:totalitems="items.length"
-            v-bind:index="index"
-            :hideavatar="item.media_type == 3 ? isMobile() : false"
-            :hidetracknum="true"
-            :hideproviders="isMobile()"
-            :hidelibrary="isMobile() ? true : item.media_type != 3">
-        </listviewItem>
-      </v-list>
-    </section>
-  `,
-  props: ['mediatype', 'provider'],
-  data() {
-    return {
-      selected: [2],
-      items: [],
-      offset: 0
-    }
-  },
-  created() {
-    this.showavatar = true;
-    mediatitle = 
-    this.$globals.windowtitle = this.$t(this.mediatype)
-    this.scroll(this.Browse);
-    this.getItems();
-  },
-  methods: {
-    getItems () {
-      this.$globals.loading = true
-      const api_url = '/api/' + this.mediatype;
-      axios
-        .get(api_url, { params: { offset: this.offset, limit: 50, provider: this.provider }})
-        .then(result => {
-          data = result.data;
-          this.items.push(...data);
-          this.offset += 50;
-          this.$globals.loading = false;
-        })
-        .catch(error => {
-          console.log("error", error);
-          this.showProgress = false;
-        });
-    },
-    scroll (Browse) {
-      window.onscroll = () => {
-        let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight;
-
-        if (bottomOfWindow) {
-          this.getItems();
-        }
-      };
-    }
-  }
-})
diff --git a/web/pages/config.vue.js b/web/pages/config.vue.js
deleted file mode 100755 (executable)
index c4164db..0000000
+++ /dev/null
@@ -1,152 +0,0 @@
-var Config = Vue.component('Config', {
-  template: `
-    <section>
-
-        <v-tabs v-model="active" color="transparent" light slider-color="black">
-            <v-tab ripple v-for="(conf_value, conf_key) in conf" :key="conf_key">{{ $t('conf.'+conf_key) }}</v-tab>
-                  <v-tab-item v-for="(conf_value, conf_key) in conf" :key="conf_key">
-
-                      <!-- generic and module settings -->
-                      <v-list two-line v-if="conf_key != 'player_settings'">
-                          <v-list-group no-action v-for="(conf_subvalue, conf_subkey) in conf[conf_key]" :key="conf_key+conf_subkey">
-                            <template v-slot:activator>
-                                <v-list-tile>
-                                  <v-list-tile-avatar>
-                                      <img :src="'images/icons/' + conf_subkey + '.png'"/>
-                                  </v-list-tile-avatar>
-                                  <v-list-tile-content>
-                                      <v-list-tile-title>{{ $t('conf.'+conf_subkey) }}</v-list-tile-title>
-                                  </v-list-tile-content>
-                                </v-list-tile>
-                              </template>
-                              <div v-for="conf_item_key in conf[conf_key][conf_subkey].__desc__">
-                                    <v-list-tile>
-                                          <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])"></v-switch>
-                                          <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-text-field>
-                                          <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-select>
-                                          <v-text-field v-else v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box></v-text-field>
-                                    </v-list-tile>
-                                </div>
-                                <v-divider></v-divider>
-                            </v-list-group>
-                      </v-list two-line>
-
-                      <!-- player settings -->
-                      <v-list two-line v-if="conf_key == 'player_settings'">
-                          <v-list-group no-action v-for="(player, key) in players" v-if="key != '__desc__' && key in players" :key="key">
-                                <template v-slot:activator>
-                                    <v-list-tile>
-                                      <v-list-tile-avatar>
-                                          <img :src="'images/icons/' + players[key].player_provider + '.png'"/>
-                                      </v-list-tile-avatar>
-                                      <v-list-tile-content>
-                                        <v-list-tile-title class="title">{{ players[key].name }}</v-list-tile-title>
-                                        <v-list-tile-sub-title class="title">{{ key }}</v-list-tile-sub-title>
-                                      </v-list-tile-content>
-                                  </v-list-tile>
-                              </template>
-                              <div v-for="conf_item_key in conf.player_settings[key].__desc__" v-if="conf.player_settings[key].enabled">
-                                  <v-list-tile>
-                                        <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])"></v-switch>
-                                        <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-text-field>
-                                        <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" 
-                                          :items="playersLst"
-                                          item-text="name"
-                                          item-value="id" box>
-                                        </v-select>
-                                        <v-select v-else-if="conf_item_key[0] == 'max_sample_rate'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" :items="sample_rates" box></v-select>
-                                        <v-slider v-else-if="conf_item_key[0] == 'crossfade_duration'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" min=0 max=10 box thumb-label></v-slider>
-                                        <v-text-field v-else v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box></v-text-field>
-                                  </v-list-tile>
-                                  <v-list-tile v-if="!conf.player_settings[key].enabled">
-                                        <v-switch v-model="conf.player_settings[key].enabled" :label="$t('conf.'+'enabled')"></v-switch>
-                                  </v-list-tile>
-                              </div>
-                              <div v-if="!conf.player_settings[key].enabled">
-                                  <v-list-tile>
-                                      <v-switch v-model="conf.player_settings[key].enabled" :label="$t('conf.'+'enabled')"></v-switch>
-                                  </v-list-tile>
-                              </div>
-                                <v-divider></v-divider>
-                            </v-list-group>
-                      </v-list two-line>
-                  </v-tab-item>
-            </v-tab>
-        </v-tabs>
-
-
-    </section>
-  `,
-  props: [],
-  data() {
-    return {
-      conf: {},
-      players: {},
-      active: 0,
-      sample_rates: [44100, 48000, 88200, 96000, 192000, 384000]
-    }
-  },
-  computed: {
-    playersLst()
-    {
-      var playersLst = [];
-      playersLst.push({id: null, name: this.$t('conf.'+'not_grouped')})
-      for (player_id in this.players)
-        playersLst.push({id: player_id, name: this.conf.player_settings[player_id].name})
-      return playersLst;
-    }
-  },
-  watch: {
-    'conf': {
-        handler: _.debounce(function (val, oldVal) {
-          if (oldVal.base) {
-            console.log("save config needed!");
-            this.saveConfig();
-            this.$toasted.show(this.$t('conf.conf_saved'))
-          }
-        }, 5000),
-        deep: true
-    }
-  },
-  created() {
-    this.$globals.windowtitle = this.$t('settings');
-    this.getPlayers();
-    this.getConfig();
-    console.log(this.$globals.all_players);
-  },
-  methods: {
-    getConfig () {
-      axios
-        .get('/api/config')
-        .then(result => {
-          this.conf = result.data;
-        })
-        .catch(error => {
-          console.log("error", error);
-        });
-    },
-    saveConfig () {
-      axios
-        .post('/api/config', this.conf)
-        .then(result => {
-          console.log(result);
-        })
-        .catch(error => {
-          console.log("error", error);
-        });
-    },
-    getPlayers () {
-      const api_url = '/api/players';
-      axios
-        .get(api_url)
-        .then(result => {
-          for (var item of result.data)
-            this.$set(this.players, item.player_id, item)
-        })
-        .catch(error => {
-          console.log("error", error);
-          this.showProgress = false;
-        });
-    },
-  }
-})
diff --git a/web/pages/home.vue.js b/web/pages/home.vue.js
deleted file mode 100755 (executable)
index 91c0b33..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-var home = Vue.component("Home", {
-  template: `
-  <section>
-      <v-list>
-        <v-list-tile 
-          v-for="item in items" :key="item.title" @click="$router.push(item.path)">
-            <v-list-tile-action style="margin-left:15px">
-                <v-icon>{{ item.icon }}</v-icon>
-            </v-list-tile-action>
-            <v-list-tile-content>
-                <v-list-tile-title>{{ item.title }}</v-list-tile-title>
-            </v-list-tile-content>
-        </v-list-tile>
-      </v-list>
-  </section>
-`,
-  props: ["title"],
-  $_veeValidate: {
-    validator: "new"
-  },
-  data() {
-    return {
-      result: null,
-      showProgress: false
-    };
-  },
-  created() {
-    this.$globals.windowtitle = this.$t('musicassistant');
-    this.items= [
-        { title: this.$t('artists'), icon: "person", path: "/artists" },
-        { title: this.$t('albums'), icon: "album", path: "/albums" },
-        { title: this.$t('tracks'), icon: "audiotrack", path: "/tracks" },
-        { title: this.$t('playlists'), icon: "playlist_play", path: "/playlists" },
-        { title: this.$t('search'), icon: "search", path: "/search" }
-    ]
-  },
-  methods: {
-    click (item) {
-      console.log("selected: "+ item.path);
-      router.push({path: item.path})
-    }
-  }
-});
diff --git a/web/pages/playlistdetails.vue.js b/web/pages/playlistdetails.vue.js
deleted file mode 100755 (executable)
index b9c617d..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-var PlaylistDetails = Vue.component('PlaylistDetails', {
-  template: `
-  <section>
-      <infoheader v-bind:info="info"/>
-      <v-tabs
-          v-model="active"
-          color="transparent"
-          light
-          slider-color="black"
-        >
-          <v-tab ripple>Playlist tracks</v-tab>
-          <v-tab-item>
-            <v-card flat>
-            <v-list two-line>
-                  <listviewItem 
-                      v-for="(item, index) in items" 
-                      v-bind:item="item"
-                      :key="item.db_id"
-                      :hideavatar="isMobile()"
-                      :hidetracknum="true"
-                      :hideproviders="isMobile()"
-                      :hidelibrary="isMobile()">
-                  </listviewItem>
-                </v-list>
-            </v-card>
-          </v-tab-item>
-        </v-tabs>
-      </section>`,
-  props: ['provider', 'media_id'],
-  data() {
-    return {
-      selected: [2],
-      info: {},
-      items: [],
-      offset: 0,
-      active: 0
-    }
-  },
-  created() {
-    this.$globals.windowtitle = ""
-    this.getInfo();
-    this.getPlaylistTracks();
-    this.scroll(this.Browse);
-  },
-  methods: {
-    getInfo () {
-      const api_url = '/api/playlists/' + this.media_id
-      axios
-      .get(api_url, { params: { provider: this.provider }})
-        .then(result => {
-          data = result.data;
-          this.info = data;
-        })
-        .catch(error => {
-          console.log("error", error);
-        });
-    },
-    getPlaylistTracks () {
-      this.$globals.loading = true
-      const api_url = '/api/playlists/' + this.media_id + '/tracks'
-      axios
-        .get(api_url, { params: { offset: this.offset, limit: 25, provider: this.provider}})
-        .then(result => {
-          data = result.data;
-          this.items.push(...data);
-          this.offset += 25;
-          this.$globals.loading = false;
-        })
-        .catch(error => {
-          console.log("error", error);
-        });
-        
-    },
-    scroll (Browse) {
-      window.onscroll = () => {
-        let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight;
-        if (bottomOfWindow) {
-          this.getPlaylistTracks();
-        }
-      };
-    }
-  }
-})
diff --git a/web/pages/queue.vue.js b/web/pages/queue.vue.js
deleted file mode 100755 (executable)
index 9bc25a9..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-var Queue = Vue.component('Queue', {
-  template: `
-  <section>
-        <v-list two-line>
-        <listviewItem 
-            v-for="(item, index) in items" 
-            v-bind:item="item"
-            :key="item.db_id"
-            :hideavatar="isMobile()"
-            :hidetracknum="true"
-            :hideproviders="isMobile()"
-            :hidelibrary="isMobile()">
-        </listviewItem>
-      </v-list>
-      </section>`,
-  props: ['player_id'],
-  data() {
-    return {
-      selected: [0],
-      info: {},
-      items: [],
-      offset: 0,
-    }
-  },
-  created() {
-    this.$globals.windowtitle = this.$t('queue')
-    this.getQueueTracks(0, 25);
-  },
-  methods: {
-
-    getQueueTracks (offset, limit) {
-      const api_url = '/api/players/' + this.player_id + '/queue'
-      return axios.get(api_url, { params: { offset: offset, limit: limit}})
-        .then(response => {
-            if (response.data.length < 1 )
-              return;
-            this.items.push(...response.data)
-            return this.getQueueTracks(offset+limit, 100)
-        })
-    }
-  }
-})
diff --git a/web/pages/search.vue.js b/web/pages/search.vue.js
deleted file mode 100755 (executable)
index 996c01e..0000000
+++ /dev/null
@@ -1,154 +0,0 @@
-var Search = Vue.component('Search', {
-  template: `
-  <section>
-
-    <v-text-field
-        solo
-        clearable
-        :label="$t('type_to_search')"
-        append-icon="search"
-        v-model="searchQuery" v-on:keyup.enter="Search" @click:append="Search" style="margin-left:30px; margin-right:30px; margin-top:10px">
-      </v-text-field>
-
-      <v-tabs
-          v-model="active"
-          color="transparent"
-          light
-          slider-color="black"
-        >
-
-        <v-tab ripple v-if="tracks.length">{{ $t('tracks') }}</v-tab>
-          <v-tab-item v-if="tracks.length">
-            <v-card flat>
-                <v-list two-line style="margin-left:15px; margin-right:15px">
-                    <listviewItem 
-                        v-for="(item, index) in tracks" 
-                        v-bind:item="item"
-                        :key="item.db_id"
-                        v-bind:totalitems="tracks.length"
-                        v-bind:index="index"
-                        :hideavatar="isMobile()"
-                        :hidetracknum="true"
-                        :hideproviders="isMobile()"
-                        :hideduration="isMobile()"
-                        :showlibrary="true">
-                    </listviewItem>
-              </v-list>
-            </v-card>
-          </v-tab-item>
-
-          <v-tab ripple v-if="artists.length">{{ $t('artists') }}</v-tab>
-          <v-tab-item v-if="artists.length">
-            <v-card flat>
-            <v-list two-line>
-                  <listviewItem 
-                      v-for="(item, index) in artists" 
-                      v-bind:item="item"
-                      :key="item.db_id"
-                      v-bind:totalitems="artists.length"
-                      v-bind:index="index"
-                      :hideproviders="isMobile()"
-                      >
-                  </listviewItem>
-                </v-list>
-            </v-card>
-          </v-tab-item>
-
-          <v-tab ripple v-if="albums.length">{{ $t('albums') }}</v-tab>
-          <v-tab-item v-if="albums.length">
-            <v-card flat>
-                <v-list two-line>
-                    <listviewItem 
-                        v-for="(item, index) in albums" 
-                        v-bind:item="item"
-                        :key="item.db_id"
-                        v-bind:totalitems="albums.length"
-                        v-bind:index="index"
-                        :hideproviders="isMobile()"
-                        >
-                    </listviewItem>
-              </v-list>
-            </v-card>
-          </v-tab-item>
-
-          <v-tab ripple v-if="playlists.length">{{ $t('playlists') }}</v-tab>
-          <v-tab-item v-if="playlists.length">
-            <v-card flat>
-                <v-list two-line>
-                    <listviewItem 
-                        v-for="(item, index) in playlists" 
-                        v-bind:item="item"
-                        :key="item.db_id"
-                        v-bind:totalitems="playlists.length"
-                        v-bind:index="index"
-                        :hidelibrary="true">
-                    </listviewItem>
-              </v-list>
-            </v-card>
-          </v-tab-item>
-
-        </v-tabs>
-
-      </section>`,
-  props: [],
-  data() {
-    return {
-      selected: [2],
-      artists: [],
-      albums: [],
-      tracks: [],
-      playlists: [],
-      timeout: null,
-      active: 0,
-      searchQuery: ""
-    }
-  },
-  created() {
-    this.$globals.windowtitle = this.$t('search');
-  },
-  watch: {
-  },
-  methods: {
-    toggle (index) {
-      const i = this.selected.indexOf(index)
-      if (i > -1) {
-        this.selected.splice(i, 1)
-      } else {
-        this.selected.push(index)
-        console.log("selected: "+ this.items[index].name);
-      }
-    },
-    Search () {
-      this.artists = [];
-      this.albums = [];
-      this.tracks = [];
-      this.playlists = [];
-      if (this.searchQuery) {
-        this.$globals.loading = true;
-        console.log(this.searchQuery);
-        const api_url = '/api/search'
-        console.log('loading ' + api_url);
-          axios
-            .get(api_url, {
-              params: {
-                query: this.searchQuery,
-                online: true,
-                limit: 3
-              }
-            })
-            .then(result => {
-              data = result.data;
-              this.artists = data.artists;
-              this.albums = data.albums;
-              this.tracks = data.tracks;
-              this.playlists = data.playlists;
-              this.$globals.loading = false;
-            })
-            .catch(error => {
-              console.log("error", error);
-            });
-        } 
-        
-    },
-  }
-})
diff --git a/web/pages/trackdetails.vue.js b/web/pages/trackdetails.vue.js
deleted file mode 100755 (executable)
index e8f0896..0000000
+++ /dev/null
@@ -1,77 +0,0 @@
-var TrackDetails = Vue.component('TrackDetails', {
-  template: `
-  <section>
-      <infoheader v-bind:info="info"/>
-      <v-tabs
-          v-model="active"
-          color="transparent"
-          light
-          slider-color="black"
-        >
-          <v-tab ripple>Other versions</v-tab>
-          <v-tab-item>
-            <v-card flat>
-                <v-list two-line>
-                  <listviewItem 
-                      v-for="(item, index) in trackversions" 
-                      v-bind:item="item"
-                      :key="item.db_id"
-                      v-bind:totalitems="trackversions.length"
-                      v-bind:index="index"
-                      :hideavatar="isMobile()"
-                      :hidetracknum="true"
-                      :hideproviders="isMobile()"
-                      :hidelibrary="isMobile()">
-                  </listviewItem>
-              </v-list>
-            </v-card>
-          </v-tab-item>
-        </v-tabs>
-
-      </section>`,
-  props: ['provider', 'media_id'],
-  data() {
-    return {
-      selected: [2],
-      info: {},
-      trackversions: [],
-      offset: 0,
-      active: null,
-    }
-  },
-  created() {
-    this.$globals.windowtitle = ""
-    this.getInfo();
-  },
-  methods: {
-    getInfo () {
-      this.$globals.loading = true;
-      const api_url = '/api/tracks/' + this.media_id
-      axios
-        .get(api_url, { params: { provider: this.provider }})
-        .then(result => {
-          data = result.data;
-          this.info = data;
-          this.getTrackVersions()
-          this.$globals.loading = false;
-        })
-        .catch(error => {
-          console.log("error", error);
-        });
-    },
-    getTrackVersions () {
-      const api_url = '/api/search';
-      var searchstr = this.info.artists[0].name + " - " + this.info.name
-      axios
-        .get(api_url, { params: { query: searchstr, limit: 50, media_types: 'tracks', online: true}})
-        .then(result => {
-          data = result.data;
-          this.trackversions.push(...data.tracks);
-          this.offset += 50;
-        })
-        .catch(error => {
-          console.log("error", error);
-        });
-    },
-  }
-})
diff --git a/web/strings.js b/web/strings.js
deleted file mode 100644 (file)
index 5c1df07..0000000
+++ /dev/null
@@ -1,179 +0,0 @@
-const messages = {
-    
-    
-    en: {
-        // generic strings
-        musicassistant: "Music Assistant",
-        home: "Home",
-        artists: "Artists",
-        albums: "Albums",
-        tracks: "Tracks",
-        playlists: "Playlists",
-        radios: "Radio",
-        search: "Search",
-        settings: "Settings",
-        queue: "Queue",
-        type_to_search: "Type here to search...",
-        add_library: "Add to library",
-        remove_library: "Remove from library",
-        add_playlist: "Add to playlist...",
-        remove_playlist: "Remove from playlist",
-        // settings strings
-        conf: {
-            enabled: "Enabled",
-            base: "Generic settings",
-            musicproviders: "Music providers",
-            playerproviders: "Player providers",
-            player_settings: "Player settings",
-            homeassistant: "Home Assistant integration",
-            web: "Webserver",
-            http_streamer: "Built-in (sox based) streamer",
-            qobuz: "Qobuz",
-            spotify: "Spotify",
-            tunein: "TuneIn",
-            file: "Filesystem",
-            chromecast: "Chromecast",
-            lms: "Logitech Media Server",
-            pylms: "Emulated (built-in) Squeezebox support",
-            username: "Username",
-            password: "Password",
-            hostname: "Hostname (or IP)",
-            port: "Port",
-            hass_url: "URL to homeassistant (e.g. https://homeassistant:8123)",
-            hass_token: "Long Lived Access Token",
-            hass_publish: "Publish players to Home Assistant",
-            hass_player_power: "Attach player power to homeassistant entity",
-            hass_player_source: "Source on the homeassistant entity (optional)",
-            hass_player_volume: "Attach player volume to homeassistant entity",
-            web_ssl_cert: "Path to ssl certificate file",
-            web_ssl_key: "Path to ssl keyfile",
-            player_enabled: "Enable player",
-            player_name: "Custom name for this player",
-            player_group_with: "Group this player to another (parent)player",
-            player_mute_power: "Use muting as power control",
-            player_disable_vol: "Disable volume controls",
-            player_group_vol: "Apply group volume to childs (for group players only)",
-            player_group_pow: "Apply group power based on childs (for group players only)",
-            player_power_play: "Issue play command on power on",
-            file_prov_music_path: "Path to music files",
-            file_prov_playlists_path: "Path to playlists (.m3u)",
-            web_http_port: "HTTP port",
-            web_https_port: "HTTPS port",
-            cert_fqdn_host: "FQDN of hostname in certificate",
-            enable_r128_volume_normalisation: "Enable R128 volume normalization",
-            target_volume_lufs: "Target volume (R128 default is -23 LUFS)",
-            fallback_gain_correct: "Fallback gain correction if R128 readings not (yet) available",
-            enable_audio_cache: "Allow caching of audio to temp files",
-            trim_silence: "Strip silence from beginning and end of audio (temp files only!)",
-            http_streamer_sox_effects: "Custom sox effects to apply to audio (built-in streamer only!) See http://sox.sourceforge.net/sox.html#EFFECTS",
-            max_sample_rate: "Maximum sample rate this player supports, higher will be downsampled",
-            force_http_streamer: "Force use of built-in streamer, even if the player can handle the music provider directly",
-            not_grouped: "Not grouped",
-            conf_saved: "Configuration saved, restart app to make effective",
-            audio_cache_folder: "Directory to use for cache files",
-            audio_cache_max_size_gb: "Maximum size of the cache folder (GB)"
-        },
-        // player strings
-        players: "Players",
-        play: "Play",
-        play_on: "Play on:",
-        play_now: "Play Now",
-        play_next: "Play Next",
-        add_queue: "Add to Queue",
-        show_info: "Show info",
-        state: {        
-            playing: "playing",
-            stopped: "stopped",
-            paused: "paused",
-            off: "off"
-        }
-    },
-
-    nl: {
-        // generic strings
-        musicassistant: "Music Assistant",
-        home: "Home",
-        artists: "Artiesten",
-        albums: "Albums",
-        tracks: "Nummers",
-        playlists: "Afspeellijsten",
-        radios: "Radio",
-        search: "Zoeken",
-        settings: "Instellingen",
-        queue: "Wachtrij",
-        type_to_search: "Type hier om te zoeken...",
-        add_library: "Voeg toe aan bibliotheek",
-        remove_library: "Verwijder uit bibliotheek",
-        add_playlist: "Aan playlist toevoegen...",
-        remove_playlist: "Verwijder uit playlist",
-        // settings strings
-        conf: {
-            enabled: "Ingeschakeld",
-            base: "Algemene instellingen",
-            musicproviders: "Muziek providers",
-            playerproviders: "Speler providers",
-            player_settings: "Speler instellingen",
-            homeassistant: "Home Assistant integratie",
-            web: "Webserver",
-            http_streamer: "Ingebouwde (sox gebaseerde) streamer",
-            qobuz: "Qobuz",
-            spotify: "Spotify",
-            tunein: "TuneIn",
-            file: "Bestandssysteem",
-            chromecast: "Chromecast",
-            lms: "Logitech Media Server",
-            pylms: "Geemuleerde (ingebouwde) Squeezebox ondersteuning",
-            username: "Gebruikersnaam",
-            password: "Wachtwoord",
-            hostname: "Hostnaam (of IP)",
-            port: "Poort",
-            hass_url: "URL naar homeassistant (b.v. https://homeassistant:8123)",
-            hass_token: "Token met lange levensduur",
-            hass_publish: "Publiceer spelers naar Home Assistant",
-            hass_player_power: "Verbind speler aan/uit met homeassistant entity",
-            hass_player_source: "Benodigde bron op de verbonden homeassistant entity (optioneel)",
-            hass_player_volume: "Verbind volume van speler aan een homeassistant entity",
-            web_ssl_cert: "Pad naar ssl certificaat bestand",
-            web_ssl_key: "Pad naar ssl certificaat key bestand",
-            player_enabled: "Speler inschakelen",
-            player_name: "Aangepaste naam voor deze speler",
-            player_group_with: "Groupeer deze speler met een andere (hoofd)speler",
-            player_mute_power: "Gebruik mute als aan/uit",
-            player_disable_vol: "Schakel volume bediening helemaal uit",
-            player_group_vol: "Pas groep volume toe op onderliggende spelers (alleen groep spelers)",
-            player_group_pow: "Pas groep aan/uit toe op onderliggende spelers (alleen groep spelers)",
-            player_power_play: "Automatisch afspelen bij inschakelen",
-            file_prov_music_path: "Pad naar muziek bestanden",
-            file_prov_playlists_path: "Pad naar playlist bestanden (.m3u)",
-            web_http_port: "HTTP poort",
-            web_https_port: "HTTPS poort",
-            cert_fqdn_host: "Hostname (FQDN van certificaat)",
-            enable_r128_volume_normalisation: "Schakel R128 volume normalisatie in",
-            target_volume_lufs: "Doelvolume (R128 standaard is -23 LUFS)",
-            fallback_gain_correct: "Fallback gain correctie indien R128 meting (nog) niet beschikbaar is",
-            enable_audio_cache: "Sta het cachen van audio toe naar temp map",
-            trim_silence: "Strip stilte van begin en eind van audio (in temp bestanden)",
-            http_streamer_sox_effects: "Eigen sox effects toepassen op audio (alleen voor ingebouwde streamer). Zie http://sox.sourceforge.net/sox.html#EFFECTS",
-            max_sample_rate: "Maximale sample rate welke deze speler ondersteund, hoger wordt gedownsampled.",
-            force_http_streamer: "Forceer het gebruik van de ingebouwde streamer, ook al heeft de speler directe ondersteuning voor de muziek provider",
-            not_grouped: "Niet gegroepeerd",
-            conf_saved: "Configuratie is opgeslagen, herstart om actief te maken",
-            audio_cache_folder: "Map om te gebruiken voor cache bestanden",
-            audio_cache_max_size_gb: "Maximale grootte van de cache map in GB."
-        },
-        // player strings
-        players: "Spelers",
-        play: "Afspelen",
-        play_on: "Afspelen op:",
-        play_now: "Nu afspelen",
-        play_next: "Speel als volgende af",
-        add_queue: "Voeg toe aan wachtrij",
-        show_info: "Bekijk informatie",
-        state: {        
-            playing: "afspelen",
-            stopped: "gestopt",
-            paused: "gepauzeerd",
-            off: "uitgeschakeld"
-        }
-    }
-}
\ No newline at end of file