diff --git a/pandora/clip/models.py b/pandora/clip/models.py
index 8ae0cd11..e66408a7 100644
--- a/pandora/clip/models.py
+++ b/pandora/clip/models.py
@@ -190,6 +190,12 @@ class MetaClip(object):
def __str__(self):
return self.public_id
+ def get_first_frame(self, resolution=None):
+ if resolution is None:
+ resolution = max(settings.CONFIG['video']['resolutions'])
+ return '/%s/%sp%0.03f.jpg' % (self.item.public_id, resolution, float(self.start))
+
+
class Meta:
unique_together = ("item", "start", "end")
diff --git a/pandora/document/models.py b/pandora/document/models.py
index 30006ecc..326b9387 100644
--- a/pandora/document/models.py
+++ b/pandora/document/models.py
@@ -567,7 +567,7 @@ class Document(models.Model, FulltextMixin):
if len(crop) == 4:
path = os.path.join(folder, '%s.jpg' % ','.join(map(str, crop)))
if not os.path.exists(path):
- img = Image.open(src).crop(crop)
+ img = Image.open(src).convert('RGB').crop(crop)
img.save(path)
else:
img = Image.open(path)
diff --git a/pandora/document/views.py b/pandora/document/views.py
index 47bf5bd8..112f1595 100644
--- a/pandora/document/views.py
+++ b/pandora/document/views.py
@@ -12,9 +12,10 @@ from oxdjango.decorators import login_required_json
from oxdjango.http import HttpFileResponse
from oxdjango.shortcuts import render_to_json_response, get_object_or_404_json, json_response, HttpErrorJson
from django import forms
-from django.db.models import Count, Sum
from django.conf import settings
+from django.db.models import Count, Sum
from django.http import HttpResponse
+from django.shortcuts import render
from item import utils
from item.models import Item
@@ -512,3 +513,33 @@ def autocompleteDocuments(request, data):
response['data']['items'] = [i['value'] for i in qs]
return render_to_json_response(response)
actions.register(autocompleteDocuments)
+
+
+def document(request, fragment):
+ context = {}
+ parts = fragment.split('/')
+ id = parts[0]
+ page = None
+ crop = None
+ if len(parts) == 2:
+ rect = parts[1].split(',')
+ if len(rect) == 1:
+ page = rect[0]
+ else:
+ crop = rect
+ document = models.Document.objects.filter(id=ox.fromAZ(id)).first()
+ if document and document.access(request.user):
+ context['title'] = document.data['title']
+ if document.data.get('description'):
+ context['description'] = document.data['description']
+ link = request.build_absolute_uri(document.get_absolute_url())
+ public_id = ox.toAZ(document.id)
+ preview = '/documents/%s/512p.jpg' % public_id
+ if page:
+ preview = '/documents/%s/512p%s.jpg' % (public_id, page)
+ if crop:
+ preview = '/documents/%s/512p%s.jpg' % (public_id, ','.join(crop))
+ context['preview'] = request.build_absolute_uri(preview)
+ context['url'] = request.build_absolute_uri('/documents/' + fragment)
+ context['settings'] = settings
+ return render(request, "document.html", context)
diff --git a/pandora/mobile/__init__.py b/pandora/mobile/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/pandora/mobile/admin.py b/pandora/mobile/admin.py
new file mode 100644
index 00000000..8c38f3f3
--- /dev/null
+++ b/pandora/mobile/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/pandora/mobile/apps.py b/pandora/mobile/apps.py
new file mode 100644
index 00000000..13970a58
--- /dev/null
+++ b/pandora/mobile/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class MobileConfig(AppConfig):
+ name = 'mobile'
diff --git a/pandora/mobile/migrations/__init__.py b/pandora/mobile/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/pandora/mobile/models.py b/pandora/mobile/models.py
new file mode 100644
index 00000000..71a83623
--- /dev/null
+++ b/pandora/mobile/models.py
@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.
diff --git a/pandora/mobile/tests.py b/pandora/mobile/tests.py
new file mode 100644
index 00000000..7ce503c2
--- /dev/null
+++ b/pandora/mobile/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/pandora/mobile/views.py b/pandora/mobile/views.py
new file mode 100644
index 00000000..fbc0f44c
--- /dev/null
+++ b/pandora/mobile/views.py
@@ -0,0 +1,75 @@
+from django.conf import settings
+from django.shortcuts import render
+
+import ox
+
+
+def index(request, fragment):
+ from item.models import Item
+ from edit.models import Edit
+ from document.models import Document
+ context = {}
+ parts = fragment.split('/')
+ if parts[0] in ('document', 'documents'):
+ type = 'document'
+ id = parts[1]
+ page = None
+ crop = None
+ if len(parts) == 3:
+ rect = parts[2].split(',')
+ if len(rect) == 1:
+ page = rect[0]
+ else:
+ crop = rect
+ document = Document.objects.filter(id=ox.fromAZ(id)).first()
+ if document and document.access(request.user):
+ context['title'] = document.data['title']
+ link = request.build_absolute_uri(document.get_absolute_url())
+ # FIXME: get preview image or fragment parse from url
+ public_id = ox.toAZ(document.id)
+ preview = '/documents/%s/512p.jpg' % public_id
+ if page:
+ preview = '/documents/%s/512p%s.jpg' % (public_id, page)
+ if crop:
+ preview = '/documents/%s/512p%s.jpg' % (public_id, ','.join(crop))
+ context['preview'] = request.build_absolute_uri(preview)
+
+ elif parts[0] == 'edits':
+ type = 'edit'
+ id = parts[1]
+ id = id.split(':')
+ username = id[0]
+ name = ":".join(id[1:])
+ name = name.replace('_', ' ')
+ edit = Edit.objects.filter(user__username=username, name=name).first()
+ if edit and edit.accessible(request.user):
+ link = request.build_absolute_uri('/m' + edit.get_absolute_url())
+ context['title'] = name
+ context['description'] = edit.description.split('\n\n')[0]
+ # FIXME: use sort from parts if needed
+ context['preview'] = request.build_absolute_uri(edit.get_clips().first().get_first_frame())
+ else:
+ type = 'item'
+ id = parts[0]
+ item = Item.objects.filter(public_id=id).first()
+ if item and item.accessible(request.user):
+ link = request.build_absolute_uri(item.get_absolute_url())
+ if len(parts) > 1 and parts[1] in ('editor', 'player'):
+ parts = [parts[0]] + parts[2:]
+ if len(parts) > 1:
+ inout = parts[1]
+ if '-' in inout:
+ inout = inout.split('-')
+ else:
+ inout = inout.split(',')
+ inout = [ox.parse_timecode(p) for p in inout]
+ if len(inout) == 3:
+ inout.pop(1)
+ context['preview'] = link + '/480p%s.jpg' % inout[0]
+ else:
+ context['preview'] = link + '/480p.jpg'
+ context['title'] = item.get('title')
+ if context:
+ context['url'] = request.build_absolute_uri('/m/' + fragment)
+ context['settings'] = settings
+ return render(request, "mobile/index.html", context)
diff --git a/pandora/settings.py b/pandora/settings.py
index f6fba9f7..eae5c942 100644
--- a/pandora/settings.py
+++ b/pandora/settings.py
@@ -67,7 +67,8 @@ STATICFILES_DIRS = (
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
- #'django.contrib.staticfiles.finders.DefaultStorageFinder',
+ "sass_processor.finders.CssFinder",
+ "compressor.finders.CompressorFinder",
)
GEOIP_PATH = normpath(join(PROJECT_ROOT, '..', 'data', 'geo'))
@@ -124,6 +125,9 @@ INSTALLED_APPS = (
'django_extensions',
'django_celery_results',
'django_celery_beat',
+ 'compressor',
+ 'sass_processor',
+
'app',
'log',
'annotation',
@@ -150,6 +154,7 @@ INSTALLED_APPS = (
'websocket',
'taskqueue',
'home',
+ 'mobile',
)
AUTH_USER_MODEL = 'system.User'
diff --git a/pandora/templates/document.html b/pandora/templates/document.html
new file mode 100644
index 00000000..c893d363
--- /dev/null
+++ b/pandora/templates/document.html
@@ -0,0 +1,38 @@
+
+
+
+
+ {{head_title}}
+
+ {% include "baseheader.html" %}
+
+ {%if description %} {%endif%}
+
+
+
+
+
+
+ {%if description %}{%endif%}
+
+
+
+
+
+
diff --git a/pandora/templates/mobile/index.html b/pandora/templates/mobile/index.html
new file mode 100644
index 00000000..5cda3d82
--- /dev/null
+++ b/pandora/templates/mobile/index.html
@@ -0,0 +1,45 @@
+{% load static sass_tags compress %}
+
+
+
+
+ {% if title %}
+ {{title}}
+
+
+ {% endif %}
+ {%if description %}
+
+
+ {%endif%}
+ {% if preview %}
+
+
+
+ {% endif %}
+ {% if url %}
+
+ {% endif %}
+
+ {% compress css file m %}
+
+
+ {% endcompress %}
+
+
+
+
+ {% compress js file m %}
+
+
+
+
+
+
+
+
+
+
+ {% endcompress %}
+
+
diff --git a/pandora/urls.py b/pandora/urls.py
index d8eba049..36af1aa0 100644
--- a/pandora/urls.py
+++ b/pandora/urls.py
@@ -26,6 +26,7 @@ import edit.views
import itemlist.views
import item.views
import item.site
+import mobile.views
import translation.views
import urlalias.views
@@ -47,6 +48,7 @@ urlpatterns = [
re_path(r'^collection/(?P.*?)/icon(?P\d*).jpg$', documentcollection.views.icon),
re_path(r'^documents/(?P[A-Z0-9]+)/(?P\d*)p(?P[\d,]*).jpg$', document.views.thumbnail),
re_path(r'^documents/(?P[A-Z0-9]+)/(?P.*?\.[^\d]{3})$', document.views.file),
+ re_path(r'^documents/(?P.*?)$', document.views.document),
re_path(r'^edit/(?P.*?)/icon(?P\d*).jpg$', edit.views.icon),
re_path(r'^list/(?P.*?)/icon(?P\d*).jpg$', itemlist.views.icon),
re_path(r'^text/(?P.*?)/icon(?P\d*).jpg$', text.views.icon),
@@ -65,6 +67,7 @@ urlpatterns = [
re_path(r'^robots.txt$', app.views.robots_txt),
re_path(r'^sitemap.xml$', item.views.sitemap_xml),
re_path(r'^sitemap(?P\d+).xml$', item.views.sitemap_part_xml),
+ re_path(r'm/(?P.*?)$', mobile.views.index),
path(r'', item.site.urls),
]
#sould this not be enabled by default? nginx should handle those
diff --git a/requirements.txt b/requirements.txt
index 66f20ff0..737f77ce 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,6 +5,9 @@ celery<5.0,>4.3
django-celery-results<2
django-celery-beat
django-extensions==2.2.9
+libsass
+django-compressor
+django-sass-processor
gunicorn==20.0.4
html5lib
requests<3.0.0,>=2.24.0
diff --git a/static/js/mainMenu.js b/static/js/mainMenu.js
index a7906d8a..457f168d 100644
--- a/static/js/mainMenu.js
+++ b/static/js/mainMenu.js
@@ -609,6 +609,8 @@ pandora.ui.mainMenu = function() {
pandora.$ui.player.options({fullscreen: true});
} else if (data.id == 'embed') {
pandora.$ui.embedDialog = pandora.ui.embedDialog().open();
+ } else if (data.id == 'share') {
+ pandora.$ui.shareDialog = pandora.ui.shareDialog().open();
} else if (data.id == 'advancedfind') {
pandora.$ui.filterDialog = pandora.ui.filterDialog().open();
} else if (data.id == 'clearquery') {
@@ -1909,7 +1911,8 @@ pandora.ui.mainMenu = function() {
}) }
] },
{},
- { id: 'embed', title: Ox._('Embed...') }
+ { id: 'embed', title: Ox._('Embed...') },
+ { id: 'share', title: Ox._('Share...') }
]}
}
diff --git a/static/js/shareDialog.js b/static/js/shareDialog.js
new file mode 100644
index 00000000..cf399f2b
--- /dev/null
+++ b/static/js/shareDialog.js
@@ -0,0 +1,37 @@
+'use strict';
+
+pandora.ui.shareDialog = function(/*[url, ]callback*/) {
+
+ if (arguments.length == 1) {
+ var url, callback = arguments[0];
+ } else {
+ var url = arguments[0], callback = arguments[1];
+ }
+ var url = document.location.href.replace(document.location.hostname, document.location.hostname + '/m'),
+ $content = Ox.Element().append(
+ Ox.Input({
+ height: 100,
+ width: 256,
+ placeholder: 'Share Link',
+ type: 'textarea',
+ disabled: true,
+ value: url
+ })
+ ),
+ that = pandora.ui.iconDialog({
+ buttons: [
+ Ox.Button({
+ id: 'close',
+ title: Ox._('Close')
+ }).bindEvent({
+ click: function() {
+ that.close();
+ }
+ }),
+ ],
+ keys: {enter: 'close', escape: 'close'},
+ content: $content,
+ title: "Share current view",
+ });
+ return that;
+}
diff --git a/static/mobile/css/reset.css b/static/mobile/css/reset.css
new file mode 100644
index 00000000..b84ca0d8
--- /dev/null
+++ b/static/mobile/css/reset.css
@@ -0,0 +1,46 @@
+/* http://meyerweb.com/eric/tools/css/reset/
+ v2.0 | 20110126
+ License: none (public domain)
+*/
+
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+u, i, center,
+ol,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed,
+figure, figcaption, footer, header, hgroup,
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+}
+/* HTML5 display-role reset for older browsers */
+article, aside, details, figcaption, figure,
+footer, header, hgroup, menu, nav, section {
+ display: block;
+}
+body {
+ line-height: 1;
+
+}
+blockquote, q {
+ quotes: none;
+}
+blockquote:before, blockquote:after,
+q:before, q:after {
+ content: '';
+ content: none;
+}
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
diff --git a/static/mobile/css/style.css b/static/mobile/css/style.css
new file mode 100644
index 00000000..811b250c
--- /dev/null
+++ b/static/mobile/css/style.css
@@ -0,0 +1,171 @@
+body {
+ margin: 0;
+ //background: rgb(240, 240, 240);
+ //background: rgb(144, 144, 144);
+ //color: rgb(0, 0, 0);
+ background: rgb(16, 16, 16);
+ color: rgb(240, 240, 240);
+ font-family: "Noto Sans", "Lucida Grande", "Segoe UI", "DejaVu Sans", "Lucida Sans Unicode", Helvetica, Arial, sans-serif;
+ line-height: normal;
+}
+
+a {
+ color: rgb(128, 128, 255)
+}
+iframe {
+ max-width: 100%;
+}
+
+.player {
+ max-width: 100%;
+}
+video, .poster {
+ border-bottom: 1px solid yellow;
+}
+.content {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ max-width: 1000px;
+ margin: auto;
+}
+.title {
+ padding-top: 16px;
+ padding-bottom: 0;
+ margin-bottom: 4px;
+ font-size: 14pt;
+ font-weight: bold;
+ border-bottom: 1px solid pink;
+ text-wrap: balance;
+}
+.byline {
+ padding: 0;
+ padding-bottom: 16px;
+}
+.player {
+ text-align: center;
+ padding-top: 0px;
+ padding-bottom: 0px;
+}
+@media(max-width:768px) {
+ .title,
+ .byline {
+ padding-left: 4px;
+ padding-right: 4px;
+ }
+ .player {
+ position: sticky;
+ top: 0px;
+ }
+}
+.player video {
+ z-index: 0;
+}
+.value {
+ padding: 4px;
+ padding-top: 16px;
+ padding-left: 0;
+ padding-right: 0;
+ flex: 1;
+}
+@media(max-width:768px) {
+ .value {
+ padding-left: 4px;
+ padding-right: 4px;
+ }
+}
+.more {
+ padding-top: 16px;
+ padding-bottom: 16px;
+ text-align: center;
+ font-size: 16pt;
+}
+.layer.active {
+ padding-top: 8px;
+}
+.layer.active:first-child {
+ padding-top: 0px;
+}
+.layer h3 {
+ font-weight: bold;
+ padding: 4px;
+ padding-left: 0;
+ margin: 0;
+ //display: none;
+}
+.layer.active h3 {
+ display: block;
+}
+
+.annotation {
+ padding: 4px;
+ border-bottom: 1px solid blueviolet;
+ display: none;
+}
+.annotation.active {
+ display: block;
+}
+.annotation.active.place,
+.annotation.active.string {
+ display: inline-block;
+ border: 1px solid blueviolet;
+ margin-bottom: 4px;
+}
+@media(max-width:768px) {
+ .annotation a img {
+ width: 100%;
+ }
+}
+
+.layer h3 {
+ cursor: pointer;
+}
+.layer .icon svg {
+ width: 12px;
+ height: 12px;
+}
+.layer.collapsed .annotation.active {
+ display: none;
+}
+.rotate {
+ transform: rotate(90deg) translateY(-100%);
+ transform-origin:bottom left;
+}
+
+.document.text {
+ line-height: 1.5;
+ letter-spacing: 0.1px;
+ word-wrap: break-word;
+ hyphens: auto;
+}
+.document.text p {
+ padding-bottom: 1em;
+}
+.document.text img {
+ max-width: 100vw;
+ margin-left: -4px;
+ margin-right: -4px;
+}
+figure {
+ text-align: center;
+}
+figure img {
+ max-width: 100vw;
+ margin-left: -4px;
+ margin-right: -4px;
+}
+@media(max-width:768px) {
+.document.text {
+ padding-left: 4px;
+ padding-right: 4px;
+}
+}
+
+ol li {
+ margin-left: 1em;
+}
+
+.blocked svg {
+ width: 64px;
+ height: 64px;
+}
diff --git a/static/mobile/js/VideoElement.js b/static/mobile/js/VideoElement.js
new file mode 100644
index 00000000..8238d95d
--- /dev/null
+++ b/static/mobile/js/VideoElement.js
@@ -0,0 +1,730 @@
+'use strict';
+
+/*@
+VideoElement VideoElement Object
+ options Options object
+ autoplay autoplay
+ items array of objects with src,in,out,duration
+ loop loop playback
+ playbackRate playback rate
+ position start position
+ self Shared private variable
+ ([options[, self]]) -> VideoElement Object
+ loadedmetadata loadedmetadata
+ itemchange itemchange
+ seeked seeked
+ seeking seeking
+ sizechange sizechange
+ ended ended
+@*/
+
+(function() {
+ var queue = [],
+ queueSize = 100,
+ restrictedElements = [],
+ requiresUserGesture = mediaPlaybackRequiresUserGesture(),
+ unblock = [];
+
+
+window.VideoElement = function(options) {
+
+ var self = {},
+ that = document.createElement("div");
+
+ self.options = {
+ autoplay: false,
+ items: [],
+ loop: false,
+ muted: false,
+ playbackRate: 1,
+ position: 0,
+ volume: 1
+ }
+ Object.assign(self.options, options);
+ debug(self.options)
+
+ that.style.position = "relative"
+ that.style.width = "100%"
+ that.style.height = "100%"
+ that.style.maxHeight = "100vh"
+ that.style.margin = 'auto'
+ if (self.options.aspectratio) {
+ that.style.aspectRatio = self.options.aspectratio
+ } else {
+ that.style.height = '128px'
+ }
+ that.triggerEvent = function(event, data) {
+ if (event != 'timeupdate') {
+ debug('Video', 'triggerEvent', event, data);
+ }
+ event = new Event(event)
+ event.data = data
+ that.dispatchEvent(event)
+ }
+
+
+ /*
+ .update({
+ items: function() {
+ self.loadedMetadata = false;
+ loadItems(function() {
+ self.loadedMetadata = true;
+ var update = true;
+ if (self.currentItem >= self.numberOfItems) {
+ self.currentItem = 0;
+ }
+ if (!self.numberOfItems) {
+ self.video.src = '';
+ that.triggerEvent('durationchange', {
+ duration: that.duration()
+ });
+ } else {
+ if (self.currentItemId != self.items[self.currentItem].id) {
+ // check if current item is in new items
+ self.items.some(function(item, i) {
+ if (item.id == self.currentItemId) {
+ self.currentItem = i;
+ loadNextVideo();
+ update = false;
+ return true;
+ }
+ });
+ if (update) {
+ self.currentItem = 0;
+ self.currentItemId = self.items[self.currentItem].id;
+ }
+ }
+ if (!update) {
+ that.triggerEvent('seeked');
+ that.triggerEvent('durationchange', {
+ duration: that.duration()
+ });
+ } else {
+ setCurrentVideo(function() {
+ that.triggerEvent('seeked');
+ that.triggerEvent('durationchange', {
+ duration: that.duration()
+ });
+ });
+ }
+ }
+ });
+ },
+ playbackRate: function() {
+ self.video.playbackRate = self.options.playbackRate;
+ }
+ })
+ .css({width: '100%', height: '100%'});
+ */
+
+ debug('Video', 'VIDEO ELEMENT OPTIONS', self.options);
+
+ self.currentItem = -1;
+ self.currentTime = 0;
+ self.currentVideo = 0;
+ self.items = [];
+ self.loadedMetadata = false;
+ that.paused = self.paused = true;
+ self.seeking = false;
+ self.loading = true;
+ self.buffering = true;
+ self.videos = [getVideo(), getVideo()];
+ self.video = self.videos[self.currentVideo];
+ self.video.classList.add("active")
+ self.volume = self.options.volume;
+ self.muted = self.options.muted;
+ self.brightness = document.createElement('div')
+ self.brightness.style.top = '0'
+ self.brightness.style.left = '0'
+ self.brightness.style.width = '100%'
+ self.brightness.style.height = '100%'
+ self.brightness.style.background = 'rgb(0, 0, 0)'
+ self.brightness.style.opacity = '0'
+ self.brightness.style.position = "absolute"
+ that.append(self.brightness)
+
+ self.timeupdate = setInterval(function() {
+ if (!self.paused
+ && !self.loading
+ && self.loadedMetadata
+ && self.items[self.currentItem]
+ && self.items[self.currentItem].out
+ && self.video.currentTime >= self.items[self.currentItem].out) {
+ setCurrentItem(self.currentItem + 1);
+ }
+ }, 30);
+
+ // mobile browsers only allow playing media elements after user interaction
+ if (restrictedElements.length > 0) {
+ unblock.push(setSource)
+ setTimeout(function() {
+ that.triggerEvent('requiresusergesture');
+ })
+ } else {
+ setSource();
+ }
+
+ function getCurrentTime() {
+ var item = self.items[self.currentItem];
+ return self.seeking || self.loading
+ ? self.currentTime
+ : item ? item.position + self.video.currentTime - item['in'] : 0;
+ }
+
+ function getset(key, value) {
+ var ret;
+ if (isUndefined(value)) {
+ ret = self.video[key];
+ } else {
+ self.video[key] = value;
+ ret = that;
+ }
+ return ret;
+ }
+
+ function getVideo() {
+ var video = getVideoElement()
+ video.style.display = "none"
+ video.style.width = "100%"
+ video.style.height = "100%"
+ video.style.margin = "auto"
+ video.style.background = '#000'
+ if (self.options.aspectratio) {
+ video.style.aspectRatio = self.options.aspectratio
+ } else {
+ video.style.height = '128px'
+ }
+ video.style.top = 0
+ video.style.left = 0
+ video.style.position = "absolute"
+ video.preload = "metadata"
+ video.addEventListener("ended", event => {
+ if (self.video == video) {
+ setCurrentItem(self.currentItem + 1);
+ }
+ })
+ video.addEventListener("loadedmetadata", event => {
+ //console.log("!!", video.src, "loaded", 'current?', video == self.video)
+ })
+ video.addEventListener("progress", event => {
+ // stop buffering if buffered to end point
+ var item = self.items[self.currentItem],
+ nextItem = mod(self.currentItem + 1, self.numberOfItems),
+ next = self.items[nextItem],
+ nextVideo = self.videos[mod(self.currentVideo + 1, self.videos.length)];
+ if (self.video == video && (video.preload != 'none' || self.buffering)) {
+ if (clipCached(video, item)) {
+ video.preload = 'none';
+ self.buffering = false;
+ if (nextItem != self.currentItem) {
+ nextVideo.preload = 'auto';
+ }
+ } else {
+ if (nextItem != self.currentItem && nextVideo.preload != 'none' && nextVideo.src) {
+ nextVideo.preload = 'none';
+ }
+ }
+ } else if (nextVideo == video && video.preload != 'none' && nextVideo.src) {
+ if (clipCached(video, next)) {
+ video.preload = 'none';
+ }
+ }
+
+ function clipCached(video, item) {
+ var cached = false
+ for (var i=0; i= item.out) {
+ cached = true
+ }
+ }
+ return cached
+ }
+ })
+ video.addEventListener("volumechange", event => {
+ if (self.video == video) {
+ that.triggerEvent('volumechange')
+ }
+ })
+ video.addEventListener("play", event => {
+ /*
+ if (self.video == video) {
+ that.triggerEvent('play')
+ }
+ */
+ })
+ video.addEventListener("pause", event => {
+ /*
+ if (self.video == video) {
+ that.triggerEvent('pause')
+ }
+ */
+ })
+ video.addEventListener("timeupdate", event => {
+ if (self.video == video) {
+ /*
+ var box = self.video.getBoundingClientRect()
+ if (box.width && box.height) {
+ that.style.width = box.width + 'px'
+ that.style.height = box.height + 'px'
+ }
+ */
+ that.triggerEvent('timeupdate', {
+ currentTime: getCurrentTime()
+ })
+ }
+ })
+ video.addEventListener("seeking", event => {
+ if (self.video == video) {
+ that.triggerEvent('seeking')
+ }
+ })
+ video.addEventListener("stop", event => {
+ if (self.video == video) {
+ //self.video.pause();
+ that.triggerEvent('ended');
+ }
+ })
+ that.append(video)
+ return video
+ }
+
+ function getVideoElement() {
+ var video;
+ if (requiresUserGesture) {
+ if (queue.length) {
+ video = queue.pop();
+ } else {
+ video = document.createElement('video');
+ restrictedElements.push(video);
+ }
+ } else {
+ video = document.createElement('video');
+ }
+ video.playsinline = true
+ video.setAttribute('playsinline', 'playsinline')
+ video.setAttribute('webkit-playsinline', 'webkit-playsinline')
+ video.WebKitPlaysInline = true
+ return video
+ };
+
+ function getVolume() {
+ var volume = 1;
+ if (self.items[self.currentItem] && isNumber(self.items[self.currentItem].volume)) {
+ volume = self.items[self.currentItem].volume;
+ }
+ return self.volume * volume;
+ }
+
+
+ function isReady(video, callback) {
+ if (video.seeking && !self.paused && !self.seeking) {
+ that.triggerEvent('seeking');
+ debug('Video', 'isReady', 'seeking');
+ video.addEventListener('seeked', function(event) {
+ debug('Video', 'isReady', 'seeked');
+ that.triggerEvent('seeked');
+ callback(video);
+ }, {once: true})
+ } else if (video.readyState) {
+ callback(video);
+ } else {
+ that.triggerEvent('seeking');
+ video.addEventListener('loadedmetadata', function(event) {
+ callback(video);
+ }, {once: true});
+ video.addEventListener('seeked', event => {
+ that.triggerEvent('seeked');
+ }, {once: true})
+ }
+ }
+
+ function loadItems(callback) {
+ debug('loadItems')
+ var currentTime = 0,
+ items = self.options.items.map(function(item) {
+ return isObject(item) ? {...item} : {src: item};
+ });
+
+ self.items = items;
+ self.numberOfItems = self.items.length;
+ items.forEach(item => {
+ item['in'] = item['in'] || 0;
+ item.position = currentTime;
+ if (item.out) {
+ item.duration = item.out - item['in'];
+ }
+ if (item.duration) {
+ if (!item.out) {
+ item.out = item.duration;
+ }
+ currentTime += item.duration;
+ item.id = getId(item);
+ } else {
+ getVideoInfo(item.src, function(info) {
+ item.duration = info.duration;
+ if (!item.out) {
+ item.out = item.duration;
+ }
+ currentTime += item.duration;
+ item.id = getId(item);
+ });
+ }
+ })
+ debug('loadItems done')
+ callback && callback();
+
+ function getId(item) {
+ return item.id || item.src + '/' + item['in'] + '-' + item.out;
+ }
+ }
+
+ function loadNextVideo() {
+ if (self.numberOfItems <= 1) {
+ return;
+ }
+ var item = self.items[self.currentItem],
+ nextItem = mod(self.currentItem + 1, self.numberOfItems),
+ next = self.items[nextItem],
+ nextVideo = self.videos[mod(self.currentVideo + 1, self.videos.length)];
+ nextVideo.addEventListener('loadedmetadata', function() {
+ if (self.video != nextVideo) {
+ nextVideo.currentTime = next['in'] || 0;
+ }
+ }, {once: true});
+ nextVideo.src = next.src;
+ nextVideo.preload = 'metadata';
+ }
+
+ function setCurrentItem(item) {
+ debug('Video', 'sCI', item, self.numberOfItems);
+ var interval;
+ if (item >= self.numberOfItems || item < 0) {
+ if (self.options.loop) {
+ item = mod(item, self.numberOfItems);
+ } else {
+ self.seeking = false;
+ self.ended = true;
+ that.paused = self.paused = true;
+ self.video && self.video.pause();
+ that.triggerEvent('ended');
+ return;
+ }
+ }
+ self.video && self.video.pause();
+ self.currentItem = item;
+ self.currentItemId = self.items[self.currentItem].id;
+ setCurrentVideo(function() {
+ if (!self.loadedMetadata) {
+ self.loadedMetadata = true;
+ that.triggerEvent('loadedmetadata');
+ }
+ debug('Video', 'sCI', 'trigger itemchange',
+ self.items[self.currentItem]['in'], self.video.currentTime, self.video.seeking);
+ that.triggerEvent('sizechange');
+ that.triggerEvent('itemchange', {
+ item: self.currentItem
+ });
+ });
+ }
+
+ function setCurrentVideo(callback) {
+ var css = {},
+ muted = self.muted,
+ item = self.items[self.currentItem],
+ next;
+ debug('Video', 'sCV', JSON.stringify(item));
+
+ ['left', 'top', 'width', 'height'].forEach(function(key) {
+ css[key] = self.videos[self.currentVideo].style[key];
+ });
+ self.currentTime = item.position;
+ self.loading = true;
+ if (self.video) {
+ self.videos[self.currentVideo].style.display = "none"
+ self.videos[self.currentVideo].classList.remove("active")
+ self.video.pause();
+ }
+ self.currentVideo = mod(self.currentVideo + 1, self.videos.length);
+ self.video = self.videos[self.currentVideo];
+ self.video.classList.add("active")
+ self.video.muted = true; // avoid sound glitch during load
+ if (!self.video.attributes.src || self.video.attributes.src.value != item.src) {
+ self.loadedMetadata && debug('Video', 'caching next item failed, reset src');
+ self.video.src = item.src;
+ }
+ self.video.preload = 'metadata';
+ self.video.volume = getVolume();
+ self.video.playbackRate = self.options.playbackRate;
+ Object.keys(css).forEach(key => {
+ self.video.style[key] = css[key]
+ })
+ self.buffering = true;
+ debug('Video', 'sCV', self.video.src, item['in'],
+ self.video.currentTime, self.video.seeking);
+ isReady(self.video, function(video) {
+ var in_ = item['in'] || 0;
+
+ function ready() {
+ debug('Video', 'sCV', 'ready');
+ self.seeking = false;
+ self.loading = false;
+ self.video.muted = muted;
+ !self.paused && self.video.play();
+ self.video.style.display = 'block'
+ callback && callback();
+ loadNextVideo();
+ }
+ if (video.currentTime == in_) {
+ debug('Video', 'sCV', 'already at position', item.id, in_, video.currentTime);
+ ready();
+ } else {
+ self.video.addEventListener("seeked", event => {
+ debug('Video', 'sCV', 'seeked callback');
+ ready();
+ }, {once: true})
+ if (!self.seeking) {
+ debug('Video', 'sCV set in', video.src, in_, video.currentTime, video.seeking);
+ self.seeking = true;
+ video.currentTime = in_;
+ if (self.paused) {
+ var promise = self.video.play();
+ if (promise !== undefined) {
+ promise.then(function() {
+ self.video.pause();
+ self.video.muted = muted;
+ }).catch(function() {
+ self.video.pause();
+ self.video.muted = muted;
+ });
+ } else {
+ self.video.pause();
+ self.video.muted = muted;
+ }
+ }
+ }
+ }
+ });
+ }
+
+ function setCurrentItemTime(currentTime) {
+ debug('Video', 'sCIT', currentTime, self.video.currentTime,
+ 'delta', currentTime - self.video.currentTime);
+ isReady(self.video, function(video) {
+ if (self.video == video) {
+ if(self.video.seeking) {
+ self.video.addEventListener("seeked", event => {
+ that.triggerEvent('seeked');
+ self.seeking = false;
+ }, {once: true})
+ } else if (self.seeking) {
+ that.triggerEvent('seeked');
+ self.seeking = false;
+ }
+ video.currentTime = currentTime;
+ }
+ });
+ }
+
+ function setCurrentTime(time) {
+ debug('Video', 'sCT', time);
+ var currentTime, currentItem;
+ self.items.forEach(function(item, i) {
+ if (time >= item.position
+ && time < item.position + item.duration) {
+ currentItem = i;
+ currentTime = time - item.position + item['in'];
+ return false;
+ }
+ });
+ if (self.items.length) {
+ // Set to end of items if time > duration
+ if (isUndefined(currentItem) && isUndefined(currentTime)) {
+ currentItem = self.items.length - 1;
+ currentTime = self.items[currentItem].duration + self.items[currentItem]['in'];
+ }
+ debug('Video', 'sCT', time, '=>', currentItem, currentTime);
+ if (currentItem != self.currentItem) {
+ setCurrentItem(currentItem);
+ }
+ self.seeking = true;
+ self.currentTime = time;
+ that.triggerEvent('seeking');
+ setCurrentItemTime(currentTime);
+ } else {
+ self.currentTime = 0;
+ }
+ }
+
+ function setSource() {
+ self.loadedMetadata = false;
+ loadItems(function() {
+ setCurrentTime(self.options.position);
+ self.options.autoplay && setTimeout(function() {
+ that.play();
+ });
+ });
+ }
+
+
+ /*@
+ brightness get/set brightness
+ @*/
+ that.brightness = function() {
+ var ret;
+ if (arguments.length == 0) {
+ ret = 1 - parseFloat(self.brightness.style.opacity);
+ } else {
+ self.brightness.style.opacity = 1 - arguments[0]
+ ret = that;
+ }
+ return ret;
+ };
+
+ /*@
+ buffered buffered
+ @*/
+ that.buffered = function() {
+ return self.video.buffered;
+ };
+
+ /*@
+ currentTime get/set currentTime
+ @*/
+ that.currentTime = function() {
+ var ret;
+ if (arguments.length == 0) {
+ ret = getCurrentTime();
+ } else {
+ self.ended = false;
+ setCurrentTime(arguments[0]);
+ ret = that;
+ }
+ return ret;
+ };
+
+ /*@
+ duration duration
+ @*/
+ that.duration = function() {
+ return self.items ? self.items.reduce((duration, item) => {
+ return duration + item.duration;
+ }, 0) : NaN;
+ };
+
+ /*@
+ muted get/set muted
+ @*/
+ that.muted = function(value) {
+ if (!isUndefined(value)) {
+ self.muted = value;
+ }
+ return getset('muted', value);
+ };
+
+ /*@
+ pause pause
+ @*/
+ that.pause = function() {
+ that.paused = self.paused = true;
+ self.video.pause();
+ that.paused && that.triggerEvent('pause')
+ };
+
+ /*@
+ play play
+ @*/
+ that.play = function() {
+ if (self.ended) {
+ that.currentTime(0);
+ }
+ isReady(self.video, function(video) {
+ self.ended = false;
+ that.paused = self.paused = false;
+ self.seeking = false;
+ video.play();
+ that.triggerEvent('play')
+ });
+ };
+
+ that.removeElement = function() {
+ self.currentTime = getCurrentTime();
+ self.loading = true;
+ clearInterval(self.timeupdate);
+ //Chrome does not properly release resources, reset manually
+ //http://code.google.com/p/chromium/issues/detail?id=31014
+ self.videos.forEach(function(video) {
+ video.src = ''
+ });
+ return Ox.Element.prototype.removeElement.apply(that, arguments);
+ };
+
+ /*@
+ videoHeight get videoHeight
+ @*/
+ that.videoHeight = function() {
+ return self.video.videoHeight;
+ };
+
+ /*@
+ videoWidth get videoWidth
+ @*/
+ that.videoWidth = function() {
+ return self.video.videoWidth;
+ };
+
+ /*@
+ volume get/set volume
+ @*/
+ that.volume = function(value) {
+ if (isUndefined(value)) {
+ value = self.volume
+ } else {
+ self.volume = value;
+ self.video.volume = getVolume();
+ }
+ return value;
+ };
+
+ return that;
+
+};
+
+// mobile browsers only allow playing media elements after user interaction
+
+ function mediaPlaybackRequiresUserGesture() {
+ // test if play() is ignored when not called from an input event handler
+ var video = document.createElement('video');
+ video.muted = true
+ video.play();
+ return video.paused;
+ }
+
+
+ async function removeBehaviorsRestrictions() {
+ debug('Video', 'remove restrictions on video', self.video, restrictedElements.length, queue.length);
+ if (restrictedElements.length > 0) {
+ var rElements = restrictedElements;
+ restrictedElements = [];
+ rElements.forEach(async function(video) {
+ await video.load();
+ });
+ setTimeout(function() {
+ var u = unblock;
+ unblock = [];
+ u.forEach(function(callback) { callback(); });
+ }, 1000);
+ }
+ while (queue.length < queueSize) {
+ var video = document.createElement('video');
+ video.load();
+ queue.push(video);
+ }
+ }
+
+ if (requiresUserGesture) {
+ window.addEventListener('keydown', removeBehaviorsRestrictions);
+ window.addEventListener('mousedown', removeBehaviorsRestrictions);
+ window.addEventListener('touchstart', removeBehaviorsRestrictions);
+ }
+})();
diff --git a/static/mobile/js/VideoPlayer.js b/static/mobile/js/VideoPlayer.js
new file mode 100644
index 00000000..829fc02f
--- /dev/null
+++ b/static/mobile/js/VideoPlayer.js
@@ -0,0 +1,400 @@
+(function() {
+
+window.VideoPlayer = function(options) {
+
+ var self = {}, that;
+ self.options = {
+ autoplay: false,
+ controls: true,
+ items: [],
+ loop: false,
+ muted: false,
+ playbackRate: 1,
+ position: 0,
+ volume: 1
+ }
+ Object.assign(self.options, options);
+ that = VideoElement(options);
+
+ self.controls = document.createElement('div')
+ self.controls.classList.add('mx-controls')
+ //self.controls.style.display = "none"
+ if (self.options.controls) {
+ var ratio = `aspect-ratio: ${self.options.aspectratio};`
+ if (!self.options.aspectratio) {
+ ratio = 'height: 128px;'
+ }
+ self.controls.innerHTML = `
+
+
+
+
+ ${icon.mute}
+
+
+
+
+ ${isIOS || !self.options.aspectratio ? "" : icon.enterFullscreen}
+
+
+ `
+ var toggleVideo = event => {
+ event.preventDefault()
+ event.stopPropagation()
+ if (that.paused) {
+ that.play()
+ } else {
+ that.pause()
+ }
+ }
+ async function toggleFullscreen(event) {
+ if (isIOS) {
+ return
+ }
+ event.preventDefault()
+ event.stopPropagation()
+ if (!document.fullscreenElement) {
+ that.classList.add('fullscreen')
+ if (that.webkitRequestFullscreen) {
+ await that.webkitRequestFullscreen()
+ } else {
+ await that.requestFullscreen()
+ }
+ console.log('entered fullscreen')
+ var failed = false
+ if (!screen.orientation.type.startsWith("landscape")) {
+ await screen.orientation.lock("landscape").catch(err => {
+ console.log('no luck with lock', err)
+ /*
+ document.querySelector('.error').innerHTML = '' + err
+ that.classList.remove('fullscreen')
+ document.exitFullscreen();
+ screen.orientation.unlock()
+ failed = true
+ */
+ })
+ }
+ if (that.paused && !failed) {
+ that.play()
+ }
+ } else {
+ that.classList.remove('fullscreen')
+ document.exitFullscreen();
+ screen.orientation.unlock()
+ }
+ }
+ var toggleSound = event => {
+ event.preventDefault()
+ event.stopPropagation()
+ if (that.muted()) {
+ that.muted(false)
+ } else {
+ that.muted(true)
+ }
+ }
+ var showControls
+ var toggleControls = event => {
+ if (self.controls.style.opacity == '0') {
+ event.preventDefault()
+ event.stopPropagation()
+ self.controls.style.opacity = '1'
+ showControls = setTimeout(() => {
+ self.controls.style.opacity = that.paused ? '1' : '0'
+ showControls = null
+ }, 3000)
+ } else {
+ self.controls.style.opacity = '0'
+ }
+ }
+ self.controls.addEventListener("mousemove", event => {
+ if (showControls) {
+ clearTimeout(showControls)
+ }
+ self.controls.style.opacity = '1'
+ showControls = setTimeout(() => {
+ self.controls.style.opacity = that.paused ? '1' : '0'
+ showControls = null
+ }, 3000)
+ })
+ self.controls.addEventListener("mouseleave", event => {
+ if (showControls) {
+ clearTimeout(showControls)
+ }
+ self.controls.style.opacity = that.paused ? '1' : '0'
+ showControls = null
+ })
+ self.controls.addEventListener("touchstart", toggleControls)
+ self.controls.querySelector('.toggle').addEventListener("click", toggleVideo)
+ self.controls.querySelector('.volume').addEventListener("click", toggleSound)
+ self.controls.querySelector('.fullscreen-btn').addEventListener("click", toggleFullscreen)
+ document.addEventListener('fullscreenchange', event => {
+ if (!document.fullscreenElement) {
+ screen.orientation.unlock()
+ that.classList.remove('fullscreen')
+ that.querySelector('.fullscreen-btn').innerHTML = icon.enterFullscreen
+ } else {
+ self.controls.querySelector('.fullscreen-btn').innerHTML = icon.exitFullscreen
+ }
+ })
+ that.append(self.controls)
+ }
+
+ function getVideoWidth() {
+ if (document.fullscreenElement) {
+ return ''
+ }
+ var av = that.querySelector('video.active')
+ return av ? av.getBoundingClientRect().width + 'px' : '100%'
+ }
+
+ var playOnLoad = false
+ var unblock = document.createElement("div")
+
+ that.addEventListener("requiresusergesture", event => {
+ unblock.style.position = "absolute"
+ unblock.style.width = '100%'
+ unblock.style.height = '100%'
+ unblock.style.backgroundImage = `url(${self.options.poster})`
+ unblock.style.zIndex = '1000'
+ unblock.style.backgroundPosition = "top left"
+ unblock.style.backgroundRepeat = "no-repeat"
+ unblock.style.backgroundSize = "cover"
+ unblock.style.display = 'flex'
+ unblock.classList.add('mx-controls')
+ unblock.classList.add('poster')
+ unblock.innerHTML = `
+
+
+ `
+ self.controls.style.opacity = '0'
+ unblock.addEventListener("click", event => {
+ event.preventDefault()
+ event.stopPropagation()
+ playOnLoad = true
+ unblock.querySelector('.toggle').innerHTML = `
+ ${icon.loading}
+ `
+ }, {once: true})
+ that.append(unblock)
+ })
+ var loading = true
+ that.brightness(0)
+ that.addEventListener("loadedmetadata", event => {
+ //
+ })
+ that.addEventListener("seeked", event => {
+ if (loading) {
+ that.brightness(1)
+ loading = false
+ }
+ if (playOnLoad) {
+ playOnLoad = false
+ var toggle = self.controls.querySelector('.toggle')
+ toggle.title = 'Pause'
+ toggle.querySelector('div').innerHTML = icon.pause
+ self.controls.style.opacity = '0'
+ unblock.remove()
+ that.play()
+ }
+ })
+
+ var time = that.querySelector('.controls .time div'),
+ progress = that.querySelector('.controls .position .progress')
+ that.querySelector('.controls .position').addEventListener("click", event => {
+ var bar = event.target
+ while (bar && !bar.classList.contains('bar')) {
+ bar = bar.parentElement
+ }
+ if (bar && bar.classList.contains('bar')) {
+ event.preventDefault()
+ event.stopPropagation()
+ var rect = bar.getBoundingClientRect()
+ var x = event.clientX - rect.x
+ var percent = x / rect.width
+ var position = percent * self.options.duration
+ if (self.options.position) {
+ position += self.options.position
+ }
+ progress.style.width = (100 * percent) + '%'
+ that.currentTime(position)
+ }
+ })
+ that.addEventListener("timeupdate", event => {
+ var currentTime = that.currentTime(),
+ duration = self.options.duration
+ if (self.options.position) {
+ currentTime -= self.options.position
+ }
+ progress.style.width = (100 * currentTime / duration) + '%'
+ duration = formatDuration(duration)
+ currentTime = formatDuration(currentTime)
+ while (duration && duration.startsWith('00:')) {
+ duration = duration.slice(3)
+ }
+ currentTime = currentTime.slice(currentTime.length - duration.length)
+ time.innerText = `${currentTime} / ${duration}`
+
+ })
+
+ that.addEventListener("play", event => {
+ var toggle = self.controls.querySelector('.toggle')
+ toggle.title = 'Pause'
+ toggle.querySelector('div').innerHTML = icon.pause
+ self.controls.style.opacity = '0'
+ })
+ that.addEventListener("pause", event => {
+ var toggle = self.controls.querySelector('.toggle')
+ toggle.title = 'Play'
+ toggle.querySelector('div').innerHTML = icon.play
+ self.controls.style.opacity = '1'
+ })
+ that.addEventListener("ended", event => {
+ var toggle = self.controls.querySelector('.toggle')
+ toggle.title = 'Play'
+ toggle.querySelector('div').innerHTML = icon.play
+ self.controls.style.opacity = '1'
+ })
+ that.addEventListener("seeking", event => {
+ //console.log("seeking")
+
+ })
+ that.addEventListener("seeked", event => {
+ //console.log("seeked")
+ })
+ that.addEventListener("volumechange", event => {
+ var volume = self.controls.querySelector('.volume')
+ if (that.muted()) {
+ volume.innerHTML = icon.unmute
+ volume.title = "Unmute"
+ } else {
+ volume.innerHTML = icon.mute
+ volume.title = "Mute"
+ }
+ })
+ window.addEventListener('resize', event => {
+ //
+ })
+ return that
+};
+
+})();
diff --git a/static/mobile/js/api.js b/static/mobile/js/api.js
new file mode 100644
index 00000000..5e989597
--- /dev/null
+++ b/static/mobile/js/api.js
@@ -0,0 +1,25 @@
+var pandora = {
+ format: getFormat(),
+ hostname: document.location.hostname || 'pad.ma'
+}
+
+var pandoraURL = document.location.hostname ? "" : `https://${pandora.hostname}`
+var cache = cache || {}
+
+async function pandoraAPI(action, data) {
+ var url = pandoraURL + '/api/'
+ var key = JSON.stringify([action, data])
+ if (!cache[key]) {
+ var response = await fetch(url, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ action: action,
+ data: data
+ })
+ })
+ cache[key] = await response.json()
+ }
+ return cache[key]
+}
+
diff --git a/static/mobile/js/documents.js b/static/mobile/js/documents.js
new file mode 100644
index 00000000..2d58b0ff
--- /dev/null
+++ b/static/mobile/js/documents.js
@@ -0,0 +1,112 @@
+
+async function loadDocument(id, args) {
+ var data = window.data = {}
+ var parts = id.split('/')
+ data.id = parts.shift()
+ data.site = pandora.hostname
+
+ if (parts.length == 2) {
+ data.page = parts.shift()
+ }
+
+ if (parts.length == 1) {
+ var rect = parts[0].split(',')
+ if (rect.length == 1) {
+ data.page = parts[0]
+ } else {
+ data.crop = rect
+ }
+ } else if (parts.length == 2) {
+
+ }
+
+ var response = await pandoraAPI('getDocument', {
+ id: data.id,
+ keys: [
+ "id",
+ "title",
+ "extension",
+ "text",
+ ]
+ })
+ if (response.status.code != 200) {
+ return {
+ site: data.site,
+ error: response.status
+ }
+ }
+ data.document = response['data']
+ data.title = data.document.name
+ data.link = `${pandora.proto}://${data.site}/documents/${data.document.id}`
+ return data
+}
+
+async function renderDocument(data) {
+ if (data.error) {
+ return renderError(data)
+ }
+ div = document.createElement('div')
+ div.className = "content"
+ if (!data.document) {
+ div.innerHTML = ``
+ } else if (data.document.extension == "html") {
+ div.innerHTML = `
+ ${data.document.title}
+
+ ${data.document.text}
+
+
+ `
+ div.querySelectorAll('.text a').forEach(a => {
+ a.addEventListener("click", clickLink)
+
+ })
+ } else if (data.document.extension == "pdf" && data.page && data.crop) {
+ var img = `${pandora.proto}://${data.site}/documents/${data.document.id}/1024p${data.page},${data.crop.join(',')}.jpg`
+ data.link = getLink(`documents/${data.document.id}/${data.page}`)
+ div.innerHTML = `
+ ${data.document.title}
+
+
+
+
+ `
+ } else if (data.document.extension == "pdf") {
+ var page = data.page || 1,
+ file = encodeURIComponent(`/documents/${data.document.id}/${safeDocumentName(data.document.title)}.pdf`)
+ div.innerHTML = `
+ ${data.document.title}
+
+
+
+
+ `
+ } else if (data.document.extension == "jpg" || data.document.extension == "png") {
+ var img = `${pandora.proto}://${data.site}/documents/${data.document.id}/${safeDocumentName(data.document.title)}.${data.document.extension}`
+ var open_text = `Open on ${data.site}`
+ if (data.crop) {
+ img = `${pandora.proto}://${data.site}/documents/${data.document.id}/1024p${data.crop.join(',')}.jpg`
+ data.link = getLink(`documents/${data.document.id}`)
+ open_text = `Open image`
+ }
+ div.innerHTML = `
+ ${data.document.title}
+
+
+
+
+ `
+
+ } else {
+ div.innerHTML = `unsupported document type`
+ }
+ document.querySelector(".content").replaceWith(div)
+}
diff --git a/static/mobile/js/edits.js b/static/mobile/js/edits.js
new file mode 100644
index 00000000..f23766ef
--- /dev/null
+++ b/static/mobile/js/edits.js
@@ -0,0 +1,230 @@
+
+const getSortValue = function(value) {
+ var sortValue = value;
+ function trim(value) {
+ return value.replace(/^\W+(?=\w)/, '');
+ }
+ if (
+ isEmpty(value)
+ || isNull(value)
+ || isUndefined(value)
+ ) {
+ sortValue = null;
+ } else if (isString(value)) {
+ // make lowercase and remove leading non-word characters
+ sortValue = trim(value.toLowerCase());
+ // move leading articles to the end
+ // and remove leading non-word characters
+ ['a', 'an', 'the'].forEach(function(article) {
+ if (new RegExp('^' + article + ' ').test(sortValue)) {
+ sortValue = trim(sortValue.slice(article.length + 1))
+ + ', ' + sortValue.slice(0, article.length);
+ return false; // break
+ }
+ });
+ // remove thousand separators and pad numbers
+ sortValue = sortValue.replace(/(\d),(?=(\d{3}))/g, '$1')
+ .replace(/\d+/g, function(match) {
+ return match.padStart(64, '0')
+ });
+ }
+ return sortValue;
+};
+
+const sortByKey = function(array, by) {
+ return array.sort(function(a, b) {
+ var aValue, bValue, index = 0, key, ret = 0;
+ while (ret == 0 && index < by.length) {
+ key = by[index].key;
+ aValue = getSortValue(a[key])
+ bValue = getSortValue(b[key])
+ if ((aValue === null) != (bValue === null)) {
+ ret = aValue === null ? 1 : -1;
+ } else if (aValue < bValue) {
+ ret = by[index].operator == '+' ? -1 : 1;
+ } else if (aValue > bValue) {
+ ret = by[index].operator == '+' ? 1 : -1;
+ } else {
+ index++;
+ }
+ }
+ return ret;
+ });
+};
+
+async function sortClips(edit, sort) {
+ var key = sort.key, index;
+ if (key == 'position') {
+ key = 'in';
+ }
+ if ([
+ 'id', 'index', 'in', 'out', 'duration',
+ 'title', 'director', 'year', 'videoRatio'
+ ].indexOf(key) > -1) {
+ sortBy(sort);
+ index = 0;
+ edit.clips.forEach(function(clip) {
+ clip.sort = index++;
+ if (sort.operator == '-') {
+ clip.sort = -clip.sort;
+ }
+ });
+ } else {
+ var response = await pandoraAPI('sortClips', {
+ edit: edit.id,
+ sort: [sort]
+ })
+ edit.clips.forEach(function(clip) {
+ clip.sort = response.data.clips.indexOf(clip.id);
+ if (sort.operator == '-') {
+ clip.sort = -clip.sort;
+ }
+ });
+ sortBy({
+ key: 'sort',
+ operator: '+'
+ });
+ }
+ function sortBy(key) {
+ edit.clips = sortByKey(edit.clips, [key]);
+ }
+}
+
+async function loadEdit(id, args) {
+ var data = window.data = {}
+ data.id = id
+ data.site = pandora.hostname
+
+ var response = await pandoraAPI('getEdit', {
+ id: data.id,
+ keys: [
+ ]
+ })
+ if (response.status.code != 200) {
+ return {
+ site: data.site,
+ error: response.status
+ }
+ }
+ data.edit = response['data']
+ if (data.edit.status !== 'public') {
+ return {
+ site: data.site,
+ error: {
+ code: 403,
+ text: 'permission denied'
+ }
+ }
+ }
+ data.layers = {}
+ data.videos = []
+
+ if (args.sort) {
+ await sortClips(data.edit, args.sort)
+ }
+
+ data.edit.duration = 0;
+ data.edit.clips.forEach(function(clip) {
+ clip.position = data.edit.duration;
+ data.edit.duration += clip.duration;
+ });
+
+ data.edit.clips.forEach(clip => {
+ var start = clip['in'] || 0, end = clip.out, position = 0;
+ clip.durations.forEach((duration, idx) => {
+ if (!duration) {
+ return
+ }
+ if (position + duration <= start || position > end) {
+ // pass
+ } else {
+ var video = {}
+ var oshash = clip.streams[idx]
+ video.src = getVideoURL(clip.item, 480, idx+1, '', oshash)
+ /*
+ if (clip['in'] && clip.out) {
+ video.src += `#t=${clip['in']},${clip.out}`
+ }
+ */
+ if (isNumber(clip.volume)) {
+ video.volume = clip.volume;
+ }
+ if (
+ position <= start
+ && position + duration > start
+ ) {
+ video['in'] = start - position;
+ }
+ if (position + duration >= end) {
+ video.out = end - position;
+ }
+ if (video['in'] && video.out) {
+ video.duration = video.out - video['in']
+ } else if (video.out) {
+ video.duration = video.out;
+ } else if (!isUndefined(video['in'])) {
+ video.duration = duration - video['in'];
+ video.out = duration;
+ } else {
+ video.duration = duration;
+ video['in'] = 0;
+ video.out = video.duration;
+ }
+ data.videos.push(video)
+ }
+ position += duration
+ })
+ Object.keys(clip.layers).forEach(layer => {
+ data.layers[layer] = data.layers[layer] || []
+ clip.layers[layer].forEach(annotation => {
+ if (args.users && !args.users.includes(annotation.user)) {
+ return
+ }
+ if (args.layers && !args.layers.includes(layer)) {
+ return
+ }
+ var a = {...annotation}
+ a['id'] = clip['id'] + '/' + a['id'];
+ a['in'] = Math.max(
+ clip['position'],
+ a['in'] - clip['in'] + clip['position']
+ );
+ a.out = Math.min(
+ clip['position'] + clip['duration'],
+ a.out - clip['in'] + clip['position']
+ );
+ data.layers[layer].push(a)
+ })
+ })
+ })
+ var value = []
+ pandora.layerKeys.forEach(layer => {
+ if (!data.layers[layer]) {
+ return
+ }
+ var html = []
+ var layerData = getObjectById(pandora.site.layers, layer)
+ html.push(`
+ ${icon.down}
+ ${layerData.title}
+
`)
+ data.layers[layer].forEach(annotation => {
+ html.push(`
+
+ ${annotation.value}
+
+ `)
+ })
+ value.push('' + html.join('\n') + '
')
+ })
+ data.value = value.join('\n')
+
+ data.title = data.edit.name
+ data.byline = data.edit.description
+ data.link = `${pandora.proto}://${data.site}/edits/${data.edit.id}`
+ data.poster = data.videos[0].src.split('/48')[0] + `/480p${data.videos[0].in}.jpg`
+ data.aspectratio = data.edit.clips[0].videoRatio
+ data.duration = data.edit.duration
+ return data
+
+}
diff --git a/static/mobile/js/icons.js b/static/mobile/js/icons.js
new file mode 100644
index 00000000..295a12fe
--- /dev/null
+++ b/static/mobile/js/icons.js
@@ -0,0 +1,182 @@
+var icon = {}
+icon.enterFullscreen = `
+
+`
+icon.exitFullscreen = `
+
+`
+
+icon.mute = `
+
+
+`
+
+icon.unmute = `
+
+
+`
+
+icon.play = `
+
+`
+icon.pause = `
+
+
+`
+icon.loading = `
+
+`
+
+icon.right = `
+
+`
+icon.left = `
+
+`
+icon.down = `
+
+`
diff --git a/static/mobile/js/item.js b/static/mobile/js/item.js
new file mode 100644
index 00000000..61463aa5
--- /dev/null
+++ b/static/mobile/js/item.js
@@ -0,0 +1,133 @@
+
+async function loadData(id, args) {
+ var data = window.data = {}
+ data.id = id
+ data.site = pandora.hostname
+
+ var response = await pandoraAPI('get', {
+ id: data.id.split('/')[0],
+ keys: [
+ "id",
+ "title",
+ "director",
+ "summary",
+ "streams",
+ "duration",
+ "durations",
+ "layers",
+ "rightslevel",
+ "videoRatio"
+ ]
+ })
+
+ if (response.status.code != 200) {
+ return {
+ site: data.site,
+ error: response.status
+ }
+ }
+ data.item = response['data']
+ if (data.item.rightslevel > pandora.site.capabilities.canPlayClips['guest']) {
+ return {
+ site: data.site,
+ error: {
+ code: 403,
+ text: 'permission denied'
+ }
+ }
+ }
+ if (id.split('/').length == 1 || id.split('/')[1] == 'info') {
+ data.view = 'info'
+ data.title = data.item.title
+ data.byline = data.item.director ? data.item.director.join(', ') : ''
+ data.link = `${pandora.proto}://${data.site}/${data.item.id}/info`
+ let poster = pandora.site.user.ui.icons == 'posters' ? 'poster' : 'icon'
+ data.icon = `${pandora.proto}://${data.site}/${data.item.id}/${poster}.jpg`
+ return data
+ }
+
+ if (id.includes('-') || id.includes(',')) {
+ var inout = id.split('/')[1].split(id.includes('-') ? '-' : ',').map(parseDuration)
+ data.out = inout.pop()
+ data['in'] = inout.pop()
+ } else if (args.full) {
+ data.out = data.item.duration
+ data['in'] = 0
+ } else {
+ var annotation = await pandoraAPI('getAnnotation', {
+ id: data.id,
+ })
+ if (annotation.status.code != 200) {
+ return {
+ site: data.site,
+ error: annotation.status
+ }
+ }
+ data.annotation = annotation['data']
+ data['in'] = data.annotation['in']
+ data.out = data.annotation['out']
+ }
+
+ data.layers = {}
+
+ pandora.layerKeys.forEach(layer => {
+ data.item.layers[layer].forEach(annot => {
+ if (data.annotation) {
+ if (annot.id == data.annotation.id) {
+ data.layers[layer] = data.layers[layer] || []
+ data["layers"][layer].push(annot)
+ }
+ } else if (annot['out'] > data['in'] && annot['in'] < data['out']) {
+ if (args.users && !args.users.includes(annot.user)) {
+ return
+ }
+ if (args.layers && !args.layers.includes(layer)) {
+ return
+ }
+ data.layers[layer] = data.layers[layer] || []
+ //annot['in'] = Math.max([annot['in'], data['in']])
+ //annot['out'] = Math.min([annot['out'], data['out']])
+ data["layers"][layer].push(annot)
+ }
+ })
+ })
+ data.videos = []
+ data.item.durations.forEach((duration, idx) => {
+ var oshash = data.item.streams[idx]
+ var url = getVideoURL(data.item.id, 480, idx+1, '', oshash)
+ data.videos.push({
+ src: url,
+ duration: duration
+ })
+ })
+ var value = []
+ Object.keys(data.layers).forEach(layer => {
+ var html = []
+ var layerData = getObjectById(pandora.site.layers, layer)
+ html.push(`
+ ${icon.down}
+ ${layerData.title}
+
`)
+ data.layers[layer].forEach(annotation => {
+ html.push(`
+
+ ${annotation.value}
+
+ `)
+ })
+ value.push('' + html.join('\n') + '
')
+ })
+ data.value = value.join('\n')
+
+ data.title = data.item.title
+ data.byline = data.item.director ? data.item.director.join(', ') : ''
+ data.link = `${pandora.proto}://${data.site}/${data.item.id}/${data["in"]},${data.out}`
+ data.poster = `${pandora.proto}://${data.site}/${data.item.id}/480p${data["in"]}.jpg`
+ data.aspectratio = data.item.videoRatio
+ if (data['in'] == data['out']) {
+ data['out'] += 0.04
+ }
+ data.duration = data.out - data['in']
+ return data
+}
+
diff --git a/static/mobile/js/main.js b/static/mobile/js/main.js
new file mode 100644
index 00000000..c69a9f79
--- /dev/null
+++ b/static/mobile/js/main.js
@@ -0,0 +1,129 @@
+
+
+function parseURL() {
+ var fragment = document.location.hash.slice(1)
+ if (!fragment && document.location.pathname.startsWith('/m/')) {
+ var prefix = document.location.protocol + '//' + document.location.hostname + '/m/'
+ fragment = document.location.href.slice(prefix.length)
+ }
+ var args = fragment.split('?')
+ var id = args.shift()
+ if (args) {
+ args = args.join('?').split('&').map(arg => {
+ var kv = arg.split('=')
+ k = kv.shift()
+ v = kv.join('=')
+ if (['users', 'layers'].includes(k)) {
+ v = v.split(',')
+ }
+ return [k, v]
+ }).filter(kv => {
+ return kv[0].length
+ })
+ if (args) {
+ args = Object.fromEntries(args);
+ } else {
+ args = {}
+ }
+ } else {
+ args = {}
+ }
+ var type = "item"
+ if (id.startsWith('document')) {
+ id = id.split('/')
+ id.shift()
+ id = id.join('/')
+ type = "document"
+ } else if (id.startsWith('edits/')) {
+ var parts = id.split('/')
+ parts.shift()
+ id = parts.shift().replace(/_/g, ' ')
+ type = "edit"
+ if (parts.length >= 2) {
+ args.sort = parts[1]
+ if (args.sort[0] == '-') {
+ args.sort = {
+ key: args.sort.slice(1),
+ operator: '-'
+ }
+ } else if (args.sort[0] == '+') {
+ args.sort = {
+ key: args.sort.slice(1),
+ operator: '+'
+ }
+ } else {
+ args.sort = {
+ key: args.sort,
+ operator: '+'
+ }
+ }
+ }
+ args.parts = parts
+ } else {
+ if (id.endsWith('/player') || id.endsWith('/editor')) {
+ args.full = true
+ }
+ id = id.replace('/editor/', '/').replace('/player/', '/')
+ type = "item"
+ }
+ return [type, id, args]
+}
+
+function render() {
+ var type, id, args;
+ [type, id, args] = parseURL()
+ document.querySelector(".content").innerHTML = loadingScreen
+ if (type == "document") {
+ loadDocument(id, args).then(renderDocument)
+ } else if (type == "edit") {
+ loadEdit(id, args).then(renderItem)
+ } else {
+ loadData(id, args).then(renderItem)
+ }
+
+}
+var loadingScreen = `
+
+ ${icon.loading}
+`
+
+document.querySelector(".content").innerHTML = loadingScreen
+pandoraAPI("init").then(response => {
+ pandora = {
+ ...pandora,
+ ...response.data
+ }
+ pandora.proto = pandora.site.site.https ? 'https' : 'http'
+ if (pandora.site.site.videoprefix.startsWith('//')) {
+ pandora.site.site.videoprefix = pandora.proto + ':' + pandora.site.site.videoprefix
+ }
+ var layerKeys = []
+ var subtitleLayer = pandora.site.layers.filter(layer => {return layer.isSubtitles})[0]
+ if (subtitleLayer) {
+ layerKeys.push(subtitleLayer.id)
+ }
+ pandora.site.layers.map(layer => {
+ return layer.id
+ }).filter(layer => {
+ return !subtitleLayer || layer != subtitleLayer.id
+ }).forEach(layer => {
+ layerKeys.push(layer)
+ })
+ pandora.layerKeys = layerKeys
+ id = document.location.hash.slice(1)
+ window.addEventListener("hashchange", event => {
+ render()
+ })
+ window.addEventListener("popstate", event => {
+ console.log("popsatte")
+ render()
+ })
+ window.addEventListener('resize', event => {
+ })
+ render()
+})
diff --git a/static/mobile/js/render.js b/static/mobile/js/render.js
new file mode 100644
index 00000000..a7519342
--- /dev/null
+++ b/static/mobile/js/render.js
@@ -0,0 +1,120 @@
+
+function renderItemInfo(data) {
+ div = document.createElement('div')
+ div.className = "content"
+ div.innerHTML = `
+ ${data.title}
+ ${data.byline}
+
+
+ `
+ document.querySelector(".content").replaceWith(div)
+}
+
+function renderItem(data) {
+ if (data.error) {
+ return renderError(data)
+ }
+ if (data.view == "info") {
+ return renderItemInfo(data)
+ }
+ div = document.createElement('div')
+ div.className = "content"
+ div.innerHTML = `
+ ${data.title}
+ ${data.byline}
+
+ ${data.value}
+
+ `
+ var comments = `
+
+ `
+
+ div.querySelectorAll('.layer a').forEach(a => {
+ a.addEventListener("click", clickLink)
+ })
+
+ div.querySelectorAll('.layer').forEach(layer => {
+ layer.querySelector('h3').addEventListener("click", event => {
+ var img = layer.querySelector('h3 .icon')
+ if (layer.classList.contains("collapsed")) {
+ layer.classList.remove("collapsed")
+ img.innerHTML = icon.down
+ } else {
+ layer.classList.add("collapsed")
+ img.innerHTML = icon.right
+ }
+ })
+ })
+
+ var video = window.video = VideoPlayer({
+ items: data.videos,
+ poster: data.poster,
+ position: data["in"] || 0,
+ duration: data.duration,
+ aspectratio: data.aspectratio
+ })
+ div.querySelector('.video').replaceWith(video)
+ video.classList.add('video')
+
+ video.addEventListener("loadedmetadata", event => {
+ //
+ })
+ video.addEventListener("timeupdate", event => {
+ var currentTime = video.currentTime()
+ if (currentTime >= data['out']) {
+ if (!video.paused) {
+ video.pause()
+ }
+ video.currentTime(data['in'])
+ }
+ div.querySelectorAll('.annotation').forEach(annot => {
+ var now = currentTime
+ var start = parseFloat(annot.dataset.in)
+ var end = parseFloat(annot.dataset.out)
+ if (now >= start && now <= end) {
+ annot.classList.add("active")
+ annot.parentElement.classList.add('active')
+ } else {
+ annot.classList.remove("active")
+ if (!annot.parentElement.querySelector('.active')) {
+ annot.parentElement.classList.remove('active')
+ }
+ }
+ })
+
+ })
+ document.querySelector(".content").replaceWith(div)
+}
+
+function renderError(data) {
+ var link = '/' + document.location.hash.slice(1)
+ div = document.createElement('div')
+ div.className = "content"
+ div.innerHTML = `
+
+
+ `
+ document.querySelector(".content").replaceWith(div)
+}
diff --git a/static/mobile/js/utils.js b/static/mobile/js/utils.js
new file mode 100644
index 00000000..d77024f1
--- /dev/null
+++ b/static/mobile/js/utils.js
@@ -0,0 +1,160 @@
+
+const parseDuration = function(string) {
+ return string.split(':').reverse().slice(0, 4).reduce(function(p, c, i) {
+ return p + (parseFloat(c) || 0) * (i == 3 ? 86400 : Math.pow(60, i));
+ }, 0);
+};
+
+const formatDuration = function(seconds) {
+ var parts = [
+ parseInt(seconds / 86400),
+ parseInt(seconds % 86400 / 3600),
+ parseInt(seconds % 3600 / 60),
+ s = parseInt(seconds % 60)
+ ]
+ return parts.map(p => { return p.toString().padStart(2, '0')}).join(':')
+}
+
+const typeOf = function(value) {
+ return Object.prototype.toString.call(value).slice(8, -1).toLowerCase();
+};
+const isUndefined = function(value) {
+ return typeOf(value) == 'undefined';
+}
+const isNumber = function(value) {
+ return typeOf(value) == 'number';
+};
+const isObject = function(value) {
+ return typeOf(value) == 'object';
+};
+const isNull = function(value) {
+ return typeOf(value) == 'null';
+};
+const isString = function(value) {
+ return typeOf(value) == 'string';
+};
+const isEmpty = function(value) {
+ var type = typeOf(value)
+ if (['arguments', 'array', 'nodelist', 'string'].includes(value)) {
+ return value.length == 0
+ }
+ if (['object', 'storage'].includes(type)) {
+ return Object.keys(value).length;
+ }
+ return false
+};
+const mod = function(number, by) {
+ return (number % by + by) % by;
+};
+
+const getObjectById = function(array, id) {
+ return array.filter(obj => { return obj.id == id})[0]
+}
+
+const debug = function() {
+ if (localStorage.debug) {
+ console.log.apply(null, arguments)
+ }
+};
+
+const canPlayMP4 = function() {
+ var video = document.createElement('video');
+ if (video.canPlayType && video.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace('no', '')) {
+ return true
+ }
+ return false
+};
+
+const canPlayWebm = function() {
+ var video = document.createElement('video');
+ if (video.canPlayType && video.canPlayType('video/webm; codecs="vp8, vorbis"').replace('no', '')) {
+ return true
+ }
+ return false
+};
+
+const getFormat = function() {
+ //var format = canPlayWebm() ? "webm" : "mp4"
+ var format = canPlayMP4() ? "mp4" : "webm"
+ return format
+}
+
+const safeDocumentName = function(name) {
+ ['\\?', '#', '%', '/'].forEach(function(c) {
+ var r = new RegExp(c, 'g')
+ name = name.replace(r, '_');
+ })
+ return name;
+};
+
+const getVideoInfo = function() {
+ console.log("FIXME implement getvideoInfo")
+}
+
+const isIOS = /(iPad|iPhone|iPod)/g.test(navigator.userAgent);
+
+
+const getLink = function(fragment) {
+ if (document.location.hash.length > 2) {
+ return '#' + fragment
+ } else {
+ return '/m/' + fragment
+ }
+}
+
+const clickLink = function(event) {
+ var a = event.target
+ while (a && a.tagName != 'A') {
+ a = a.parentElement
+ }
+ if (!a) {
+ return
+ }
+ var href = a.attributes.href.value
+ var prefix = document.location.protocol + '//' + document.location.hostname
+ if (href.startsWith(prefix)) {
+ href = href.slice(prefix.length)
+ }
+ if (href.startsWith('/')) {
+ event.preventDefault()
+ event.stopPropagation()
+ var link = href.split('#embed')[0]
+ if (document.location.hash.length > 2) {
+ if (link.startsWith('/m')) {
+ link = link.slice(2)
+ }
+ document.location.hash = '#' + link.slice(1)
+ } else {
+ if (!link.startsWith('/m')) {
+ link = '/m' + link
+ }
+ history.pushState({}, '', link);
+ render()
+ }
+ }
+}
+
+const getUid = (function() {
+ var uid = 0;
+ return function() {
+ return ++uid;
+ };
+}());
+
+
+const getVideoURLName = function(id, resolution, part, track, streamId) {
+ return id + '/' + resolution + 'p' + part + (track ? '.' + track : '')
+ + '.' + pandora.format + (streamId ? '?' + streamId : '');
+};
+
+const getVideoURL = function(id, resolution, part, track, streamId) {
+ var uid = getUid(),
+ prefix = pandora.site.site.videoprefix
+ .replace('{id}', id)
+ .replace('{part}', part)
+ .replace('{resolution}', resolution)
+ .replace('{uid}', uid)
+ .replace('{uid42}', uid % 42);
+ return prefix + '/' + getVideoURLName(id, resolution, part, track, streamId);
+};
+
diff --git a/update.py b/update.py
index 2cc118f6..5cf0dfca 100755
--- a/update.py
+++ b/update.py
@@ -303,6 +303,8 @@ if __name__ == "__main__":
run('./bin/pip', 'install', 'yt-dlp>=2022.3.8.2')
if old < 6465:
run('./bin/pip', 'install', '-r', 'requirements.txt')
+ if old < 6507:
+ run('./bin/pip', 'install', '-r', 'requirements.txt')
else:
if len(sys.argv) == 1:
branch = get_branch()
+