-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
+++ /dev/null
-#!/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
--- /dev/null
+#!/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
#!/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
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 '''
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
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=""):
'''
--- /dev/null
+#!/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
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"
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));')
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 '''
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'):
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):
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'])
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':
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'''
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):
'''
'''
# 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
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:
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 '''
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)
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 '''
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):
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):
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:
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 #####
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
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"))
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
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'
''' [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
''' [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
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')
''' [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):
''' [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
''' [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:
''' [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:
''' [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:
''' [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:
''' [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:
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(
'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)
# 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()
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(
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)
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:
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
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):
("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
'''
- 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
@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 #####
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'''
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))
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):
'''
'''
- 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 '''
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 '''
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 '''
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 '''
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
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):
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
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 '''
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 '''
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(
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(
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)
# 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 '''
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):
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
import asyncio
import os
-import json
import aiohttp
from aiohttp import web
from functools import partial
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 '''
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):
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')
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):
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 '''
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)
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')
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':
--- /dev/null
+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: { }
+})
--- /dev/null
+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
--- /dev/null
+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: {
+ }
+})
--- /dev/null
+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);
+ }
+ }
+})
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+/* 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
--- /dev/null
+[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
--- /dev/null
+.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
+}
+
--- /dev/null
+<!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
--- /dev/null
+!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
--- /dev/null
+{
+ "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
--- /dev/null
+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);
+ });
+ },
+ }
+})
--- /dev/null
+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);
+ });
+ },
+ }
+})
--- /dev/null
+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();
+ }
+ };
+ }
+ }
+})
--- /dev/null
+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;
+ });
+ },
+ }
+})
--- /dev/null
+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})
+ }
+ }
+});
--- /dev/null
+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();
+ }
+ };
+ }
+ }
+})
--- /dev/null
+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)
+ })
+ }
+ }
+})
--- /dev/null
+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);
+ });
+ }
+
+ },
+ }
+})
--- /dev/null
+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);
+ });
+ },
+ }
+})
--- /dev/null
+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
cytoolz
aiohttp
spotify_token
+protobuf
pychromecast
uvloop
asyncio_throttle
python-slugify
netaddr
memory-tempfile
-soundfile
-pyloudnorm
\ No newline at end of file
+aiohttp
+pyloudnorm
+SoundFile
+aiorun
\ No newline at end of file
+++ /dev/null
-#!/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
+++ /dev/null
-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: { }
-})
+++ /dev/null
-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
+++ /dev/null
-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: {
- }
-})
+++ /dev/null
-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);
- }
- }
-})
+++ /dev/null
-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
+++ /dev/null
-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
+++ /dev/null
-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
+++ /dev/null
-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
+++ /dev/null
-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
+++ /dev/null
-/* 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
+++ /dev/null
-[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
+++ /dev/null
-.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
-}
-
+++ /dev/null
-<!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
+++ /dev/null
-!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
+++ /dev/null
-{
- "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
+++ /dev/null
-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);
- });
- },
- }
-})
+++ /dev/null
-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);
- });
- },
- }
-})
+++ /dev/null
-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();
- }
- };
- }
- }
-})
+++ /dev/null
-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;
- });
- },
- }
-})
+++ /dev/null
-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})
- }
- }
-});
+++ /dev/null
-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();
- }
- };
- }
- }
-})
+++ /dev/null
-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)
- })
- }
- }
-})
+++ /dev/null
-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);
- });
- }
-
- },
- }
-})
+++ /dev/null
-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);
- });
- },
- }
-})
+++ /dev/null
-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