]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Handle basic playlist in embed
authorChocobozzz <me@florianbigard.com>
Wed, 5 Aug 2020 07:44:58 +0000 (09:44 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Fri, 7 Aug 2020 06:58:29 +0000 (08:58 +0200)
13 files changed:
client/src/assets/player/images/tick-white.svg
client/src/assets/player/peertube-player-manager.ts
client/src/assets/player/peertube-videojs-typings.ts
client/src/assets/player/playlist/playlist-button.ts [new file with mode: 0644]
client/src/assets/player/playlist/playlist-menu-item.ts [new file with mode: 0644]
client/src/assets/player/playlist/playlist-menu.ts [new file with mode: 0644]
client/src/assets/player/playlist/playlist-plugin.ts [new file with mode: 0644]
client/src/sass/include/_miniature.scss
client/src/sass/include/_mixins.scss
client/src/sass/player/index.scss
client/src/sass/player/playlist.scss [new file with mode: 0644]
client/src/standalone/videos/embed.ts
scripts/i18n/create-custom-files.ts

index d329e6bfb3fa60fbc77bfce2de90af979f0abfb7..8868a2481f0362fc5b97c58241c632aa7df35327 100644 (file)
@@ -1,8 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-    <defs></defs>
-    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
-        <g id="Artboard-4" transform="translate(-356.000000, -115.000000)" stroke="#fff" stroke-width="2">
+    <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
+        <g transform="translate(-356.000000, -115.000000)" stroke="#fff" stroke-width="2">
             <g id="8" transform="translate(356.000000, 115.000000)">
                 <path d="M21,6 L9,18" id="Path-14"></path>
                 <path d="M9,13 L4,18" id="Path-14" transform="translate(6.500000, 15.500000) scale(-1, 1) translate(-6.500000, -15.500000) "></path>
index 6a6d6346276e5ab06ba561c8992e301c694978be..dcfa3a59316f913f23f2c85b5f35ccfc7f8d9c18 100644 (file)
@@ -18,14 +18,21 @@ import './videojs-components/settings-menu-item'
 import './videojs-components/settings-panel'
 import './videojs-components/settings-panel-child'
 import './videojs-components/theater-button'
+import './playlist/playlist-plugin'
 import videojs from 'video.js'
-import { VideoFile } from '@shared/models'
 import { isDefaultLocale } from '@shared/core-utils/i18n'
+import { VideoFile } from '@shared/models'
 import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
 import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
 import { segmentValidatorFactory } from './p2p-media-loader/segment-validator'
 import { getStoredP2PEnabled } from './peertube-player-local-storage'
-import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions } from './peertube-videojs-typings'
+import {
+  P2PMediaLoaderPluginOptions,
+  PlaylistPluginOptions,
+  UserWatching,
+  VideoJSCaption,
+  VideoJSPluginOptions
+} from './peertube-videojs-typings'
 import { TranslationsManager } from './translations-manager'
 import { buildVideoEmbed, buildVideoLink, copyToClipboard, getRtcConfig, isIOS, isSafari } from './utils'
 
@@ -71,6 +78,9 @@ export interface CommonOptions extends CustomizationOptions {
 
   autoplay: boolean
   nextVideo?: Function
+
+  playlist?: PlaylistPluginOptions
+
   videoDuration: number
   enableHotkeys: boolean
   inactivityTimeout: number
@@ -203,6 +213,10 @@ export class PeertubePlayerManager {
       }
     }
 
+    if (commonOptions.playlist) {
+      plugins.playlist = commonOptions.playlist
+    }
+
     if (commonOptions.enableHotkeys === true) {
       PeertubePlayerManager.addHotkeysOptions(plugins)
     }
index 1506a04ac305cec3f620b549008ab96e058f8d84..b72c4b0f94ee4fa4a0da82a5625629e5b1dd4128 100644 (file)
@@ -1,10 +1,11 @@
 import { Config, Level } from 'hls.js'
 import videojs from 'video.js'
-import { VideoFile } from '@shared/models'
+import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models'
 import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin'
 import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
 import { PlayerMode } from './peertube-player-manager'
 import { PeerTubePlugin } from './peertube-plugin'
+import { PlaylistPlugin } from './playlist/playlist-plugin'
 import { EndCardOptions } from './upnext/end-card'
 import { WebTorrentPlugin } from './webtorrent/webtorrent-plugin'
 
@@ -45,6 +46,8 @@ declare module 'video.js' {
     dock (options: { title: string, description: string }): void
 
     upnext (options: Partial<EndCardOptions>): void
+
+    playlist (): PlaylistPlugin
   }
 }
 
@@ -105,6 +108,16 @@ type PeerTubePluginOptions = {
   stopTime: number | string
 }
 
+type PlaylistPluginOptions = {
+  elements: VideoPlaylistElement[]
+
+  playlist: VideoPlaylist
+
+  getCurrentPosition: () => number
+
+  onItemClicked: (element: VideoPlaylistElement) => void
+}
+
 type WebtorrentPluginOptions = {
   playerElement: HTMLVideoElement
 
@@ -125,6 +138,8 @@ type P2PMediaLoaderPluginOptions = {
 }
 
 type VideoJSPluginOptions = {
+  playlist?: PlaylistPluginOptions
+
   peertube: PeerTubePluginOptions
 
   webtorrent?: WebtorrentPluginOptions
@@ -170,10 +185,18 @@ type PlayerNetworkInfo = {
   }
 }
 
+type PlaylistItemOptions = {
+  element: VideoPlaylistElement
+
+  onClicked: Function
+}
+
 export {
   PlayerNetworkInfo,
+  PlaylistItemOptions,
   ResolutionUpdateData,
   AutoResolutionUpdateData,
+  PlaylistPluginOptions,
   VideoJSCaption,
   UserWatching,
   PeerTubePluginOptions,
diff --git a/client/src/assets/player/playlist/playlist-button.ts b/client/src/assets/player/playlist/playlist-button.ts
new file mode 100644 (file)
index 0000000..a7996ec
--- /dev/null
@@ -0,0 +1,61 @@
+import videojs from 'video.js'
+import { PlaylistPluginOptions } from '../peertube-videojs-typings'
+import { PlaylistMenu } from './playlist-menu'
+
+const ClickableComponent = videojs.getComponent('ClickableComponent')
+
+class PlaylistButton extends ClickableComponent {
+  private playlistInfoElement: HTMLElement
+  private wrapper: HTMLElement
+
+  constructor (player: videojs.Player, options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu }) {
+    super(player, options as any)
+  }
+
+  createEl () {
+    this.wrapper = super.createEl('div', {
+      className: 'vjs-playlist-button',
+      innerHTML: '',
+      tabIndex: -1
+    }) as HTMLElement
+
+    const icon = super.createEl('div', {
+      className: 'vjs-playlist-icon',
+      innerHTML: '',
+      tabIndex: -1
+    })
+
+    this.playlistInfoElement = super.createEl('div', {
+      className: 'vjs-playlist-info',
+      innerHTML: '',
+      tabIndex: -1
+    }) as HTMLElement
+
+    this.wrapper.appendChild(icon)
+    this.wrapper.appendChild(this.playlistInfoElement)
+
+    this.update()
+
+    return this.wrapper
+  }
+
+  update () {
+    const options = this.options_ as PlaylistPluginOptions
+
+    this.playlistInfoElement.innerHTML = options.getCurrentPosition() + '/' + options.playlist.videosLength
+    this.wrapper.title = this.player().localize('Playlist: {1}', [ options.playlist.displayName ])
+  }
+
+  handleClick () {
+    const playlistMenu = this.getPlaylistMenu()
+    playlistMenu.open()
+  }
+
+  private getPlaylistMenu () {
+    return (this.options_ as any).playlistMenu as PlaylistMenu
+  }
+}
+
+videojs.registerComponent('PlaylistButton', PlaylistButton)
+
+export { PlaylistButton }
diff --git a/client/src/assets/player/playlist/playlist-menu-item.ts b/client/src/assets/player/playlist/playlist-menu-item.ts
new file mode 100644 (file)
index 0000000..916c633
--- /dev/null
@@ -0,0 +1,98 @@
+import videojs from 'video.js'
+import { VideoPlaylistElement } from '@shared/models'
+import { PlaylistItemOptions } from '../peertube-videojs-typings'
+
+const Component = videojs.getComponent('Component')
+
+class PlaylistMenuItem extends Component {
+  private element: VideoPlaylistElement
+
+  constructor (player: videojs.Player, options?: PlaylistItemOptions) {
+    super(player, options as any)
+
+    this.emitTapEvents()
+
+    this.element = options.element
+
+    this.on([ 'click', 'tap' ], () => this.switchPlaylistItem())
+    this.on('keydown', event => this.handleKeyDown(event))
+  }
+
+  createEl () {
+    const options = this.options_ as PlaylistItemOptions
+
+    const li = super.createEl('li', {
+      className: 'vjs-playlist-menu-item',
+      innerHTML: ''
+    }) as HTMLElement
+
+    const positionBlock = super.createEl('div', {
+      className: 'item-position-block'
+    })
+
+    const position = super.createEl('div', {
+      className: 'item-position',
+      innerHTML: options.element.position
+    })
+
+    const player = super.createEl('div', {
+      className: 'item-player'
+    })
+
+    positionBlock.appendChild(position)
+    positionBlock.appendChild(player)
+
+    li.appendChild(positionBlock)
+
+    const thumbnail = super.createEl('img', {
+      src: window.location.origin + options.element.video.thumbnailPath
+    })
+
+    const infoBlock = super.createEl('div', {
+      className: 'info-block'
+    })
+
+    const title = super.createEl('div', {
+      innerHTML: options.element.video.name,
+      className: 'title'
+    })
+
+    const channel = super.createEl('div', {
+      innerHTML: options.element.video.channel.displayName,
+      className: 'channel'
+    })
+
+    infoBlock.appendChild(title)
+    infoBlock.appendChild(channel)
+
+    li.append(thumbnail)
+    li.append(infoBlock)
+
+    return li
+  }
+
+  setSelected (selected: boolean) {
+    if (selected) this.addClass('vjs-selected')
+    else this.removeClass('vjs-selected')
+  }
+
+  getElement () {
+    return this.element
+  }
+
+  private handleKeyDown (event: KeyboardEvent) {
+    if (event.code === 'Space' || event.code === 'Enter') {
+      this.switchPlaylistItem()
+    }
+  }
+
+  private switchPlaylistItem () {
+    const options = this.options_ as PlaylistItemOptions
+
+    options.onClicked()
+  }
+}
+
+Component.registerComponent('PlaylistMenuItem', PlaylistMenuItem)
+
+export { PlaylistMenuItem }
diff --git a/client/src/assets/player/playlist/playlist-menu.ts b/client/src/assets/player/playlist/playlist-menu.ts
new file mode 100644 (file)
index 0000000..7d7d9e1
--- /dev/null
@@ -0,0 +1,124 @@
+import videojs from 'video.js'
+import { VideoPlaylistElement } from '@shared/models'
+import { PlaylistPluginOptions } from '../peertube-videojs-typings'
+import { PlaylistMenuItem } from './playlist-menu-item'
+
+const Component = videojs.getComponent('Component')
+
+class PlaylistMenu extends Component {
+  private menuItems: PlaylistMenuItem[]
+
+  constructor (player: videojs.Player, options?: PlaylistPluginOptions) {
+    super(player, options as any)
+
+    this.player().on('userinactive', () => {
+      this.close()
+    })
+
+    this.player().on('click', event => {
+      let current = event.target as HTMLElement
+
+      do {
+        if (
+          current.classList.contains('vjs-playlist-menu') ||
+          current.classList.contains('vjs-playlist-button')
+        ) {
+          return
+        }
+
+        current = current.parentElement
+      } while (current)
+
+      this.close()
+    })
+  }
+
+  createEl () {
+    this.menuItems = []
+
+    const options = this.getOptions()
+
+    const menu = super.createEl('div', {
+      className: 'vjs-playlist-menu',
+      innerHTML: '',
+      tabIndex: -1
+    })
+
+    const header = super.createEl('div', {
+      className: 'header'
+    })
+
+    const headerLeft = super.createEl('div')
+
+    const leftTitle = super.createEl('div', {
+      innerHTML: options.playlist.displayName,
+      className: 'title'
+    })
+
+    const leftSubtitle = super.createEl('div', {
+      innerHTML: this.player().localize('By {1}', [ options.playlist.videoChannel.displayName ]),
+      className: 'channel'
+    })
+
+    headerLeft.appendChild(leftTitle)
+    headerLeft.appendChild(leftSubtitle)
+
+    const tick = super.createEl('div', {
+      className: 'cross'
+    })
+    tick.addEventListener('click', () => this.close())
+
+    header.appendChild(headerLeft)
+    header.appendChild(tick)
+
+    const list = super.createEl('ol')
+
+    for (const playlistElement of options.elements) {
+      const item = new PlaylistMenuItem(this.player(), {
+        element: playlistElement,
+        onClicked: () => this.onItemClicked(playlistElement)
+      })
+
+      list.appendChild(item.el())
+
+      this.menuItems.push(item)
+    }
+
+    menu.appendChild(header)
+    menu.appendChild(list)
+
+    return menu
+  }
+
+  update () {
+    const options = this.getOptions()
+
+    this.updateSelected(options.getCurrentPosition())
+  }
+
+  open () {
+    this.player().addClass('playlist-menu-displayed')
+  }
+
+  close () {
+    this.player().removeClass('playlist-menu-displayed')
+  }
+
+  updateSelected (newPosition: number) {
+    for (const item of this.menuItems) {
+      item.setSelected(item.getElement().position === newPosition)
+    }
+  }
+
+  private getOptions () {
+    return this.options_ as PlaylistPluginOptions
+  }
+
+  private onItemClicked (element: VideoPlaylistElement) {
+    this.getOptions().onItemClicked(element)
+  }
+}
+
+Component.registerComponent('PlaylistMenu', PlaylistMenu)
+
+export { PlaylistMenu }
diff --git a/client/src/assets/player/playlist/playlist-plugin.ts b/client/src/assets/player/playlist/playlist-plugin.ts
new file mode 100644 (file)
index 0000000..b69d82e
--- /dev/null
@@ -0,0 +1,35 @@
+import videojs from 'video.js'
+import { PlaylistPluginOptions } from '../peertube-videojs-typings'
+import { PlaylistButton } from './playlist-button'
+import { PlaylistMenu } from './playlist-menu'
+
+const Plugin = videojs.getPlugin('plugin')
+
+class PlaylistPlugin extends Plugin {
+  private playlistMenu: PlaylistMenu
+  private playlistButton: PlaylistButton
+  private options: PlaylistPluginOptions
+
+  constructor (player: videojs.Player, options?: PlaylistPluginOptions) {
+    super(player, options)
+
+    this.options = options
+
+    this.player.ready(() => {
+      player.addClass('vjs-playlist')
+    })
+
+    this.playlistMenu = new PlaylistMenu(player, options)
+    this.playlistButton = new PlaylistButton(player, Object.assign({}, options, { playlistMenu: this.playlistMenu }))
+
+    player.addChild(this.playlistMenu, options)
+    player.addChild(this.playlistButton, options)
+  }
+
+  updateSelected () {
+    this.playlistMenu.updateSelected(this.options.getCurrentPosition())
+  }
+}
+
+videojs.registerPlugin('playlist', PlaylistPlugin)
+export { PlaylistPlugin }
index 976bbf4d68b1b47e5334e5517610a9fe43e72683..97b4c690b1dd96d2dad891661f5352c3273af04e 100644 (file)
@@ -52,18 +52,7 @@ $play-overlay-width: 18px;
     }
 
     .icon {
-      width: 0;
-      height: 0;
-
-      position: absolute;
-      left: 50%;
-      top: 50%;
-      transform: translate(-50%, -50%) scale(0.5);
-
-      border-top: ($play-overlay-height / 2) solid transparent;
-      border-bottom: ($play-overlay-height / 2) solid transparent;
-
-      border-left: $play-overlay-width solid rgba(255, 255, 255, 0.95);
+      @include play-icon($play-overlay-height, $play-overlay-width);
     }
   }
 
index ee2fe04978ace5eeccbc32f4cd252a89df0c928a..e4c2dffa0f0e83794621bb9055ce9a5772b4967f 100644 (file)
     }
   }
 }
+
+@mixin play-icon ($width, $height) {
+  width: 0;
+  height: 0;
+
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%) scale(0.5);
+
+  border-top: ($height / 2) solid transparent;
+  border-bottom: ($height / 2) solid transparent;
+
+  border-left: $width solid rgba(255, 255, 255, 0.95);
+}
index 58ce3ac96874e0d6816a1319dfe4e63c54af7e1a..fe92ce5e0637c7bf9c4c1434fa8da49431d7a515 100644 (file)
@@ -4,4 +4,5 @@
 @import './settings-menu';
 @import './spinner';
 @import './upnext';
-@import './bezels.scss';
\ No newline at end of file
+@import './bezels.scss';
+@import './playlist.scss';
diff --git a/client/src/sass/player/playlist.scss b/client/src/sass/player/playlist.scss
new file mode 100644 (file)
index 0000000..c242acb
--- /dev/null
@@ -0,0 +1,165 @@
+$playlist-menu-width: 350px;
+
+.vjs-playlist-menu {
+  position: absolute;
+  right: 0;
+  height: 100%;
+  width: $playlist-menu-width;
+  background: rgba(0, 0, 0, 0.8);
+  z-index: 101;
+  transition: right 0.2s;
+
+  // Hidden
+  right: -$playlist-menu-width;
+
+  ol {
+    padding: 0;
+    margin: 0;
+  }
+
+  .header {
+    border-bottom: 1px solid $header-border-color;
+    padding: 20px 10px;
+    display: flex;
+    justify-content: space-between;
+
+    .title {
+      font-size: 14px;
+      margin-bottom: 5px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    .channel {
+      font-size: 11px;
+      color: #bfbfbf;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    .cross {
+      cursor: pointer;
+      width: 20px;
+      height: 20px;
+      mask-image: url('#{$assets-path}/images/feather/x.svg');
+      -webkit-mask-image: url('#{$assets-path}/images/feather/x.svg');
+      background-color: white;
+      mask-size: cover;
+      -webkit-mask-size: cover;
+    }
+  }
+}
+
+.playlist-menu-displayed {
+
+  .vjs-playlist-menu {
+    right: 0;
+    display: block;
+  }
+
+  .vjs-playlist-button {
+    display: none;
+  }
+}
+
+@media screen and (max-width: $playlist-menu-width) {
+  .vjs-playlist-menu {
+    width: 100%;
+    min-width: unset;
+    display: none;
+  }
+
+  .playlist-menu-displayed .vjs-playlist-menu {
+    display: block;
+  }
+}
+
+.vjs-playlist-button {
+  font-size: 15px;
+  position: absolute;
+  right: 0;
+  top: 0;
+  z-index: 100;
+  padding: 1em;
+  cursor: pointer;
+}
+
+.vjs-playlist-icon {
+  width: 22px;
+  height: 22px;
+  mask-image: url('#{$assets-path}/images/feather/list.svg');
+  -webkit-mask-image: url('#{$assets-path}/images/feather/list.svg');
+  background-color: white;
+  mask-size: cover;
+  -webkit-mask-size: cover;
+  margin-bottom: 3px;
+}
+
+.vjs-playing.vjs-user-inactive .vjs-playlist-button {
+  opacity: 0;
+
+  transition: opacity 1s;
+}
+
+.vjs-playing.vjs-no-flex.vjs-user-inactive .vjs-playlist-button {
+  display: none;
+}
+
+.vjs-playlist-menu-item {
+  cursor: pointer;
+  display: flex;
+  padding: 10px 0;
+
+  .item-position-block {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 30px;
+  }
+
+  .item-player {
+    display: none;
+
+    @include play-icon(20px, 16px);
+  }
+
+  &.vjs-selected {
+    background-color: rgba(150, 150, 150, 0.3);
+
+    .item-position {
+      display: none;
+    }
+
+    .item-player {
+      display: block;
+    }
+  }
+
+  &:hover {
+    background-color: rgba(150, 150, 150, 0.2);
+  }
+
+  img {
+    width: 80px;
+    height: 40px;
+  }
+
+  .info-block {
+    margin-left: 10px;
+
+    .title {
+      font-size: 13px;
+      margin-bottom: 5px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    .channel {
+      font-size: 11px;
+      color: #bfbfbf;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+  }
+}
index 71bd04e764378dca8cc525deadf1fb2f14598562..17b0ee9ef03053c2da4bd6cd839a519745da097f 100644 (file)
@@ -324,7 +324,11 @@ export class PeerTubeEmbed {
 
     this.currentPlaylistElement = next
 
-    const res = await this.loadVideo(this.currentPlaylistElement.video.uuid)
+    return this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid)
+  }
+
+  private async loadVideoAndBuildPlayer (uuid: string) {
+    const res = await this.loadVideo(uuid)
     if (res === undefined) return
 
     return this.buildVideoPlayer(res.videoResponse, res.captionsPromise)
@@ -386,6 +390,22 @@ export class PeerTubeEmbed {
 
     this.loadParams(videoInfo)
 
+    const playlistPlugin = this.currentPlaylistElement
+      ? {
+        elements: this.playlistElements,
+        playlist: this.playlist,
+
+        getCurrentPosition: () => this.currentPlaylistElement.position,
+
+        onItemClicked: (videoPlaylistElement: VideoPlaylistElement) => {
+          this.currentPlaylistElement = videoPlaylistElement
+
+          this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid)
+            .catch(err => console.error(err))
+        }
+      }
+      : undefined
+
     const options: PeertubePlayerManagerOptions = {
       common: {
         // Autoplay in playlist mode
@@ -399,6 +419,7 @@ export class PeerTubeEmbed {
         subtitle: this.subtitle,
 
         nextVideo: () => this.autoplayNext(),
+        playlist: playlistPlugin,
 
         videoCaptions,
         inactivityTimeout: 2500,
@@ -452,6 +473,7 @@ export class PeerTubeEmbed {
 
     if (this.isPlaylistEmbed()) {
       await this.buildPlaylistManager()
+      this.player.playlist().updateSelected()
     }
   }
 
@@ -480,10 +502,7 @@ export class PeerTubeEmbed {
       videoId = this.getResourceId()
     }
 
-    const res = await this.loadVideo(videoId)
-    if (res === undefined) return
-
-    return this.buildVideoPlayer(res.videoResponse, res.captionsPromise)
+    return this.loadVideoAndBuildPlayer(videoId)
   }
 
   private handleError (err: Error, translations?: { [ id: string ]: string }) {
index 298eda71b4fd8c53f6196530ca0d072d296a3e3d..89a967b148b194f96037af449f45b91c7aaceffb 100755 (executable)
@@ -50,7 +50,9 @@ values(VIDEO_CATEGORIES)
     'Sorry',
     'This video is not available because the remote instance is not responding.',
     'This playlist does not exist',
-    'We cannot fetch the playlist. Please try again later.'
+    'We cannot fetch the playlist. Please try again later.',
+    'Playlist: {1}',
+    'By {1}'
   ])
   .forEach(v => { serverKeys[v] = v })