From bea0d301a4c22eee402d4aee8f9c5a23a408e0b3 Mon Sep 17 00:00:00 2001 From: j Date: Sat, 15 Jul 2023 12:04:04 +0530 Subject: [PATCH] add share link at /m/, add share dialog in view menu, fix preview for documents --- pandora/clip/models.py | 6 + pandora/document/models.py | 2 +- pandora/document/views.py | 33 +- pandora/mobile/__init__.py | 0 pandora/mobile/admin.py | 3 + pandora/mobile/apps.py | 5 + pandora/mobile/migrations/__init__.py | 0 pandora/mobile/models.py | 3 + pandora/mobile/tests.py | 3 + pandora/mobile/views.py | 75 +++ pandora/settings.py | 7 +- pandora/templates/document.html | 38 ++ pandora/templates/mobile/index.html | 45 ++ pandora/urls.py | 3 + requirements.txt | 3 + static/js/mainMenu.js | 5 +- static/js/shareDialog.js | 37 ++ static/mobile/css/reset.css | 46 ++ static/mobile/css/style.css | 171 ++++++ static/mobile/js/VideoElement.js | 730 ++++++++++++++++++++++++++ static/mobile/js/VideoPlayer.js | 400 ++++++++++++++ static/mobile/js/api.js | 25 + static/mobile/js/documents.js | 112 ++++ static/mobile/js/edits.js | 230 ++++++++ static/mobile/js/icons.js | 182 +++++++ static/mobile/js/item.js | 133 +++++ static/mobile/js/main.js | 129 +++++ static/mobile/js/render.js | 120 +++++ static/mobile/js/utils.js | 160 ++++++ update.py | 2 + 30 files changed, 2704 insertions(+), 4 deletions(-) create mode 100644 pandora/mobile/__init__.py create mode 100644 pandora/mobile/admin.py create mode 100644 pandora/mobile/apps.py create mode 100644 pandora/mobile/migrations/__init__.py create mode 100644 pandora/mobile/models.py create mode 100644 pandora/mobile/tests.py create mode 100644 pandora/mobile/views.py create mode 100644 pandora/templates/document.html create mode 100644 pandora/templates/mobile/index.html create mode 100644 static/js/shareDialog.js create mode 100644 static/mobile/css/reset.css create mode 100644 static/mobile/css/style.css create mode 100644 static/mobile/js/VideoElement.js create mode 100644 static/mobile/js/VideoPlayer.js create mode 100644 static/mobile/js/api.js create mode 100644 static/mobile/js/documents.js create mode 100644 static/mobile/js/edits.js create mode 100644 static/mobile/js/icons.js create mode 100644 static/mobile/js/item.js create mode 100644 static/mobile/js/main.js create mode 100644 static/mobile/js/render.js create mode 100644 static/mobile/js/utils.js diff --git a/pandora/clip/models.py b/pandora/clip/models.py index 8ae0cd119..e66408a7d 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 30006ecc8..326b93872 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 47bf5bd85..112f1595b 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 000000000..e69de29bb diff --git a/pandora/mobile/admin.py b/pandora/mobile/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /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 000000000..13970a589 --- /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 000000000..e69de29bb diff --git a/pandora/mobile/models.py b/pandora/mobile/models.py new file mode 100644 index 000000000..71a836239 --- /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 000000000..7ce503c2d --- /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 000000000..fbc0f44c0 --- /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 f6fba9f7e..eae5c9421 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 000000000..c893d3639 --- /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 000000000..5cda3d825 --- /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 d8eba0496..36af1aa05 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 66f20ff05..737f77ce6 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 a7906d8a1..457f168de 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 000000000..cf399f2b3 --- /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 000000000..b84ca0d85 --- /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 000000000..811b250cc --- /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 000000000..8238d95df --- /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 000000000..829fc02f6 --- /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.play}
+
+
+
+ ${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 = ` +
+
${icon.play}
+
+
+ ` + 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 000000000..5e989597d --- /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 000000000..2d58b0ff5 --- /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 = `
document not found
` + } 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 000000000..f23766ef4 --- /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 000000000..295a12fee --- /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 000000000..61463aa53 --- /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 000000000..c69a9f797 --- /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 000000000..a7519342b --- /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}
+ +
+ +
+ + ` + 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.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 = ` + +
+ Page not found
+ Open on ${data.site} +
+ ` + document.querySelector(".content").replaceWith(div) +} diff --git a/static/mobile/js/utils.js b/static/mobile/js/utils.js new file mode 100644 index 000000000..d77024f1a --- /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 2cc118f69..5cf0dfcad 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()