implement own player protocol
authormarcelveldt <marcelvanderveldt@MacBook-Silvia.local>
Sat, 19 Oct 2019 08:18:37 +0000 (10:18 +0200)
committermarcelveldt <marcelvanderveldt@MacBook-Silvia.local>
Sat, 19 Oct 2019 08:18:37 +0000 (10:18 +0200)
music_assistant/__init__.py
music_assistant/http_streamer.py
music_assistant/models/playerstate.py
music_assistant/playerproviders/web.py
music_assistant/web.py
music_assistant/web/app.js [new file with mode: 0644]
music_assistant/web/components/player.vue.js
music_assistant/web/index.html
music_assistant/web/lib/utils.js [new file with mode: 0644]

index ba0482662f28858913a85bdf526678904ca5fa8b..a9f2b9202c9484728d97b508fdd4cf9d3b5ffd82 100644 (file)
@@ -57,6 +57,10 @@ class MusicAssistant():
         await self.players.setup()
         await self.web.setup()
         await self.http_streamer.setup()
+        # temp code to chase memory leak
+        import subprocess
+        subprocess.call("pip install pympler", shell=True)
+        self.event_loop.create_task(self.print_memory())
 
     def handle_exception(self, loop, context):
         ''' global exception handler '''
@@ -80,3 +84,18 @@ class MusicAssistant():
     async def remove_event_listener(self, cb_id):
         ''' remove callback from our event listeners '''
         self.event_listeners.pop(cb_id, None)
+
+    @run_periodic(30)
+    async def print_memory(self):
+        
+        from pympler import muppy, summary
+        
+        all_objects = muppy.get_objects()
+        sum1 = summary.summarize(all_objects)
+        # Prints out a summary of the large objects
+        summary.print_(sum1)
+        # Get references to certain types of objects such as dataframe
+        # dataframes = [ao for ao in all_objects if isinstance(ao, pd.DataFrame)]
+        # for d in dataframes:
+        #     print(d.columns.values)
+        #     print(len(d))
index 1c9638d37da5d652057c5a60c4dd85e47372bfec..464aded716755e8aa0a2b1d01fa32c9cd8a18bd6 100755 (executable)
@@ -33,21 +33,7 @@ class HTTPStreamer():
         pass
         # self.mass.event_loop.create_task(
         #         asyncio.start_server(self.sockets_streamer, '0.0.0.0', 8093))
-    
-    async def webplayer(self, http_request):
-        ''' 
-            start stream for a player
-        '''
-        from .models import Player
-        player_id = http_request.match_info.get('player_id')
-        player = Player(self.mass, player_id, 'web')
-        player.name = player_id
-        await self.mass.players.add_player(player)
-        # wait for queue
-        while not player.queue.items:
-            await asyncio.sleep(0.2)
-        return await self.stream(http_request)    
-    
+        
     async def stream(self, http_request):
         ''' 
             start stream for a player
@@ -58,7 +44,10 @@ class HTTPStreamer():
         assert(player)
         # prepare headers as audio/flac content
         resp = web.StreamResponse(status=200, reason='OK', 
-                headers={'Content-Type': 'audio/flac'})
+                headers={
+                    'Content-Type': 'audio/mp3' if player.player_provider else 'audio/flac',
+                    'Accept-Ranges': 'None'
+                    })
         await resp.prepare(http_request)
         # send content only on GET request
         if http_request.method.upper() != 'GET':
@@ -120,12 +109,17 @@ class HTTPStreamer():
         fade_length = try_parse_int(player.settings["crossfade_duration"])
         if not sample_rate or sample_rate < 44100 or sample_rate > 384000:
             sample_rate = 96000
+        elif player.player_provider == 'web':
+            sample_rate = 41100
         if fade_length:
             fade_bytes = int(sample_rate * 4 * 2 * fade_length)
         else:
             fade_bytes = int(sample_rate * 4 * 2)
         pcm_args = 'raw -b 32 -c 2 -e signed-integer -r %s' % sample_rate
-        args = 'sox -t %s - -t flac -C 0 -' % pcm_args
+        if player.player_provider == 'web':
+            args = 'sox -t %s - -t flac -C 0 -' % pcm_args
+        else:
+             args = 'sox -t %s - -t mp3 -' % pcm_args
         # start sox process
         # we use normal subprocess instead of asyncio because of bug with executor
         # this should be fixed with python 3.8
@@ -133,7 +127,7 @@ class HTTPStreamer():
             stdout=subprocess.PIPE, stdin=subprocess.PIPE)
 
         def fill_buffer():
-            sample_size = int(sample_rate * 4 * 2 * 2)
+            sample_size = int(sample_rate * 4 * 2)
             while sox_proc.returncode == None:
                 chunk = sox_proc.stdout.read(sample_size)
                 if not chunk:
index 34336119300a2b39e929f0e2319d507f1b1dc9a7..6ca6e2e02eca3401be97aa4194ec2d673dff9fe3 100755 (executable)
@@ -8,3 +8,13 @@ class PlayerState(str, Enum):
     Stopped = "stopped"
     Paused = "paused"
     Playing = "playing"
+
+    # def from_string(self, string):
+    #     if string == "off":
+    #         return self.Off
+    #     elif string == "stopped":
+    #         return self.Stopped
+    #     elif string == "paused":
+    #         return self.Paused
+    #     elif string == "playing":
+    #         return self.Playing
index 09555889fbfd470a3dcf11ea8e6e6c4f757c8ce0..6b6af1b45c29bf67b2ef2a41dedb600ec58c39ba 100644 (file)
@@ -26,9 +26,17 @@ CONFIG_ENTRIES = [
 
 PLAYER_CONFIG_ENTRIES = []
 
+EVENT_WEBPLAYER_CMD = 'webplayer command'
+EVENT_WEBPLAYER_STATE = 'webplayer state'
+EVENT_WEBPLAYER_REGISTER = 'webplayer register'
 
 class WebPlayerProvider(PlayerProvider):
-    ''' Python implementation of SlimProto server '''
+    ''' 
+        Implementation of a player using pure HTML/javascript
+        used in the front-end.
+        Communication is handled through the websocket connection
+        and our internal event bus
+    '''
 
     def __init__(self, mass, conf):
         super().__init__(mass, conf)
@@ -40,6 +48,103 @@ class WebPlayerProvider(PlayerProvider):
 
     async def setup(self):
         ''' async initialize of module '''
-        pass
+        await self.mass.add_event_listener(self.handle_mass_event, EVENT_WEBPLAYER_STATE)
+        await self.mass.add_event_listener(self.handle_mass_event, EVENT_WEBPLAYER_REGISTER)
+        self.mass.event_loop.create_task(self.check_players())
+
+    async def handle_mass_event(self, msg, msg_details):
+        ''' received event for the webplayer component '''
+        #print("%s ---> %s" %(msg, msg_details))
+        if msg == EVENT_WEBPLAYER_REGISTER:
+            # register new player
+            player_id = msg_details['player_id']
+            player = WebPlayer(self.mass, player_id, self.prov_id)
+            player.supports_crossfade = False
+            player.supports_gapless = False
+            player.supports_queue = False
+            player.name = msg_details['name']
+            await self.add_player(player)
+        elif msg == EVENT_WEBPLAYER_STATE:
+            player_id = msg_details['player_id']
+            player = await self.get_player(player_id)
+            if player:
+                await player.handle_state(msg_details)
+
+    @run_periodic(30)
+    async def check_players(self):
+        ''' invalidate players that did not send a heartbeat message in a while '''
+        cur_time = time.time()
+        offline_players = []
+        for player in self.players:
+            if cur_time - player._last_message > 30:
+                offline_players.append(player.player_id)
+        for player_id in offline_players:
+            await self.remove_player(player_id)
+
+
+class WebPlayer(Player):
+    ''' Web player object '''
+
+    def __init__(self, mass, player_id, prov_id):
+        self._last_message = time.time()
+        super().__init__(mass, player_id, prov_id)
+
+    async def cmd_stop(self):
+        ''' send stop command to player '''
+        data = { 'player_id': self.player_id, 'cmd': 'stop'}
+        await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+
+    async def cmd_play(self):
+        ''' send play command to player '''
+        data = { 'player_id': self.player_id, 'cmd': 'play'}
+        await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+
+    async def cmd_pause(self):
+        ''' send pause command to player '''
+        data = { 'player_id': self.player_id, 'cmd': 'pause'}
+        await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+    
+    async def cmd_power_on(self):
+        ''' send power ON command to player '''
+        self.powered = True # not supported on webplayer
+        data = { 'player_id': self.player_id, 'cmd': 'stop'}
+        await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+
+    async def cmd_power_off(self):
+        ''' send power OFF command to player '''
+        self.powered = False
+
+    async def cmd_volume_set(self, volume_level):
+        ''' send new volume level command to player '''
+        data = { 'player_id': self.player_id, 'cmd': 'volume_set', 'volume_level': volume_level}
+        await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+
+    async def cmd_volume_mute(self, is_muted=False):
+        ''' send mute command to player '''
+        data = { 'player_id': self.player_id, 'cmd': 'volume_mute', 'is_muted': is_muted}
+        await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+
+    async def cmd_play_uri(self, uri:str):
+        ''' play single uri on player '''
+        data = { 'player_id': self.player_id, 'cmd': 'play_uri', 'uri': uri}
+        await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+
+    async def handle_state(self, data):
+        ''' handle state event from player '''
+        if 'volume_level' in data:
+            self.volume_level = data['volume_level']
+        if 'muted' in data:
+            self.muted = data['muted']
+        if 'state' in data:
+            self.state = PlayerState(data['state'])
+        if 'cur_time' in data:
+            self.cur_time = data['cur_time']
+        if 'cur_uri' in data:
+            self.cur_uri = data['cur_uri']
+        if 'powered' in data:
+            self.powered = data['powered']
+        if 'name' in data:
+            self.name = data['name']
+        self._last_message = time.time()
+
 
-    
\ No newline at end of file
index e855e9d8707e8fccca5fdc887221172afab6f703..10bed3433767e80cb21747ad4ab6a2c3cae75cdc 100755 (executable)
@@ -10,7 +10,6 @@ import ssl
 import concurrent
 import threading
 from .models.media_types import MediaItem, MediaType, media_type_from_string
-from .models.player import Player
 from .utils import run_periodic, LOGGER, run_async_background_task, get_ip, json_serializer
 
 CONF_KEY = 'web'
@@ -50,7 +49,6 @@ class Web():
         app.add_routes([web.post('/jsonrpc.js', self.json_rpc)])
         app.add_routes([web.get('/ws', self.websocket_handler)])
         app.add_routes([web.get('/stream/{player_id}', self.mass.http_streamer.stream)])
-        app.add_routes([web.get('/stream/web/{player_id}', self.mass.http_streamer.webplayer)])
         app.add_routes([web.get('/stream/{player_id}/{queue_item_id}', self.mass.http_streamer.stream)])
         app.add_routes([web.get('/api/search', self.search)])
         app.add_routes([web.get('/api/config', self.get_config)])
@@ -235,27 +233,34 @@ class Web():
             cb_id = await self.mass.add_event_listener(send_event)
             # process incoming messages
             async for msg in ws:
-                if msg.type != aiohttp.WSMsgType.TEXT:
-                    continue
-                # for now we only use WS for (simple) player commands
-                if msg.data == '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)
-                elif msg.data.startswith('players') and '/cmd/' in msg.data:
-                    # players/{player_id}/cmd/{cmd} or players/{player_id}/cmd/{cmd}/{cmd_args}
-                    msg_data_parts = msg.data.split('/')
-                    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.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, AssertionError) as exc:
+                if msg.type == aiohttp.WSMsgType.ERROR:
+                    LOGGER.debug('ws connection closed with exception %s' %
+                        ws.exception())
+                elif msg.type != aiohttp.WSMsgType.TEXT:
+                    LOGGER.warning(msg.data)
+                else:
+                    data = msg.json()
+                    # for now we only use WS for (simple) player commands
+                    if data['message'] == '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)
+                    elif data['message'] == 'player command':
+                        player_id = data['message_details']['player_id']
+                        cmd = data['message_details']['cmd']
+                        cmd_args = data['message_details']['cmd_args']
+                        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()
+                    else:
+                        # echo the websocket message on event bus
+                        # can be picked up by other modules, e.g. the webplayer
+                        await self.mass.signal_event(data['message'], data['message_details'])
+        except (Exception, AssertionError, asyncio.CancelledError) as exc:
             LOGGER.warning("Websocket disconnected - %s" % str(exc))
         finally:
             await self.mass.remove_event_listener(cb_id)
diff --git a/music_assistant/web/app.js b/music_assistant/web/app.js
new file mode 100644 (file)
index 0000000..c270e5a
--- /dev/null
@@ -0,0 +1,109 @@
+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
+})
\ No newline at end of file
index b0a80b6a46e74f6abebcd7ebc93f941f279f4195..c526f845fe484c220061a12d9f0a5fec495fd97a 100755 (executable)
@@ -81,7 +81,7 @@ Vue.component("player", {
 
               <!-- active player btn -->
               <v-list-tile-action style="padding:30px;margin-right:-13px;">
-                  <v-btn x-small flat icon @click="menu = !menu">
+                  <v-btn x-small flat icon @click="menu = !menu;createAudioPlayer();">
                       <v-flex xs12 class="vertical-btn">
                       <v-icon>speaker</v-icon>
                       <span class="caption">{{ active_player_id ? players[active_player_id].name : '' }}</span>
@@ -93,8 +93,6 @@ Vue.component("player", {
           <!-- add some additional whitespace in standalone mode only -->
           <v-list-tile avatar ripple style="height:14px" v-if="isInStandaloneMode()"/>
 
-          
-
       </v-card>
     </v-footer>
 
@@ -151,11 +149,18 @@ Vue.component("player", {
       menu: false,
       players: {},
       active_player_id: "",
-      ws: null
+      ws: null,
+      file: "",
+      audioPlayer: null,
+      audioPlayerId: '',
+      audioPlayerName: ''
     }
   },
-  mounted() { },
+  mounted() { 
+    
+  },
   created() {
+    // connect the websocket
     this.connectWS();
   },
   computed: {
@@ -196,10 +201,12 @@ Vue.component("player", {
   },
   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);
+      let msg_details = {
+        player_id: player_id,
+        cmd: cmd,
+        cmd_args: cmd_opt
+      }
+      this.ws.send(JSON.stringify({message:'player command', message_details: msg_details}));
     },
     playItem(item, queueopt) {
       console.log('playItem: ' + item);
@@ -244,6 +251,110 @@ Vue.component("player", {
       else
         this.playerCommand('power_on', null, player_id);
     },
+    handleAudioPlayerCommand(data) {
+      /// we received a command for our built-in audio player
+      if (data.cmd == 'play')
+        this.audioPlayer.play();
+      else if (data.cmd == 'pause')
+        this.audioPlayer.pause();
+      else if (data.cmd == 'stop')
+        {
+          console.log('stop called');
+          this.audioPlayer.pause();
+          this.audioPlayer = new Audio();
+          let msg_details = {
+            player_id: this.audioPlayerId,
+            state: 'stopped'
+          }
+          this.ws.send(JSON.stringify({message:'webplayer state', message_details: msg_details}));
+        }
+      else if (data.cmd == 'volume_set')
+        this.audioPlayer.volume = data.volume_level/100;
+      else if (data.cmd == 'volume_mute')
+        this.audioPlayer.mute = data.is_muted;
+      else if (data.cmd == 'play_uri')
+        {
+          this.audioPlayer.src = data.uri;
+          this.audioPlayer.load();
+        }
+    },
+    createAudioPlayer(data) {
+      if (localStorage.getItem('audio_player_id'))
+        // get player id from local storage
+        this.audioPlayerId = localStorage.getItem('audio_player_id');
+      else
+      {
+        // generate a new (randomized) player id
+        this.audioPlayerId = (Date.now().toString(36) + Math.random().toString(36).substr(2, 5)).toUpperCase();
+        localStorage.setItem('audio_player_id', this.audioPlayerId);
+      }
+      this.audioPlayerName = 'Webplayer ' + this.audioPlayerId.substring(1, 4);
+      this.audioPlayer = new Audio();
+      this.audioPlayer.autoplay = false;
+      this.audioPlayer.preload = 'none';
+      let msg_details = {
+        player_id: this.audioPlayerId,
+        name: this.audioPlayerName,
+        state: 'stopped',
+        powered: true,
+        volume_level: this.audioPlayer.volume * 100,
+        muted: this.audioPlayer.muted,
+        cur_uri: this.audioPlayer.src
+      }
+      // register the player on the server
+      this.ws.send(JSON.stringify({message:'webplayer register', message_details: msg_details}));
+      // add event handlers
+      this.audioPlayer.addEventListener("canplaythrough", event => {
+        /* the audio is now playable; play it if permissions allow */
+        console.log("canplaythrough")
+        this.audioPlayer.play();
+      });
+      this.audioPlayer.addEventListener("canplay", event => {
+        /* the audio is now playable; play it if permissions allow */
+        console.log("canplay");
+        //this.audioPlayer.play();
+        //msg_details['cur_uri'] = this.audioPlayer.src;
+        //this.ws.send(JSON.stringify({message:'webplayer state', message_details: msg_details}));
+      });
+      this.audioPlayer.addEventListener("emptied", event => {
+        /* the audio is now playable; play it if permissions allow */
+        console.log("emptied");
+        //this.audioPlayer.play();
+      });
+      const timeupdateHandler = (event) => {
+        // currenTime of player updated, sent state (throttled at 1 sec)
+        msg_details['cur_time'] = Math.round(this.audioPlayer.currentTime);
+        this.ws.send(JSON.stringify({message:'webplayer state', message_details: msg_details}));
+      }
+      const throttledTimeUpdateHandler = this.throttle(timeupdateHandler, 1000);
+      this.audioPlayer.addEventListener("timeupdate",throttledTimeUpdateHandler);
+
+      this.audioPlayer.addEventListener("volumechange", event => {
+        /* the audio is now playable; play it if permissions allow */
+        console.log('volume: ' + this.audioPlayer.volume);
+        msg_details['volume_level'] = this.audioPlayer.volume*100;
+        msg_details['muted'] = this.audioPlayer.muted;
+        this.ws.send(JSON.stringify({message:'webplayer state', message_details: msg_details}));
+      });
+      this.audioPlayer.addEventListener("playing", event => {
+        msg_details['state'] = 'playing';
+        this.ws.send(JSON.stringify({message:'webplayer state', message_details: msg_details}));
+      });
+      this.audioPlayer.addEventListener("pause", event => {
+        msg_details['state'] = 'paused';
+        this.ws.send(JSON.stringify({message:'webplayer state', message_details: msg_details}));
+      });
+      this.audioPlayer.addEventListener("ended", event => {
+        msg_details['state'] = 'stopped';
+        this.ws.send(JSON.stringify({message:'webplayer state', message_details: msg_details}));
+      });
+      const heartbeatMessage = (event) => {
+        // heartbeat message
+        this.ws.send(JSON.stringify({message:'webplayer state', message_details: msg_details}));
+      }
+      setInterval(heartbeatMessage, 5000);
+
+    },
     connectWS() {
       var loc = window.location, new_uri;
       if (loc.protocol === "https:") {
@@ -257,12 +368,14 @@ Vue.component("player", {
 
       this.ws.onopen = function() {
         console.log('websocket connected!');
-        this.ws.send('players');
+        this.createAudioPlayer();
+        data = JSON.stringify({message:'players', message_details: null});
+        this.ws.send(data);
       }.bind(this);
     
       this.ws.onmessage = function(e) {
         var msg = JSON.parse(e.data);
-        if (msg.message == 'player changed')
+        if (msg.message == 'player changed' || msg.message == 'player added')
           {
             Vue.set(this.players, msg.message_details.player_id, msg.message_details);
         }
@@ -271,12 +384,13 @@ Vue.component("player", {
         }
         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);
+        else if (msg.message == 'webplayer command' && msg.message_details.player_id == this.audioPlayerId) {
+          // message for our audio player
+          this.handleAudioPlayerCommand(msg.message_details);
+        }
 
         // select new active player
         // TODO: store previous player in local storage
@@ -309,6 +423,18 @@ Vue.component("player", {
         console.error('Socket encountered error: ', err.message, 'Closing socket');
         this.ws.close();
       }.bind(this);
-    }
+    },
+    throttle (callback, limit) {
+      var wait = false;
+      return function () {
+          if (!wait) {
+          callback.apply(null, arguments);
+          wait = true;
+          setTimeout(function () {
+              wait = false;
+          }, limit);
+          }
+      }
+  }
   }
 })
index dcef414f44e15678d04c1995ec659ece6ec5bd08..14b39713ca71a500db470304018b392a4fdd0b2b 100755 (executable)
         <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>
+        <script src="https://requirejs.org/docs/release/2.3.5/minified/require.js"></script>
+        <script src="./lib/utils.js"></script>
 
         <!-- Vue Pages and Components here -->
+        
         <script src='./pages/home.vue.js'></script>
         <script src='./pages/browse.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>
+        <script src='./app.js'></script>
     </body>
 
 </html>
\ No newline at end of file
diff --git a/music_assistant/web/lib/utils.js b/music_assistant/web/lib/utils.js
new file mode 100644 (file)
index 0000000..e078d26
--- /dev/null
@@ -0,0 +1,63 @@
+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);
+        });
+
+};