From 077b2f442c6c369c5b77850cc891651abd98eb2f Mon Sep 17 00:00:00 2001
From: uhensler <urs.hensler@frentix.com>
Date: Tue, 17 Dec 2019 07:28:04 +0100
Subject: [PATCH] OO-4357: Configuration of multiple stream URLs per event

---
 .../nodes/livestream/LiveStreamModule.java    |  17 ++
 .../manager/LiveStreamServiceImpl.java        | 150 -----------------
 .../livestream/paella/PaellaFactory.java      |  45 +++++-
 .../nodes/livestream/paella/PaellaMapper.java | 152 +++++++++++++++---
 .../nodes/livestream/paella/Stream.java       |  49 ++++++
 .../nodes/livestream/paella/Streams.java      |  40 +++++
 .../ui/LiveStreamAdminController.java         |  10 ++
 .../livestream/ui/LiveStreamUIFactory.java    |  14 ++
 .../ui/LiveStreamVideoController.java         |   6 +-
 .../ui/_i18n/LocalStrings_de.properties       |   2 +
 .../ui/_i18n/LocalStrings_en.properties       |   2 +
 .../resources/serviceconfig/olat.properties   |   2 +
 .../static/js/paella/openolat/config.json     | 151 -----------------
 13 files changed, 313 insertions(+), 327 deletions(-)
 delete mode 100644 src/main/java/org/olat/course/nodes/livestream/manager/LiveStreamServiceImpl.java
 create mode 100644 src/main/java/org/olat/course/nodes/livestream/paella/Stream.java
 create mode 100644 src/main/java/org/olat/course/nodes/livestream/paella/Streams.java
 delete mode 100644 src/main/webapp/static/js/paella/openolat/config.json

diff --git a/src/main/java/org/olat/course/nodes/livestream/LiveStreamModule.java b/src/main/java/org/olat/course/nodes/livestream/LiveStreamModule.java
index 7dd830efb80..cf91567e796 100644
--- a/src/main/java/org/olat/course/nodes/livestream/LiveStreamModule.java
+++ b/src/main/java/org/olat/course/nodes/livestream/LiveStreamModule.java
@@ -37,12 +37,15 @@ import org.springframework.stereotype.Service;
 public class LiveStreamModule extends AbstractSpringModule implements ConfigOnOff {
 
 	public static final String LIVE_STREAM_ENABLED = "live.stream.enabled";
+	public static final String LIVE_STREAM_URL_SEPARATOR = "live.stream.url.separator";
 	public static final String LIVE_STREAM_BUFFER_BEFORE_MIN = "live.stream.buffer.before.min";
 	public static final String LIVE_STREAM_BUFFER_AFTER_MIN = "live.stream.buffer.after.min";
 	public static final String LIVE_STREAM_EDIT_COACH = "live.stream.edit.coach";
 
 	@Value("${live.stream.enabled:false}")
 	private boolean enabled;
+	@Value("${live.stream.url.separator:,}")
+	private String urlSeparator;
 	@Value("${live.stream.buffer.before.min:5}")
 	private int bufferBeforeMin;
 	@Value("${live.stream.buffer.after.min:5}")
@@ -62,6 +65,11 @@ public class LiveStreamModule extends AbstractSpringModule implements ConfigOnOf
 			enabled = "true".equals(enabledObj);
 		}
 		
+		String urlSeparatorObj = getStringPropertyValue(LIVE_STREAM_URL_SEPARATOR, true);
+		if(StringHelper.containsNonWhitespace(urlSeparatorObj)) {
+			urlSeparator = urlSeparatorObj;
+		}
+		
 		String bufferBeforeMinObj = getStringPropertyValue(LIVE_STREAM_BUFFER_BEFORE_MIN, true);
 		if(StringHelper.containsNonWhitespace(bufferBeforeMinObj)) {
 			bufferAfterMin = Integer.parseInt(bufferBeforeMinObj);
@@ -93,6 +101,15 @@ public class LiveStreamModule extends AbstractSpringModule implements ConfigOnOf
 		setStringProperty(LIVE_STREAM_ENABLED, Boolean.toString(enabled), true);
 	}
 
+	public String getUrlSeparator() {
+		return urlSeparator;
+	}
+
+	public void setUrlSeparator(String urlSeparator) {
+		this.urlSeparator = urlSeparator;
+		setStringProperty(LIVE_STREAM_URL_SEPARATOR, urlSeparator, true);
+	}
+
 	public int getBufferBeforeMin() {
 		return bufferBeforeMin;
 	}
diff --git a/src/main/java/org/olat/course/nodes/livestream/manager/LiveStreamServiceImpl.java b/src/main/java/org/olat/course/nodes/livestream/manager/LiveStreamServiceImpl.java
deleted file mode 100644
index 5b7a52b92f5..00000000000
--- a/src/main/java/org/olat/course/nodes/livestream/manager/LiveStreamServiceImpl.java
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * <a href="http://www.openolat.org">
- * OpenOLAT - Online Learning and Training</a><br>
- * <p>
- * Licensed under the Apache License, Version 2.0 (the "License"); <br>
- * you may not use this file except in compliance with the License.<br>
- * You may obtain a copy of the License at the
- * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a>
- * <p>
- * Unless required by applicable law or agreed to in writing,<br>
- * software distributed under the License is distributed on an "AS IS" BASIS, <br>
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
- * See the License for the specific language governing permissions and <br>
- * limitations under the License.
- * <p>
- * Initial code contributed and copyrighted by<br>
- * frentix GmbH, http://www.frentix.com
- * <p>
- */
-package org.olat.course.nodes.livestream.manager;
-
-import java.util.ArrayList;
-import java.util.Calendar;
-import java.util.Date;
-import java.util.List;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.ThreadFactory;
-import java.util.function.Predicate;
-import java.util.stream.Collectors;
-
-import org.olat.commons.calendar.CalendarManager;
-import org.olat.commons.calendar.CalendarUtils;
-import org.olat.commons.calendar.model.KalendarEvent;
-import org.olat.commons.calendar.ui.components.KalendarRenderWrapper;
-import org.olat.course.nodes.cal.CourseCalendars;
-import org.olat.course.nodes.livestream.LiveStreamEvent;
-import org.olat.course.nodes.livestream.LiveStreamService;
-import org.olat.course.nodes.livestream.model.LiveStreamEventImpl;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
-import org.springframework.stereotype.Service;
-
-/**
- * 
- * Initial date: 28 May 2019<br>
- * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
- *
- */
-@Service
-public class LiveStreamServiceImpl implements LiveStreamService {
-	
-	private ScheduledExecutorService scheduler;
-	
-	@Autowired
-	private CalendarManager calendarManager;
-
-	@Override
-	public ScheduledExecutorService getScheduler() {
-		if (scheduler == null) {
-			ThreadFactory threadFactory = new CustomizableThreadFactory("oo-livestream-");
-			scheduler = Executors.newScheduledThreadPool(1, threadFactory);
-		}
-		return scheduler;
-	}
-
-	@Override
-	public List<? extends LiveStreamEvent> getRunningEvents(CourseCalendars calendars, int bufferBeforeMin,
-			int bufferAfterMin) {
-		Date now = new Date();
-		
-		Calendar cFrom = Calendar.getInstance();
-		cFrom.setTime(now);
-		cFrom.add(Calendar.MINUTE, -bufferAfterMin);
-		Date from = cFrom.getTime();
-		
-		Calendar cTo = Calendar.getInstance();
-		cTo.setTime(now);
-		cTo.add(Calendar.MINUTE, bufferBeforeMin);
-		Date to = cTo.getTime();
-		
-		return getLiveStreamEvents(calendars, from, to);
-	}
-	
-	@Override
-	public List<? extends LiveStreamEvent> getUpcomingEvents(CourseCalendars calendars, int bufferBeforeMin) {
-		Date now = new Date();
-		Calendar cFrom = Calendar.getInstance();
-		cFrom.setTime(now);
-		cFrom.add(Calendar.MINUTE, bufferBeforeMin);
-		Date from = cFrom.getTime();
-		Calendar cTo = Calendar.getInstance();
-		cTo.setTime(now);
-		cTo.add(Calendar.YEAR, 10);
-		Date to = cTo.getTime();
-		
-		return getLiveStreamEvents(calendars, from, to).stream()
-				.filter(notStartedFilter(from))
-				.collect(Collectors.toList());
-	}
-
-	private Predicate<LiveStreamEvent> notStartedFilter(Date from) {
-		return (LiveStreamEvent e) -> {
-			return !e.getBegin().before(from);
-			};
-	}
-
-	private List<? extends LiveStreamEvent> getLiveStreamEvents(CourseCalendars calendars, Date from, Date to) {
-		List<LiveStreamEvent> liveStreamEvents = new ArrayList<>();
-		for (KalendarRenderWrapper cal : calendars.getCalendars()) {
-			if(cal != null) {
-				boolean privateEventsVisible = cal.isPrivateEventsVisible();
-				List<KalendarEvent> events = calendarManager.getEvents(cal.getKalendar(), from, to, privateEventsVisible);
-				for(KalendarEvent event:events) {
-					if(!privateEventsVisible && event.getClassification() == KalendarEvent.CLASS_PRIVATE) {
-						continue;
-					}
-					
-					if (isLiveStream(event)) {
-						boolean timeOnly = !privateEventsVisible && event.getClassification() == KalendarEvent.CLASS_X_FREEBUSY;
-						LiveStreamEventImpl liveStreamEvent = toLiveStreamEvent(event, timeOnly);
-						liveStreamEvents.add(liveStreamEvent);
-					};
-				}
-			}
-		}
-		
-		return liveStreamEvents;
-	}
-	
-	private boolean isLiveStream(KalendarEvent event) {
-		return event.getLiveStreamUrl() != null;
-	}
-
-	private LiveStreamEventImpl toLiveStreamEvent(KalendarEvent event, boolean timeOnly) {
-		LiveStreamEventImpl liveStreamEvent = new LiveStreamEventImpl();
-		liveStreamEvent.setId(event.getID());
-		liveStreamEvent.setAllDayEvent(event.isAllDayEvent());
-		liveStreamEvent.setBegin(event.getBegin());
-		Date end = CalendarUtils.endOf(event);
-		liveStreamEvent.setEnd(end);
-		liveStreamEvent.setLiveStreamUrl(event.getLiveStreamUrl());
-		if (!timeOnly) {
-			liveStreamEvent.setSubject(event.getSubject());
-			liveStreamEvent.setDescription(event.getDescription());
-			liveStreamEvent.setLocation(event.getLocation());
-		}
-		return liveStreamEvent;
-	}
-}
diff --git a/src/main/java/org/olat/course/nodes/livestream/paella/PaellaFactory.java b/src/main/java/org/olat/course/nodes/livestream/paella/PaellaFactory.java
index 4722da981c0..af8c7f3b90a 100644
--- a/src/main/java/org/olat/course/nodes/livestream/paella/PaellaFactory.java
+++ b/src/main/java/org/olat/course/nodes/livestream/paella/PaellaFactory.java
@@ -19,7 +19,12 @@
  */
 package org.olat.course.nodes.livestream.paella;
 
+import java.util.ArrayList;
+import java.util.List;
+
+import org.olat.core.CoreSpringFactory;
 import org.olat.core.util.StringHelper;
+import org.olat.course.nodes.livestream.LiveStreamModule;
 
 /**
  * 
@@ -29,15 +34,49 @@ import org.olat.core.util.StringHelper;
  */
 public class PaellaFactory {
 	
-	public static Sources createSources(String url) {
+	public static Streams createStreams(String url) {
+		Streams streams = new Streams();
+		if (StringHelper.containsNonWhitespace(url)) {
+			String[] urls = splitUrls(url);
+			addStreams(streams, urls);
+		}
+		return streams;
+	}
+
+	private static String[] splitUrls(String url) {
+		String urlSeparator = CoreSpringFactory.getImpl(LiveStreamModule.class).getUrlSeparator();
+		return url.split(urlSeparator);
+	}
+	
+	private static void addStreams(Streams streams, String[] urls) {
+		List<Stream> streamList = new ArrayList<>(2);
+		if (urls.length > 0) {
+			Stream stream1 = createStream("stream1", urls[0]);
+			streamList.add(stream1);
+		}
+		if (urls.length > 1) {
+			Stream stream1 = createStream("stream2", urls[1]);
+			streamList.add(stream1);
+		}
+		Stream[] streamArray = streamList.toArray(new Stream[streamList.size()]);
+		streams.setStreams(streamArray);
+	}
+
+	private static Stream createStream(String content, String url) {
+		Stream stream = new Stream();
+		stream.setContent(content);
+		Sources sources = createSources(url);
+		stream.setSources(sources);
+		return stream;
+	}
+
+	private static Sources createSources(String url) {
 		Sources sources = new Sources();
 		addSource(sources, url);
 		return sources;
 	}
 	
 	private static void addSource(Sources sources, String url) {
-		if (!StringHelper.containsNonWhitespace(url)) return;
-			
 		String suffix = getSuffix(url);
 		if (suffix == null) return;
 	
diff --git a/src/main/java/org/olat/course/nodes/livestream/paella/PaellaMapper.java b/src/main/java/org/olat/course/nodes/livestream/paella/PaellaMapper.java
index 9614911f0d2..2593b785fa1 100644
--- a/src/main/java/org/olat/course/nodes/livestream/paella/PaellaMapper.java
+++ b/src/main/java/org/olat/course/nodes/livestream/paella/PaellaMapper.java
@@ -48,10 +48,10 @@ public class PaellaMapper implements Mapper {
 	
 	private final ObjectMapper mapper = new ObjectMapper();
 	
-	private final Sources sources;
+	private final Streams streams;
 	
-	public PaellaMapper(Sources sources) {
-		this.sources = sources;
+	public PaellaMapper(Streams streams) {
+		this.streams = streams;
 	}
 
 	@Override
@@ -85,19 +85,11 @@ public class PaellaMapper implements Mapper {
 		appendStaticCSS(sb, "js/paella/player/resources/style/style_dark.css");
 		sb.append("</head>");
 		sb.append("<body id=\"body\" onload=\"paella.load('playerContainer', {");
-		sb.append(" configUrl: '");
-		apendConfigUrl(sb);
-		sb.append("',");
+		sb.append(" config: ");
+		appendPlayerConfig(sb);
+		sb.append(" ,");
 		sb.append(" data:");
-		sb.append("{");
-		
-		sb.append("'streams': [{");
-		sb.append("   'sources' : ");
-		sb.append(objectToJson(sources));
-		sb.append(",  'content': 'stream content'");
-		sb.append("}] ");
-		
-		sb.append("}");
+		sb.append(objectToJson(streams));
 		sb.append("}");
 		sb.append(");\">");
 		sb.append("<div id=\"playerContainer\" style=\"display:block;width:100%\">");
@@ -109,11 +101,6 @@ public class PaellaMapper implements Mapper {
 		log.debug(html);
 		return html;
 	}
-	
-
-	private void apendConfigUrl(StringOutput sb) {
-		StaticMediaDispatcher.renderStaticURI(sb, "js/paella/openolat/config.json");
-	}
 
 	private void appendStaticJs(StringOutput sb, String javascript) {
 		sb.append("<script src=\"");
@@ -127,6 +114,131 @@ public class PaellaMapper implements Mapper {
 		sb.append("\"></link>");
 	}
 	
+	private void appendPlayerConfig(StringOutput sb) {
+		sb.append("{");
+		sb.append("  'player':{");
+		sb.append("    'accessControlClass':'paella.AccessControl',");
+		sb.append("        'profileFrameStrategy': 'paella.ProfileFrameStrategy',");
+		sb.append("    'videoQualityStrategy': 'paella.LimitedBestFitVideoQualityStrategy',");
+		sb.append("    'videoQualityStrategyParams':{ 'maxAutoQualityRes':720 },");
+		sb.append("    'reloadOnFullscreen': true,");
+		sb.append("    'videoZoom': {");
+		sb.append("      'enabled':false,");
+		sb.append("      'max':800");
+		sb.append("    },");
+		sb.append("    'deprecated-methods':[{'name':'streaming','enabled':true},");
+		sb.append("           {'name':'html','enabled':true},");
+		sb.append("           {'name':'flash','enabled':true},");
+		sb.append("		   {'name':'image','enabled':true}],");
+		sb.append("    'methods':[");
+		sb.append("      { 'factory':'ChromaVideoFactory', 'enabled':true },");
+		sb.append("      { 'factory':'WebmVideoFactory', 'enabled':true },");
+		sb.append("      { 'factory':'Html5VideoFactory', 'enabled':true },");
+		sb.append("      { 'factory':'MpegDashVideoFactory', 'enabled':true },");
+		sb.append("      {");
+		sb.append("        'factory':'HLSVideoFactory',");
+		sb.append("        'enabled':true,");
+		sb.append("        'config': {");
+		sb.append("          'maxBufferLength': 30,");
+		sb.append("		  'maxMaxBufferLength': 600,");
+		sb.append("		  'maxBufferSize': 60000000,");
+		sb.append("		  'maxBufferHole': 0.5,");
+		sb.append("		  'lowBufferWatchdogPeriod': 0.5,");
+		sb.append("          'highBufferWatchdogPeriod': 3");
+		sb.append("        },");
+		sb.append("        'iOSMaxStreams': 2,");
+		sb.append("        'androidMaxStreams': 2");
+		sb.append("      }");
+		sb.append("    ],");
+		sb.append("    'audioMethods':[");
+		sb.append("      { 'factory':'MultiformatAudioFactory', 'enabled':true }");
+		sb.append("    ],");
+		sb.append("    'defaultAudioTag': '',");
+		sb.append("    'slidesMarks':{");
+		sb.append("      'enabled':true,");
+		sb.append("      'color':'gray'");
+		sb.append("    }");
+		sb.append("  },");
+		sb.append("  'data':{");
+		sb.append("    'enabled':true,");
+		sb.append("    'dataDelegates':{");
+		sb.append("      'trimming':'CookieDataDelegate',");
+		sb.append("      'metadata':'VideoManifestMetadataDataDelegate',");
+		sb.append("      'cameraTrack':'TrackCameraDataDelegate'");
+		sb.append("    }");
+		sb.append("  },");
+		sb.append("  'folders': {");
+		sb.append("    'profiles': 'config/profiles',");
+		sb.append("    'resources': '");
+		StaticMediaDispatcher.renderStaticURI(sb, "js/paella/player/resources");
+		sb.append("',");
+		sb.append("    'skins': 'resources/style'");
+		sb.append("  },");
+		sb.append("  'experimental':{");
+		sb.append("    'autoplay':true");
+		sb.append("  },");
+		sb.append("  'plugins':{");
+		sb.append("    'enablePluginsByDefault': false,");
+		sb.append("    'list':{");
+		sb.append("      'edu.harvard.dce.paella.flexSkipPlugin': {'enabled':true, 'direction': 'Rewind', 'seconds': 10, 'minWindowSize': 510 },");
+		sb.append("      'edu.harvard.dce.paella.flexSkipForwardPlugin': {'enabled':true, 'direction': 'Forward', 'seconds': 30},");
+		sb.append("      'es.upv.paella.captionsPlugin': {'enabled':true, 'searchOnCaptions':true},");
+		sb.append("      'es.upv.paella.frameControlPlugin':  {'enabled': true, 'showFullPreview': 'auto', 'showCaptions':true, 'minWindowSize': 450 },");
+		sb.append("      'es.upv.paella.fullScreenButtonPlugin': {'enabled':true, 'reloadOnFullscreen':{ 'enabled':true, 'keepUserSelection':true }},");
+		sb.append("      'es.upv.paella.playPauseButtonPlugin': {'enabled':true},");
+		sb.append("      'es.upv.paella.themeChooserPlugin':  {'enabled':true, 'minWindowSize': 600},");
+		sb.append("      'es.upv.paella.viewModePlugin': { 'enabled': true, 'minWindowSize': 300 },");
+		sb.append("      'es.upv.paella.volumeRangePlugin':{'enabled':true, 'showMasterVolume': true, 'showSlaveVolume': false },");
+		sb.append("      'es.upv.paella.pipModePlugin': { 'enabled':true },");
+		sb.append("      'es.upv.paella.audioSelector': { 'enabled':true, 'minWindowSize': 400 },");
+		sb.append("      'es.upv.paella.airPlayPlugin': { 'enabled':true },");
+		sb.append("      'es.upv.paella.liveStreamingIndicatorPlugin':  { 'enabled': true },");
+		sb.append("      'es.upv.paella.showEditorPlugin':{'enabled':true,'alwaysVisible':true},");
+		sb.append("      'es.upv.paella.videoDataPlugin': {");
+		sb.append("        'enabled': true,");
+		sb.append("        'excludeLocations':[");
+		sb.append("          'paellaplayer.upv.es'");
+		sb.append("        ],");
+		sb.append("        'excludeParentLocations':[");
+		sb.append("          'localhost:8000'");
+		sb.append("        ]");
+		sb.append("      },");
+		sb.append("      'es.upv.paella.blackBoardPlugin': {'enabled': true},");
+		sb.append("      'es.upv.paella.breaksPlayerPlugin': {'enabled': true},");
+		sb.append("      'es.upv.paella.overlayCaptionsPlugin': {'enabled': true},");
+		sb.append("      'es.upv.paella.playButtonOnScreenPlugin': {'enabled':true},");
+		sb.append("      'es.upv.paella.translecture.captionsPlugin': {'enabled':true},");
+		sb.append("      'es.upv.paella.trimmingPlayerPlugin': {'enabled':true},");
+		sb.append("      'es.upv.paella.windowTitlePlugin': {'enabled': true},");
+		sb.append("      'es.upv.paella.singleStreamProfilePlugin': {");
+		sb.append("          'enabled': true,");
+		sb.append("          'videoSets': [");
+		sb.append("            { 'icon':'professor_icon.svg', 'id':'professor', 'content':['stream1']},");
+		sb.append("            { 'icon':'slide_icon.svg', 'id':'slide', 'content':['stream2']}");
+		sb.append("          ]");
+		sb.append("      },");
+		sb.append("      'es.upv.paella.dualStreamProfilePlugin': { 'enabled':true,");
+		sb.append("        'videoSets': [");
+		sb.append("          { 'icon':'slide_professor_icon.svg', 'id':'slide_over_professor', 'content':['stream1','stream2'] }");
+		sb.append("        ]");
+		sb.append("      }");
+		sb.append("    }");
+		sb.append("  },");
+		sb.append("  'defaultProfile':'presenter_presentation',");
+		sb.append("  'standalone' : {");
+		sb.append("    'repository': '../repository/'");
+		sb.append("  },");
+		sb.append("  'skin': {");
+		sb.append("    'available': [");
+		sb.append("      'dark',");
+		sb.append("      'dark_small',");
+		sb.append("      'light',");
+		sb.append("      'light_small'");
+		sb.append("    ]");
+		sb.append("  }");
+		sb.append("}");
+	}
+	
 	private String objectToJson(Object o)  {
 		String json = null;
 		try {
diff --git a/src/main/java/org/olat/course/nodes/livestream/paella/Stream.java b/src/main/java/org/olat/course/nodes/livestream/paella/Stream.java
new file mode 100644
index 00000000000..f3adc58067c
--- /dev/null
+++ b/src/main/java/org/olat/course/nodes/livestream/paella/Stream.java
@@ -0,0 +1,49 @@
+/**
+ * <a href="http://www.openolat.org">
+ * OpenOLAT - Online Learning and Training</a><br>
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License"); <br>
+ * you may not use this file except in compliance with the License.<br>
+ * You may obtain a copy of the License at the
+ * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a>
+ * <p>
+ * Unless required by applicable law or agreed to in writing,<br>
+ * software distributed under the License is distributed on an "AS IS" BASIS, <br>
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
+ * See the License for the specific language governing permissions and <br>
+ * limitations under the License.
+ * <p>
+ * Initial code contributed and copyrighted by<br>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.course.nodes.livestream.paella;
+
+/**
+ * 
+ * Initial date: 16 Dec 2019<br>
+ * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
+ *
+ */
+public class Stream {
+	
+	private Sources sources;
+	private String content;
+	
+	public Sources getSources() {
+		return sources;
+	}
+
+	public void setSources(Sources sources) {
+		this.sources = sources;
+	}
+
+	public String getContent() {
+		return content;
+	}
+	
+	public void setContent(String content) {
+		this.content = content;
+	}
+
+}
diff --git a/src/main/java/org/olat/course/nodes/livestream/paella/Streams.java b/src/main/java/org/olat/course/nodes/livestream/paella/Streams.java
new file mode 100644
index 00000000000..ea4d64f808d
--- /dev/null
+++ b/src/main/java/org/olat/course/nodes/livestream/paella/Streams.java
@@ -0,0 +1,40 @@
+/**
+ * <a href="http://www.openolat.org">
+ * OpenOLAT - Online Learning and Training</a><br>
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License"); <br>
+ * you may not use this file except in compliance with the License.<br>
+ * You may obtain a copy of the License at the
+ * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a>
+ * <p>
+ * Unless required by applicable law or agreed to in writing,<br>
+ * software distributed under the License is distributed on an "AS IS" BASIS, <br>
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
+ * See the License for the specific language governing permissions and <br>
+ * limitations under the License.
+ * <p>
+ * Initial code contributed and copyrighted by<br>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.course.nodes.livestream.paella;
+
+/**
+ * 
+ * Initial date: 16 Dec 2019<br>
+ * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
+ *
+ */
+public class Streams {
+	
+	private Stream[] streams;
+
+	public Stream[] getStreams() {
+		return streams;
+	}
+
+	public void setStreams(Stream[] streams) {
+		this.streams = streams;
+	}
+
+}
diff --git a/src/main/java/org/olat/course/nodes/livestream/ui/LiveStreamAdminController.java b/src/main/java/org/olat/course/nodes/livestream/ui/LiveStreamAdminController.java
index 3acd268aa8b..3542060814b 100644
--- a/src/main/java/org/olat/course/nodes/livestream/ui/LiveStreamAdminController.java
+++ b/src/main/java/org/olat/course/nodes/livestream/ui/LiveStreamAdminController.java
@@ -21,6 +21,7 @@ package org.olat.course.nodes.livestream.ui;
 
 import static org.olat.core.gui.translator.TranslatorHelper.translateAll;
 import static org.olat.course.nodes.livestream.ui.LiveStreamUIFactory.validateInteger;
+import static org.olat.course.nodes.livestream.ui.LiveStreamUIFactory.validateMandatory;
 
 import org.olat.core.gui.UserRequest;
 import org.olat.core.gui.components.form.flexible.FormItemContainer;
@@ -44,6 +45,7 @@ public class LiveStreamAdminController extends FormBasicController {
 	private static final String[] ENABLED_KEYS = new String[]{"on"};
 	
 	private MultipleSelectionElement enabledEl;
+	private TextElement urlSeparatorEl;
 	private TextElement bufferBeforeMinEl;
 	private TextElement bufferAfterMinEl;
 	private MultipleSelectionElement coachCanEditEl;
@@ -68,6 +70,10 @@ public class LiveStreamAdminController extends FormBasicController {
 				translateAll(getTranslator(), ENABLED_KEYS));
 		enabledEl.select(ENABLED_KEYS[0], liveStreamModule.isEnabled());
 		
+		urlSeparatorEl = uifactory.addTextElement("admin.url.separator", 10, liveStreamModule.getUrlSeparator(), generalCont);
+		urlSeparatorEl.setMandatory(true);
+		urlSeparatorEl.setHelpTextKey("admin.url.separator.help", null);
+		
 		FormLayoutContainer defaultValuesCont = FormLayoutContainer.createDefaultFormLayout("default_values", getTranslator());
 		defaultValuesCont.setFormTitle(translate("admin.default.values.title"));
 		defaultValuesCont.setFormDescription(translate("admin.default.values.desc"));
@@ -99,6 +105,7 @@ public class LiveStreamAdminController extends FormBasicController {
 	protected boolean validateFormLogic(UserRequest ureq) {
 		boolean allOk = super.validateFormLogic(ureq);
 
+		allOk &= validateMandatory(urlSeparatorEl);
 		allOk &= validateInteger(bufferBeforeMinEl, true);
 		allOk &= validateInteger(bufferAfterMinEl, true);
 
@@ -110,6 +117,9 @@ public class LiveStreamAdminController extends FormBasicController {
 		boolean enabled = enabledEl.isAtLeastSelected(1);
 		liveStreamModule.setEnabled(enabled);
 		
+		String urlSeparator = urlSeparatorEl.getValue();
+		liveStreamModule.setUrlSeparator(urlSeparator);
+		
 		int bufferBeforeMin = Integer.parseInt(bufferBeforeMinEl.getValue());
 		liveStreamModule.setBufferBeforeMin(bufferBeforeMin);
 		
diff --git a/src/main/java/org/olat/course/nodes/livestream/ui/LiveStreamUIFactory.java b/src/main/java/org/olat/course/nodes/livestream/ui/LiveStreamUIFactory.java
index 6dbf4e41848..73fd1a6c4e7 100644
--- a/src/main/java/org/olat/course/nodes/livestream/ui/LiveStreamUIFactory.java
+++ b/src/main/java/org/olat/course/nodes/livestream/ui/LiveStreamUIFactory.java
@@ -29,6 +29,20 @@ import org.olat.core.util.StringHelper;
  *
  */
 class LiveStreamUIFactory {
+
+	static boolean validateMandatory(TextElement el) {
+		boolean allOk = true;
+		el.clearError();
+		if(el.isEnabled() && el.isVisible()) {
+			String val = el.getValue();
+			if (!StringHelper.containsNonWhitespace(val)) {
+				el.setErrorKey("form.mandatory.hover", null);
+				allOk = false;
+			}
+		}
+		return allOk;
+	}
+
 	
 	static boolean validateInteger(TextElement el, boolean mandatory) {
 		boolean allOk = true;
diff --git a/src/main/java/org/olat/course/nodes/livestream/ui/LiveStreamVideoController.java b/src/main/java/org/olat/course/nodes/livestream/ui/LiveStreamVideoController.java
index b8c1d4b8fac..9ea0d462e78 100644
--- a/src/main/java/org/olat/course/nodes/livestream/ui/LiveStreamVideoController.java
+++ b/src/main/java/org/olat/course/nodes/livestream/ui/LiveStreamVideoController.java
@@ -36,7 +36,7 @@ import org.olat.core.util.UserSession;
 import org.olat.course.nodes.livestream.LiveStreamEvent;
 import org.olat.course.nodes.livestream.paella.PaellaFactory;
 import org.olat.course.nodes.livestream.paella.PaellaMapper;
-import org.olat.course.nodes.livestream.paella.Sources;
+import org.olat.course.nodes.livestream.paella.Streams;
 import org.springframework.beans.factory.annotation.Autowired;
 
 /**
@@ -73,8 +73,8 @@ public class LiveStreamVideoController extends BasicController {
 	private void updateUI(UserSession usess) {
 		if (StringHelper.containsNonWhitespace(url)) {
 			mainVC.contextPut("id", CodeHelper.getRAMUniqueID());
-			Sources sources = PaellaFactory.createSources(url);
-			PaellaMapper paellaMapper = new PaellaMapper(sources);
+			Streams streams = PaellaFactory.createStreams(url);
+			PaellaMapper paellaMapper = new PaellaMapper(streams);
 			MapperKey mapperKey = mapperService.register(usess, paellaMapper);
 			mappers.add(mapperKey);
 			String baseURI = mapperKey.getUrl();
diff --git a/src/main/java/org/olat/course/nodes/livestream/ui/_i18n/LocalStrings_de.properties b/src/main/java/org/olat/course/nodes/livestream/ui/_i18n/LocalStrings_de.properties
index ae3522b212f..58b6369b9b1 100644
--- a/src/main/java/org/olat/course/nodes/livestream/ui/_i18n/LocalStrings_de.properties
+++ b/src/main/java/org/olat/course/nodes/livestream/ui/_i18n/LocalStrings_de.properties
@@ -7,6 +7,8 @@ admin.general.title=$:\admin.menu.title
 admin.menu.title=Livestream
 admin.menu.title.alt=$:\admin.menu.title
 admin.module.enabled=Kursbaustein
+admin.url.separator=URL Trennzeichen
+admin.url.separator.help=Trennzeichen, um mehrere URLs eines einzelnen Termins zu trennen.
 condition.accessibility.title=Zugang
 config.buffer.before.min=Vorlaufzeit (in Minuten)
 config.buffer.after.min=Nachlaufzeit (in Minuten)
diff --git a/src/main/java/org/olat/course/nodes/livestream/ui/_i18n/LocalStrings_en.properties b/src/main/java/org/olat/course/nodes/livestream/ui/_i18n/LocalStrings_en.properties
index 7abc980a66c..e1560ff39bb 100644
--- a/src/main/java/org/olat/course/nodes/livestream/ui/_i18n/LocalStrings_en.properties
+++ b/src/main/java/org/olat/course/nodes/livestream/ui/_i18n/LocalStrings_en.properties
@@ -7,6 +7,8 @@ admin.general.title=$:\admin.menu.title
 admin.menu.title=Live stream
 admin.menu.title.alt=$:\admin.menu.title
 admin.module.enabled=Course element
+admin.url.separator=URL separator
+admin.url.separator.help=Char / String to separate multiple URLs of a single event.
 condition.accessibility.title=Access
 config.buffer.before.min=Buffer time before start (in minutes)
 config.buffer.after.min=Buffer time after end (in minutes)
diff --git a/src/main/resources/serviceconfig/olat.properties b/src/main/resources/serviceconfig/olat.properties
index e9ccf88cc1a..73b483d66f1 100644
--- a/src/main/resources/serviceconfig/olat.properties
+++ b/src/main/resources/serviceconfig/olat.properties
@@ -1656,6 +1656,8 @@ youtube.api.key=
 # Options for the live stream course node
 ###############################################################################
 live.stream.enabled=false
+# Char / String to separate multiple urls of a single event
+live.stream.url.separator=,
 # Buffer time to switch from the announcement page to the live stream (in minutes)
 # and vice versa.
 live.stream.buffer.before.min=5
diff --git a/src/main/webapp/static/js/paella/openolat/config.json b/src/main/webapp/static/js/paella/openolat/config.json
deleted file mode 100644
index f6452d3f19a..00000000000
--- a/src/main/webapp/static/js/paella/openolat/config.json
+++ /dev/null
@@ -1,151 +0,0 @@
-{
-  "player":{
-    "accessControlClass":"paella.AccessControl",
-        "profileFrameStrategy": "paella.ProfileFrameStrategy",
-    "videoQualityStrategy": "paella.LimitedBestFitVideoQualityStrategy",
-    "videoQualityStrategyParams":{ "maxAutoQualityRes":720 },
-    "reloadOnFullscreen": true,
-    "videoZoom": {
-      "enabled":false,
-      "max":800
-    },
-
-    "deprecated-methods":[{"name":"streaming","enabled":true},
-           {"name":"html","enabled":true},
-           {"name":"flash","enabled":true},
-                   {"name":"image","enabled":true}],
-
-    "methods":[
-      { "factory":"ChromaVideoFactory", "enabled":true },
-      { "factory":"WebmVideoFactory", "enabled":true },
-      { "factory":"Html5VideoFactory", "enabled":true },
-      { "factory":"MpegDashVideoFactory", "enabled":true },
-      {
-        "factory":"HLSVideoFactory",
-        "enabled":true,
-        "config": {
-          "*** You can add more hls.js settings here": "",
-          "https://github.com/video-dev/hls.js/blob/master/docs/API.md": "",
-          "maxBufferLength": 30,
-				  "maxMaxBufferLength": 600,
-				  "maxBufferSize": 60000000,
-				  "maxBufferHole": 0.5,
-				  "lowBufferWatchdogPeriod": 0.5,
-          "highBufferWatchdogPeriod": 3
-        },
-        "iOSMaxStreams": 2,
-        "androidMaxStreams": 2
-      },
-      { "factory":"RTMPVideoFactory", "enabled":true },
-      { "factory":"ImageVideoFactory", "enabled":true },
-      { "factory":"YoutubeVideoFactory", "enabled":true },
-      { "factory":"Video360ThetaFactory", "enabled":true },
-      { "factory":"Video360Factory", "enabled":true }
-    ],
-    "audioMethods":[
-      { "factory":"MultiformatAudioFactory", "enabled":true }
-    ],
-    "defaultAudioTag": "",
-    "slidesMarks":{
-      "enabled":true,
-      "color":"gray"
-    }
-  },
-  "defaultProfile":"presenter_presentation",
-  "data":{
-    "enabled":true,
-    "dataDelegates":{
-      "trimming":"CookieDataDelegate",
-      "metadata":"VideoManifestMetadataDataDelegate",
-      "cameraTrack":"TrackCameraDataDelegate"
-    }
-  },
-  "folders": {
-    "profiles": "config/profiles",
-    "resources": "resources",
-    "skins": "resources/style"
-  },
-  "experimental":{
-    "autoplay":true
-  },
-  "plugins":{
-    "enablePluginsByDefault": false,
-
-    "//**** Instructions: Disable any individual plugin by setting its enable property to false": {"enabled": false},
-    "//**** For a list of available plugins and configuration, go to": "https://github.com/polimediaupv/paella/blob/master/doc/plugins.md",
-    "list":{
-      "//******* Button plugins": "",
-      "edu.harvard.dce.paella.flexSkipPlugin": {"enabled":true, "direction": "Rewind", "seconds": 10, "minWindowSize": 510 },
-      "edu.harvard.dce.paella.flexSkipForwardPlugin": {"enabled":true, "direction": "Forward", "seconds": 30},
-      "es.upv.paella.captionsPlugin": {"enabled":true, "searchOnCaptions":true},
-      "es.upv.paella.frameControlPlugin":  {"enabled": true, "showFullPreview": "auto", "showCaptions":true, "minWindowSize": 450 },
-      "es.upv.paella.fullScreenButtonPlugin": {"enabled":true, "reloadOnFullscreen":{ "enabled":true, "keepUserSelection":true }},
-      "es.upv.paella.multipleQualitiesPlugin": {"enabled":true, "showWidthRes":true, "minWindowSize": 550 },
-      "es.upv.paella.playPauseButtonPlugin": {"enabled":true},
-      "es.upv.paella.themeChooserPlugin":  {"enabled":true, "minWindowSize": 600},
-      "es.upv.paella.viewModePlugin": { "enabled": true, "minWindowSize": 300 },
-      "es.upv.paella.volumeRangePlugin":{"enabled":true, "showMasterVolume": true, "showSlaveVolume": false },
-      "es.upv.paella.pipModePlugin": { "enabled":true },
-      "es.upv.paella.audioSelector": { "enabled":true, "minWindowSize": 400 },
-      "es.upv.paella.airPlayPlugin": { "enabled":true },
-
-      "//***** Video Overlay Button Plugins": "",
-      "es.upv.paella.liveStreamingIndicatorPlugin":  { "enabled": true },
-      "es.upv.paella.showEditorPlugin":{"enabled":true,"alwaysVisible":true},
-      "es.upv.paella.arrowSlidesNavigatorPlugin": {"enabled": true, "content":["presentation","presenter"] },
-      "es.upv.paella.videoDataPlugin": {
-        "enabled": true,
-        "excludeLocations":[
-          "paellaplayer.upv.es"
-        ],
-        "excludeParentLocations":[
-          "localhost:8000"
-        ]
-      },
-
-      "//**** Event Driven Plugins": "",
-      "es.upv.paella.blackBoardPlugin": {"enabled": true},
-      "es.upv.paella.breaksPlayerPlugin": {"enabled": true},
-      "es.upv.paella.overlayCaptionsPlugin": {"enabled": true},
-      "es.upv.paella.playButtonOnScreenPlugin": {"enabled":true},
-      "es.upv.paella.translecture.captionsPlugin": {"enabled":true},
-      "es.upv.paella.trimmingPlayerPlugin": {"enabled":true},
-      "es.upv.paella.windowTitlePlugin": {"enabled": true},
-
-      "//**** Video profile plugins": "",
-      "es.upv.paella.singleStreamProfilePlugin": {
-          "enabled": true,
-          "videoSets": [
-            { "icon":"professor_icon.svg", "id":"presenter", "content":["presenter"]},
-            { "icon":"slide_icon.svg", "id":"presentation", "content":["presentation"]}
-          ]
-
-      },
-      "es.upv.paella.dualStreamProfilePlugin": { "enabled":true,
-        "videoSets": [
-          { "icon":"slide_professor_icon.svg", "id":"presenter_presentation", "content":["presenter","presentation"] },
-          { "icon":"slide_professor_icon.svg", "id":"presenter2_presentation", "content":["presenter-2","presentation"] },
-          { "icon":"slide_professor_icon.svg", "id":"presenter3_presentation", "content":["presenter-3","presentation"] }
-        ]
-      },
-      "es.upv.paella.tripleStreamProfilePlugin": {
-        "enabled": true,
-        "videoSets": [
-          { "icon":"three_streams_icon.svg", "id":"presenter_presentation_presenter2", "content":["presenter","presentation","presenter-2"] },
-          { "icon":"three_streams_icon.svg", "id":"presenter_presentation_presenter3", "content":["presenter","presentation","presenter-3"] }
-        ]
-      }
-    }
-  },
-  "standalone" : {
-    "repository": "../repository/"
-  },
-  "skin": {
-    "available": [
-      "dark",
-      "dark_small",
-      "light",
-      "light_small"
-    ]
-  }
-}
-- 
GitLab