From abc307bfadab93845b1b845724b3d519f52c34f8 Mon Sep 17 00:00:00 2001 From: Dmitry Tretyakov <76806170+tretdm@users.noreply.github.com> Date: Sun, 29 Oct 2023 21:41:36 +0700 Subject: [PATCH 01/21] Update PHP 8 testing status --- README_RU.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_RU.md b/README_RU.md index cc4f672f..6da17408 100644 --- a/README_RU.md +++ b/README_RU.md @@ -30,7 +30,7 @@ _[English](README.md)_ 1. Установите PHP 7.4, веб-сервер, Composer, Node.js, Yarn и [Chandler](https://github.com/openvk/chandler) -* PHP 8 еще **не** тестировался, поэтому не стоит ожидать, что он будет работать (UPD: он не работает). +* PHP 8 is still being tested; performance on this version of PHP is not yet guaranteed. 2. Установите MySQL-совместимую базу данных. From 46f7e7ceebc11b009f11bed3a4b2df2cb097064e Mon Sep 17 00:00:00 2001 From: Dmitry Tretyakov <76806170+tretdm@users.noreply.github.com> Date: Sun, 29 Oct 2023 21:42:55 +0700 Subject: [PATCH 02/21] Update PHP 8 testing status --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 152e05bf..934ff0b7 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ If you want, you can add your instance to the list above so that people can regi 1. Install PHP 7.4, web-server, Composer, Node.js, Yarn and [Chandler](https://github.com/openvk/chandler) -* PHP 8.1 is supported too, however it was not tested carefully, so be aware. +* PHP 8 is still being tested; the functionality of the engine on this version of PHP is not yet guaranteed. 2. Install MySQL-compatible database. From 1a17a245c5dab15bf04e0b7bd4950f33fd580ed5 Mon Sep 17 00:00:00 2001 From: Dmitry Tretyakov <76806170+tretdm@users.noreply.github.com> Date: Sun, 29 Oct 2023 21:55:02 +0700 Subject: [PATCH 03/21] i forgor momento --- README_RU.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_RU.md b/README_RU.md index 6da17408..7de91c39 100644 --- a/README_RU.md +++ b/README_RU.md @@ -30,7 +30,7 @@ _[English](README.md)_ 1. Установите PHP 7.4, веб-сервер, Composer, Node.js, Yarn и [Chandler](https://github.com/openvk/chandler) -* PHP 8 is still being tested; performance on this version of PHP is not yet guaranteed. +* PHP 8 пока ещё тестируется, работоспособность движка на этой версии PHP пока не гарантируется. 2. Установите MySQL-совместимую базу данных. From 9a4309ae01588e9bc2e9926bd538016f32e4bf76 Mon Sep 17 00:00:00 2001 From: Vladimir Barinov <veselcraft@icloud.com> Date: Tue, 31 Oct 2023 00:13:36 +0300 Subject: [PATCH 04/21] =?UTF-8?q?=D1=89=D0=B0=D1=81=20=D0=B4=D0=BE=D0=BB?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=20=D0=BD=D0=BE=D1=83=D1=81=D0=BF=D0=B0=D0=BC?= =?UTF-8?q?=20=D0=B7=D0=B0=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Web/Models/Entities/PasswordReset.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Web/Models/Entities/PasswordReset.php b/Web/Models/Entities/PasswordReset.php index cf0e73ea..372c63f8 100644 --- a/Web/Models/Entities/PasswordReset.php +++ b/Web/Models/Entities/PasswordReset.php @@ -54,11 +54,11 @@ class PasswordReset extends RowModel } } - function save(): void + function save(?bool $log = false): void { $this->stateChanges("key", base64_encode(openssl_random_pseudo_bytes(46))); $this->stateChanges("timestamp", time()); - parent::save(); + parent::save($log); } } From fd11dfcdd919854f2d5c4834413a0a1a81b01bbe Mon Sep 17 00:00:00 2001 From: Vladimir Barinov <veselcraft@icloud.com> Date: Tue, 31 Oct 2023 00:14:18 +0300 Subject: [PATCH 05/21] =?UTF-8?q?=D0=B2=D1=8B=20=D0=B4=D0=BE=D0=BB=D0=B1?= =?UTF-8?q?=D0=BE=D1=91=D0=B1=D1=8B=20=D0=B1=D0=BB=D1=8F=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B2=D1=8B=20=D0=BD=D0=B0=D1=85=D1=83=D1=8F=20=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=B8=D0=BB=D0=B8=20=D0=B4=D0=B5=D0=B1=D0=B0?= =?UTF-8?q?=D0=B3=20=D1=84=D0=B8=D1=87=D1=83=20=D0=B2=20=D1=80=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Web/Models/Repositories/Messages.php | 1 - 1 file changed, 1 deletion(-) diff --git a/Web/Models/Repositories/Messages.php b/Web/Models/Repositories/Messages.php index 4c870806..538338ed 100644 --- a/Web/Models/Repositories/Messages.php +++ b/Web/Models/Repositories/Messages.php @@ -52,7 +52,6 @@ class Messages $query = file_get_contents(__DIR__ . "/../sql/get-correspondencies-count.tsql"); DatabaseConnection::i()->getConnection()->query(file_get_contents(__DIR__ . "/../sql/mysql-msg-fix.tsql")); $count = DatabaseConnection::i()->getConnection()->query($query, $id, $class, $id, $class)->fetch()->cnt; - bdump($count); return $count; } } From a2473c68fe8522a8fc39a95ab01c1e66eb8e6c2e Mon Sep 17 00:00:00 2001 From: veselcraft <veselcraft@icloud.com> Date: Wed, 1 Nov 2023 14:52:24 +0300 Subject: [PATCH 06/21] VKAPI: PHP 8.2 fixes --- Web/Presenters/VKAPIPresenter.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Web/Presenters/VKAPIPresenter.php b/Web/Presenters/VKAPIPresenter.php index 431379c5..963c9ccc 100644 --- a/Web/Presenters/VKAPIPresenter.php +++ b/Web/Presenters/VKAPIPresenter.php @@ -233,8 +233,13 @@ final class VKAPIPresenter extends OpenVKPresenter $this->badMethodCall($object, $method, $parameter->getName()); } - settype($val, $parameter->getType()->getName()); - $params[] = $val; + try { + settype($val, $parameter->getType()->getName()); + $params[] = $val; + } catch (\Throwable $e) { + // Just ignore the exception, since + // some args are intended for internal use + } } define("VKAPI_DECL_VER", $this->requestParam("v") ?? "4.100", false); From 49d62543ba2374a61948176fc36392627e9881df Mon Sep 17 00:00:00 2001 From: lalka2018 <99399973+lalka2016@users.noreply.github.com> Date: Sun, 5 Nov 2023 15:19:06 +0300 Subject: [PATCH 07/21] Fix 500 in photos.get when album does not exists --- VKAPI/Handlers/Photos.php | 13 +++++-------- Web/Models/Repositories/Albums.php | 4 ++-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/VKAPI/Handlers/Photos.php b/VKAPI/Handlers/Photos.php index e3e9abac..bb1a22f0 100644 --- a/VKAPI/Handlers/Photos.php +++ b/VKAPI/Handlers/Photos.php @@ -432,13 +432,11 @@ final class Photos extends VKAPIRequestHandler if(empty($photo_ids)) { $album = (new Albums)->getAlbumByOwnerAndId($owner_id, $album_id); - if(!$album->getOwner()->getPrivacyPermission('photos.read', $this->getUser())) { - $this->fail(21, "This user chose to hide his albums."); - } - - if(!$album || $album->isDeleted()) { + if(!$album || $album->isDeleted()) $this->fail(21, "Invalid album"); - } + + if(!$album->getOwner()->getPrivacyPermission('photos.read', $this->getUser())) + $this->fail(21, "This user chose to hide his albums."); $photos = array_slice(iterator_to_array($album->getPhotos(1, $count + $offset)), $offset); $res["count"] = sizeof($photos); @@ -456,8 +454,7 @@ final class Photos extends VKAPIRequestHandler "items" => [] ]; - foreach($photos as $photo) - { + foreach($photos as $photo) { $id = explode("_", $photo); $phot = (new PhotosRepo)->getByOwnerAndVID((int)$id[0], (int)$id[1]); diff --git a/Web/Models/Repositories/Albums.php b/Web/Models/Repositories/Albums.php index b38ee462..f99848c4 100644 --- a/Web/Models/Repositories/Albums.php +++ b/Web/Models/Repositories/Albums.php @@ -130,7 +130,7 @@ class Albums "owner" => $owner, "id" => $id ])->fetch(); - - return new Album($album); + + return $album ? new Album($album) : NULL; } } From c9665ac77d7a7601e18e602e0346af0d4405bf31 Mon Sep 17 00:00:00 2001 From: lalka2018 <99399973+lalka2016@users.noreply.github.com> Date: Tue, 7 Nov 2023 20:32:04 +0300 Subject: [PATCH 08/21] Resolves #1017 --- Web/Models/Entities/User.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php index aaf00ec9..dc227039 100644 --- a/Web/Models/Entities/User.php +++ b/Web/Models/Entities/User.php @@ -720,8 +720,8 @@ class User extends RowModel for($i = 0; $i < 10 - $this->get2faBackupCodeCount(); $i++) { $codes[] = [ - owner => $this->getId(), - code => random_int(10000000, 99999999) + "owner" => $this->getId(), + "code" => random_int(10000000, 99999999) ]; } From 9d7a465d0d993319bf21b4af941d07b1228f9ef4 Mon Sep 17 00:00:00 2001 From: celestora <kitsuruko@gmail.com> Date: Sat, 11 Nov 2023 23:41:07 +0200 Subject: [PATCH 09/21] Music, finally! (#512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add audio upload feature * Add audio embed thing * Move bullet.gif to ovk * Draft some music API methods * Add support for base64 ids to Audios.getById * Disallow having more than 65k audios in playlist * Add playlist model * Draft some playlist-related API methods * Fix behabiour of album-related methods Generators f***** me in le a** * Add IDv3 autofill * Add sql dumps i forgor to upload it xdddd * Add playlists sql * Fix audio upload not working on Windows 11 because Windows is the worst operating system which doesn't work properly under any circumstances * Fix cocksex in audio.get yes * Интерфейсы * Interface updade * Update en.strings * Add audio queue * Make repeat button work * Some improvements to audio queue * Фгвшщ йгугу шьзкщмуьутеы * Make shuffle and "наушники" buttons work, add f... avicons when playing audio, save some values (like volume and last played track) to localstorage, add ability to toggle time type in player, fix uploading audios with cover (maybe) and add dragndrop to upload page * Add funny tip with time when hover track div * Add something * Add audios picker & move track in smal player вниз * Summary (required) Description * [WIP] Add calls, stories and clips. Изменены фавиконки (поменьше стали) У миниплеера ползунок теперь в стиле bsdn и большого плеера, добавлен ползунок громкости Добавлена кнопка добавления аудио в группу (у миниплеера) Если вы смотрите аудио группы, которой можете управлять, появляется кнопка "удалить аудио из группы" Снизу плейлиста в списке теперь показывается автор. При прикреплении аудиозаписей к посту теперь есть поиск "по композиции" и "по исполнителю" Добавил explicit.svg, который я забыл добавить в предыдущем коммите. Вкладочки немного переделаны При наведении на кнопки "трек вперёд" или "трек назад" показывается название предыдущего или следующего трека соответственно * 1 new commit to master: [WIP]: Add audios - Теперь группа может разрешать загружать всем треки в неё - Теперь треки загружаются на сервер ajax'ом, и так можно очень много аудио загружать - Вёрстка списка плейлистов изменена, теперь она на гридах - Немного изменено апи, теперь метод editAlbum сохраняет новую информацию ee объект плейлистов теперь возвращают реальное время - Удалены лишние пути из routes.yml - При переключении страниц теперь если на текущей странице есть играющий трек, он нормально подсвечивается - Из init-db.sql удалены таблицы аудиозаписей - В Groups.getSettings и groups.edit теперь есть информация о аудиозаписях * (смешное название коммита) - Теперь на странице пользователя/группы показываются три случайные песни, а не первые три как раньше - Теперь пробел на странице аудио не перемещает вас в низ страницы - Оптимизирован мини-плеер, теперь он инициализируется при любом нажатии на него, а не при наведении - Теперь при завершении проигрывания трека в мини-плеере он ищет другой трек рядом, и если находит то воспроизводит. Будет удобно для постов с подборками треков - Поиск теперь показывает 14 результатов - Теперь при возникновении ошибки загрузки аудио она нормально отображается - Вместе с плеером на странице с аудиозаписями теперь двигаются и вкладки - Добавление аудио в группу по идее должно нормально работать * Implement playlists listens - У плейлистов теперь есть прослушивания в общем. - Прослушивания у большого плеера теперь засчитываются, если трек был дослушан до конца - В объекте плейлистов теперь возвращается listens и cover_url - Получение плееров через /audios/context переписано, повторяющийся код удалён, правда сильно количество строк сократить не получилось - Теперь цвета плеера темнее, а иконка проигрывания изменена - Теперь, если очередь из треков кончилась, то плеер перенаправляет вас в начало очереди. * php 8.2 fixxxxxxxxxxxxxxxxxxxxxxx * Implement audiostatuses Добавлены аудиостатусы (у пользователей), блок с друзьями, слушающих музыку на странице аудиозаписей, объект status_audio в users.get, улучшены настройки приватности и ещё что-то * ? - Переделан метод в классе user для получения друзей с проигрываемыми песнями. Теперь среди них могут появляться и группы (хз стоит ли оставлять это или нет). Так же больше не показываются удалённые пользователи - Трек у плеера теперь двигается немного плавнее. Ещё теперь нету смешных багов с подсказкой времени, когда можно было увести её за экран или промотать дальше трека. Переключить повторение трека теперь можно нажатием кнопки R. - Длинное название трека больше не сносит время - Наверное, теперь аудиозаписи нормально отображаются в темах midnight и modern - Аудиозаписи больше не крашаются, если пользователь неавторизован. - Немного переделан миниплеер. - В миниплеере теперь громкость берётся из локалсторейджа. - Улучшено редактирование аудиозаписей. Теперь данные в дата атрибуты нормально сохраняются, а так же слова песни и метка "explicit" меняются - Удалён css, оставшийся ещё от public technical preview 1, а так же путь /audios{num} - При наведении на трек теперь пропадает время, и на его месте появляются кнопки - Стандартная аватарка в midnight теперь инвертируется - В админке в редактировании аудио теперь показывается дата редактирования, дата создания, длина и оригинальный файл аудио. Так же на странице редактирования больше нет вылетов, если вы задали несуществующий аккаунт * ! - Добавлены строки для мобильной темы - Добавлено предупреждение перед полным удалением плейлиста - Нажатие кнопки M = нажатие кнопки наушников - В классе апи Audio поставлены willExecuteWriteAction, ещё теперь нельзя получить число аудиозаписей у пользователей, которые их закрыли. Ещё теперь нельзя получать uploaded_only аудиозаписи у тех ну вы поняли короче. - При наведении на длинное название песни оно теперь показывается полностью - Надо ещё что-то сюда написать, так что: При редактировании аудиозаписи название окна теперь не "Редактировать", а "Редактировать аудиозапись", а вместо кнопки OK кнопка "Сохранить" * . - Добавлен тур по аудиозаписям, но пока без скриншотов. - "Мои Аудиозаписи" в меню теперь располагаются под Моими Видеозаписями для канона - В настройках приватности "кто может видеть мои аудиозаписи" теперь располагаются под "кто может видеть мои видеозаписи" - В настройках внешнего вида мои аудиозаписи тоже под видео - Изменён <title> на странице аудиозаписей. Теперь показывается "Аудиозаписи" + имя пользователя в родительном падеже. А если это группа, то "Аудиозаписи группы". То же самое с плейлистами - Исправлены ссылка в ссылке на странице с плейлистами - При наведении на название песни больше не сносится иконка explicit - Добавлена максимальная длина названия и описания плейлиста при редактировании. * М - Долокализована админка (точно помню, что уже делал это, но ладно) - Удалён лишний пункт "audios" в getLeftMenuItemStatus (реально) - Если. У плеера есть параметр "hideButtons", то при наведении на него не пропадает время. - На странице редактирования/создания плейлиста если у песни длинное название, то оно да похуй короче. Ну в общем лучше стало - Там где нужно, добавлена строка в конце файла - Возвращена строка "photo" в английской локали (я её случайно удалил :+1: ) * у - У изъятых аудиозаписей больше не показывается кнопка "добавить в группу". Так же при нажатии на кнопку удаления из коллекции окно не всплывает. - "Удаление аудио из группы" тоже лучше работать стало с изъятыми аудио. * з - В пикере аудиозаписей "more..." заменено на "показать больше аудиозаписей" - Если включен режим показа оставшегося времени, то при окончании песни больше не показывается "--1:--1" - В пикере аудиозаписей, если у вас нет аудиозаписей и вы ничего не искали, показывается "Вы ещё не добавляли аудиозаписей" - <hr>'ы стали серыми - Добавлены title'ы у кнопок в большом плеере - Проставлены alt'ы у плейлистов * Musique: linux saport) назар хуйню релизнул кста, плейерс клаб два не слушайте не рекомендую * Update and rename gamma-00000-disco.sql to 00041-music.sql * Update 00041-music.sql --------- Co-authored-by: Ilya Prokopenko <dsrev@protonmail.com> Co-authored-by: n1rwana <aydashkin@vk.com> Co-authored-by: lalka2018 <99399973+lalka2016@users.noreply.github.com> Co-authored-by: veselcraft <veselcraft@icloud.com> Co-authored-by: DeathPleiad <43928323+Parad1seF0x@users.noreply.github.com> --- VKAPI/Handlers/Audio.php | 794 ++++++++- VKAPI/Handlers/Groups.php | 32 +- VKAPI/Handlers/Status.php | 24 +- VKAPI/Handlers/Users.php | 6 + VKAPI/Handlers/Wall.php | 25 + Web/Models/Entities/Audio.php | 469 ++++++ Web/Models/Entities/Club.php | 38 +- Web/Models/Entities/MediaCollection.php | 72 +- Web/Models/Entities/Playlist.php | 256 +++ Web/Models/Entities/Report.php | 3 +- Web/Models/Entities/Traits/TAudioStatuses.php | 38 + Web/Models/Entities/Traits/TOwnable.php | 6 + Web/Models/Entities/User.php | 55 +- Web/Models/Entities/Video.php | 2 +- Web/Models/Repositories/Audios.php | 296 ++++ Web/Models/shell/processAudio.ps1 | 39 + Web/Models/shell/processAudio.sh | 35 + Web/Presenters/AdminPresenter.php | 77 +- Web/Presenters/AudioPresenter.php | 696 ++++++++ Web/Presenters/BlobPresenter.php | 4 +- Web/Presenters/CommentPresenter.php | 26 +- Web/Presenters/GroupPresenter.php | 5 +- Web/Presenters/ReportPresenter.php | 4 +- Web/Presenters/SearchPresenter.php | 31 +- Web/Presenters/UserPresenter.php | 11 +- Web/Presenters/WallPresenter.php | 26 +- Web/Presenters/templates/@layout.xml | 14 +- Web/Presenters/templates/About/Tour.xml | 24 +- Web/Presenters/templates/Admin/@layout.xml | 3 + Web/Presenters/templates/Admin/EditMusic.xml | 81 + .../templates/Admin/EditPlaylist.xml | 54 + Web/Presenters/templates/Admin/Music.xml | 135 ++ .../templates/Audio/ApiGetContext.xml | 7 + .../templates/Audio/EditPlaylist.xml | 95 ++ Web/Presenters/templates/Audio/Embed.xml | 20 + Web/Presenters/templates/Audio/List.xml | 126 ++ .../templates/Audio/NewPlaylist.xml | 109 ++ Web/Presenters/templates/Audio/Playlist.xml | 81 + Web/Presenters/templates/Audio/Upload.xml | 212 +++ Web/Presenters/templates/Audio/bigplayer.xml | 56 + Web/Presenters/templates/Audio/player.xml | 69 + Web/Presenters/templates/Audio/tabs.xml | 40 + Web/Presenters/templates/Group/Edit.xml | 9 + Web/Presenters/templates/Group/View.xml | 19 + .../templates/Photos/UploadPhoto.xml | 2 +- Web/Presenters/templates/Report/Tabs.xml | 3 + .../templates/Report/ViewContent.xml | 2 + Web/Presenters/templates/Search/Index.xml | 42 +- Web/Presenters/templates/User/Edit.xml | 7 + Web/Presenters/templates/User/Settings.xml | 24 + Web/Presenters/templates/User/View.xml | 46 +- Web/Presenters/templates/_includeCSS.xml | 4 +- .../templates/components/attachment.xml | 4 + .../templates/components/textArea.xml | 7 + Web/di.yml | 2 + Web/routes.yml | 52 +- Web/static/css/audios.css | 661 ++++++++ Web/static/css/main.css | 229 +-- Web/static/img/audio.png | Bin 0 -> 560 bytes Web/static/img/audios_controls.png | Bin 0 -> 3706 bytes Web/static/img/bullet.gif | Bin 0 -> 53 bytes Web/static/img/explicit.svg | 4 + Web/static/img/favicons/favicon24_paused.png | Bin 0 -> 932 bytes Web/static/img/favicons/favicon24_playing.png | Bin 0 -> 1081 bytes Web/static/img/play_buttons.gif | Bin 0 -> 103 bytes Web/static/img/progressbar.gif | Bin 0 -> 1018 bytes Web/static/img/song.jpg | Bin 0 -> 2481 bytes Web/static/img/tour/audios.png | Bin 0 -> 6234 bytes Web/static/img/tour/audios_playlists.png | Bin 0 -> 6234 bytes Web/static/img/tour/audios_search.png | Bin 0 -> 6234 bytes Web/static/img/tour/audios_upload.png | Bin 0 -> 6234 bytes Web/static/js/al_music.js | 1433 +++++++++++++++++ Web/static/js/al_playlists.js | 113 ++ Web/static/js/al_wall.js | 24 +- Web/static/js/package.json | 2 + Web/static/js/yarn.lock | 67 + bootstrap.php | 27 + composer.json | 1 + install/init-static-db.sql | 21 - install/sqls/00041-music.sql | 100 ++ locales/en.strings | 165 +- locales/ru.strings | 165 +- openvk-example.yml | 2 + themepacks/midnight/stylesheet.css | 127 +- themepacks/openvk_modern/stylesheet.css | 47 +- 85 files changed, 7333 insertions(+), 274 deletions(-) create mode 100644 Web/Models/Entities/Audio.php create mode 100644 Web/Models/Entities/Playlist.php create mode 100644 Web/Models/Entities/Traits/TAudioStatuses.php create mode 100644 Web/Models/Repositories/Audios.php create mode 100644 Web/Models/shell/processAudio.ps1 create mode 100644 Web/Models/shell/processAudio.sh create mode 100644 Web/Presenters/AudioPresenter.php create mode 100644 Web/Presenters/templates/Admin/EditMusic.xml create mode 100644 Web/Presenters/templates/Admin/EditPlaylist.xml create mode 100644 Web/Presenters/templates/Admin/Music.xml create mode 100644 Web/Presenters/templates/Audio/ApiGetContext.xml create mode 100644 Web/Presenters/templates/Audio/EditPlaylist.xml create mode 100644 Web/Presenters/templates/Audio/Embed.xml create mode 100644 Web/Presenters/templates/Audio/List.xml create mode 100644 Web/Presenters/templates/Audio/NewPlaylist.xml create mode 100644 Web/Presenters/templates/Audio/Playlist.xml create mode 100644 Web/Presenters/templates/Audio/Upload.xml create mode 100644 Web/Presenters/templates/Audio/bigplayer.xml create mode 100644 Web/Presenters/templates/Audio/player.xml create mode 100644 Web/Presenters/templates/Audio/tabs.xml create mode 100644 Web/static/css/audios.css create mode 100644 Web/static/img/audio.png create mode 100644 Web/static/img/audios_controls.png create mode 100644 Web/static/img/bullet.gif create mode 100644 Web/static/img/explicit.svg create mode 100644 Web/static/img/favicons/favicon24_paused.png create mode 100644 Web/static/img/favicons/favicon24_playing.png create mode 100644 Web/static/img/play_buttons.gif create mode 100644 Web/static/img/progressbar.gif create mode 100644 Web/static/img/song.jpg create mode 100644 Web/static/img/tour/audios.png create mode 100644 Web/static/img/tour/audios_playlists.png create mode 100644 Web/static/img/tour/audios_search.png create mode 100644 Web/static/img/tour/audios_upload.png create mode 100644 Web/static/js/al_music.js create mode 100644 Web/static/js/al_playlists.js create mode 100644 install/sqls/00041-music.sql diff --git a/VKAPI/Handlers/Audio.php b/VKAPI/Handlers/Audio.php index 3fa68e72..413a2a3a 100644 --- a/VKAPI/Handlers/Audio.php +++ b/VKAPI/Handlers/Audio.php @@ -1,22 +1,788 @@ <?php declare(strict_types=1); namespace openvk\VKAPI\Handlers; +use Chandler\Database\DatabaseConnection; +use openvk\Web\Models\Entities\Audio as AEntity; +use openvk\Web\Models\Entities\Playlist; +use openvk\Web\Models\Repositories\Audios; +use openvk\Web\Models\Repositories\Clubs; +use openvk\Web\Models\Repositories\Util\EntityStream; final class Audio extends VKAPIRequestHandler { - function get(): object + private function toSafeAudioStruct(?AEntity $audio, ?string $hash = NULL, bool $need_user = false): object + { + if(!$audio) + $this->fail(0404, "Audio not found"); + else if(!$audio->canBeViewedBy($this->getUser())) + $this->fail(201, "Access denied to audio(" . $audio->getPrettyId() . ")"); + + # рофлан ебало + $privApi = $hash && $GLOBALS["csrfCheck"]; + $audioObj = $audio->toVkApiStruct($this->getUser()); + if(!$privApi) { + $audioObj->manifest = false; + $audioObj->keys = false; + } + + if($need_user) { + $user = (new \openvk\Web\Models\Repositories\Users)->get($audio->getOwner()->getId()); + $audioObj->user = (object) [ + "id" => $user->getId(), + "photo" => $user->getAvatarUrl(), + "name" => $user->getCanonicalName(), + "name_gen" => $user->getCanonicalName(), + ]; + } + + return $audioObj; + } + + private function streamToResponse(EntityStream $es, int $offset, int $count, ?string $hash = NULL): object + { + $items = []; + foreach($es->offsetLimit($offset, $count) as $audio) { + $items[] = $this->toSafeAudioStruct($audio, $hash); + } + + return (object) [ + "count" => sizeof($items), + "items" => $items, + ]; + } + + private function validateGenre(?string& $genre_str, ?int $genre_id): void + { + if(!is_null($genre_str)) { + if(!in_array($genre_str, AEntity::genres)) + $this->fail(8, "Invalid genre_str"); + } else if(!is_null($genre_id)) { + $genre_str = array_flip(AEntity::vkGenres)[$genre_id] ?? NULL; + if(!$genre_str) + $this->fail(8, "Invalid genre ID $genre_id"); + } + } + + private function audioFromAnyId(string $id): ?AEntity + { + $descriptor = explode("_", $id); + if(sizeof($descriptor) === 1) { + if(ctype_digit($descriptor[0])) { + $audio = (new Audios)->get((int) $descriptor[0]); + } else { + $aid = base64_decode($descriptor[0], true); + if(!$aid) + $this->fail(8, "Invalid audio $id"); + + $audio = (new Audios)->get((int) $aid); + } + } else if(sizeof($descriptor) === 2) { + $audio = (new Audios)->getByOwnerAndVID((int) $descriptor[0], (int) $descriptor[1]); + } else { + $this->fail(8, "Invalid audio $id"); + } + + return $audio; + } + + function getById(string $audios, ?string $hash = NULL, int $need_user = 0): object + { + $this->requireUser(); + + $audioIds = array_unique(explode(",", $audios)); + if(sizeof($audioIds) === 1) { + $audio = $this->audioFromAnyId($audioIds[0]); + + return (object) [ + "count" => 1, + "items" => [ + $this->toSafeAudioStruct($audio, $hash, (bool) $need_user), + ], + ]; + } else if(sizeof($audioIds) > 6000) { + $this->fail(1980, "Can't get more than 6000 audios at once"); + } + + $audios = []; + foreach($audioIds as $id) + $audios[] = $this->getById($id, $hash)->items[0]; + + return (object) [ + "count" => sizeof($audios), + "items" => $audios, + ]; + } + + function isLagtrain(string $audio_id): int + { + $this->requireUser(); + + $audio = $this->audioFromAnyId($audio_id); + if(!$audio) + $this->fail(0404, "Audio not found"); + + # Possible information disclosure risks are acceptable :D + return (int) (strpos($audio->getName(), "Lagtrain") !== false); + } + + // TODO stub + function getRecommendations(): object + { + return (object) [ + "count" => 0, + "items" => [], + ]; + } + + function getPopular(?int $genre_id = NULL, ?string $genre_str = NULL, int $offset = 0, int $count = 100, ?string $hash = NULL): object + { + $this->requireUser(); + $this->validateGenre($genre_str, $genre_id); + + $results = (new Audios)->getGlobal(Audios::ORDER_POPULAR, $genre_str); + + return $this->streamToResponse($results, $offset, $count, $hash); + } + + function getFeed(?int $genre_id = NULL, ?string $genre_str = NULL, int $offset = 0, int $count = 100, ?string $hash = NULL): object + { + $this->requireUser(); + $this->validateGenre($genre_str, $genre_id); + + $results = (new Audios)->getGlobal(Audios::ORDER_NEW, $genre_str); + + return $this->streamToResponse($results, $offset, $count, $hash); + } + + function search(string $q, int $auto_complete = 0, int $lyrics = 0, int $performer_only = 0, int $sort = 2, int $search_own = 0, int $offset = 0, int $count = 30, ?string $hash = NULL): object + { + $this->requireUser(); + + if(($auto_complete + $search_own) != 0) + $this->fail(10, "auto_complete and search_own are not supported"); + else if($count > 300 || $count < 1) + $this->fail(8, "count is invalid: $count"); + + $results = (new Audios)->search($q, $sort, (bool) $performer_only, (bool) $lyrics); + + return $this->streamToResponse($results, $offset, $count, $hash); + } + + function getCount(int $owner_id, int $uploaded_only = 0): int + { + $this->requireUser(); + + if($owner_id < 0) { + $owner_id *= -1; + $group = (new Clubs)->get($owner_id); + if(!$group) + $this->fail(0404, "Group not found"); + + return (new Audios)->getClubCollectionSize($group); + } + + $user = (new \openvk\Web\Models\Repositories\Users)->get($owner_id); + if(!$user) + $this->fail(0404, "User not found"); + + if(!$user->getPrivacyPermission("audios.read", $this->getUser())) + $this->fail(15, "Access denied"); + + if($uploaded_only) { + return DatabaseConnection::i()->getContext()->table("audios") + ->where([ + "deleted" => false, + "owner" => $owner_id, + ])->count(); + } + + return (new Audios)->getUserCollectionSize($user); + } + + function get(int $owner_id = 0, int $album_id = 0, string $audio_ids = '', int $need_user = 1, int $offset = 0, int $count = 100, int $uploaded_only = 0, int $need_seed = 0, ?string $shuffle_seed = NULL, int $shuffle = 0, ?string $hash = NULL): object { - $serverUrl = ovk_scheme(true) . $_SERVER["SERVER_NAME"]; - - return (object) [ - "count" => 1, - "items" => [(object) [ - "id" => 1, - "owner_id" => 1, - "artist" => "В ОВК ПОКА НЕТ МУЗЫКИ", - "title" => "ЖДИТЕ :)))", - "duration" => 22, - "url" => $serverUrl . "/assets/packages/static/openvk/audio/nomusic.mp3" - ]] - ]; + $this->requireUser(); + + $shuffleSeed = NULL; + $shuffleSeedStr = NULL; + if($shuffle == 1) { + if(!$shuffle_seed) { + if($need_seed == 1) { + $shuffleSeed = openssl_random_pseudo_bytes(6); + $shuffleSeedStr = base64_encode($shuffleSeed); + $shuffleSeed = hexdec(bin2hex($shuffleSeed)); + } else { + $hOffset = ((int) date("i") * 60) + (int) date("s"); + $thisHour = time() - $hOffset; + $shuffleSeed = $thisHour + $this->getUser()->getId(); + $shuffleSeedStr = base64_encode(hex2bin(dechex($shuffleSeed))); + } + } else { + $shuffleSeed = hexdec(bin2hex(base64_decode($shuffle_seed))); + $shuffleSeedStr = $shuffle_seed; + } + } + + if($album_id != 0) { + $album = (new Audios)->getPlaylist($album_id); + if(!$album) + $this->fail(0404, "album_id invalid"); + else if(!$album->canBeViewedBy($this->getUser())) + $this->fail(600, "Can't open this album for reading"); + + $songs = []; + $list = $album->getAudios($offset, $count, $shuffleSeed); + + foreach($list as $song) + $songs[] = $this->toSafeAudioStruct($song, $hash, $need_user == 1); + + $response = (object) [ + "count" => sizeof($songs), + "items" => $songs, + ]; + if(!is_null($shuffleSeed)) + $response->shuffle_seed = $shuffleSeedStr; + + return $response; + } + + if(!empty($audio_ids)) { + $audio_ids = explode(",", $audio_ids); + if(!$audio_ids) + $this->fail(10, "Audio::get@L0d186:explode(string): Unknown error"); + else if(sizeof($audio_ids) < 1) + $this->fail(8, "Invalid audio_ids syntax"); + + if(!is_null($shuffleSeed)) + $audio_ids = knuth_shuffle($audio_ids, $shuffleSeed); + + $obj = $this->getById(implode(",", $audio_ids), $hash, $need_user); + if(!is_null($shuffleSeed)) + $obj->shuffle_seed = $shuffleSeedStr; + + return $obj; + } + + $dbCtx = DatabaseConnection::i()->getContext(); + if($uploaded_only == 1) { + if($owner_id <= 0) + $this->fail(8, "uploaded_only can only be used with owner_id > 0"); + + $user = (new \openvk\Web\Models\Repositories\Users)->get($owner_id); + + if(!$user) + $this->fail(0602, "Invalid user"); + + if(!$user->getPrivacyPermission("audios.read", $this->getUser())) + $this->fail(15, "Access denied: this user chose to hide his audios"); + + if(!is_null($shuffleSeed)) { + $audio_ids = []; + $query = $dbCtx->table("audios")->select("virtual_id")->where([ + "owner" => $owner_id, + "deleted" => 0, + ]); + + foreach($query as $res) + $audio_ids[] = $res->virtual_id; + + $audio_ids = knuth_shuffle($audio_ids, $shuffleSeed); + $audio_ids = array_slice($audio_ids, $offset, $count); + $audio_q = ""; # audio.getById query + foreach($audio_ids as $aid) + $audio_q .= ",$owner_id" . "_$aid"; + + $obj = $this->getById(substr($audio_q, 1), $hash, $need_user); + $obj->shuffle_seed = $shuffleSeedStr; + + return $obj; + } + + $res = (new Audios)->getByUploader((new \openvk\Web\Models\Repositories\Users)->get($owner_id)); + + return $this->streamToResponse($res, $offset, $count, $hash, $need_user); + } + + $query = $dbCtx->table("audio_relations")->select("audio")->where("entity", $owner_id); + if(!is_null($shuffleSeed)) { + $audio_ids = []; + foreach($query as $aid) + $audio_ids[] = $aid->audio; + + $audio_ids = knuth_shuffle($audio_ids, $shuffleSeed); + $audio_ids = array_slice($audio_ids, $offset, $count); + $audio_q = ""; + foreach($audio_ids as $aid) + $audio_q .= ",$aid"; + + $obj = $this->getById(substr($audio_q, 1), $hash, $need_user); + $obj->shuffle_seed = $shuffleSeedStr; + + return $obj; + } + + $items = []; + + if($owner_id > 0) { + $user = (new \openvk\Web\Models\Repositories\Users)->get($owner_id); + + if(!$user) + $this->fail(50, "Invalid user"); + + if(!$user->getPrivacyPermission("audios.read", $this->getUser())) + $this->fail(15, "Access denied: this user chose to hide his audios"); + } + + $audios = (new Audios)->getByEntityID($owner_id, $offset, $count); + foreach($audios as $audio) + $items[] = $this->toSafeAudioStruct($audio, $hash, $need_user == 1); + + return (object) [ + "count" => sizeof($items), + "items" => $items, + ]; } + + function getLyrics(int $lyrics_id): object + { + $this->requireUser(); + + $audio = (new Audios)->get($lyrics_id); + if(!$audio || !$audio->getLyrics()) + $this->fail(0404, "Not found"); + + if(!$audio->canBeViewedBy($this->getUser())) + $this->fail(201, "Access denied to lyrics"); + + return (object) [ + "lyrics_id" => $lyrics_id, + "text" => preg_replace("%\r\n?%", "\n", $audio->getLyrics()), + ]; + } + + function beacon(int $aid, ?int $gid = NULL): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $audio = (new Audios)->get($aid); + if(!$audio) + $this->fail(0404, "Not Found"); + else if(!$audio->canBeViewedBy($this->getUser())) + $this->fail(201, "Insufficient permissions to listen this audio"); + + $group = NULL; + if(!is_null($gid)) { + $group = (new Clubs)->get($gid); + if(!$group) + $this->fail(0404, "Not Found"); + else if(!$group->canBeModifiedBy($this->getUser())) + $this->fail(203, "Insufficient rights to this group"); + } + + return (int) $audio->listen($group ?? $this->getUser()); + } + + function setBroadcast(string $audio, string $target_ids): array + { + $this->requireUser(); + + [$owner, $aid] = explode("_", $audio); + $song = (new Audios)->getByOwnerAndVID((int) $owner, (int) $aid); + $ids = []; + foreach(explode(",", $target_ids) as $id) { + $id = (int) $id; + if($id > 0) { + if ($id != $this->getUser()->getId()) { + $this->fail(600, "Can't listen on behalf of $id"); + } else { + $ids[] = $id; + $this->beacon($song->getId()); + continue; + } + } + + $group = (new Clubs)->get($id * -1); + if(!$group) + $this->fail(0404, "Not Found"); + else if(!$group->canBeModifiedBy($this->getUser())) + $this->fail(203,"Insufficient rights to this group"); + + $ids[] = $id; + $this->beacon($song ? $song->getId() : 0, $id * -1); + } + + return $ids; + } + + function getBroadcastList(string $filter = "all", int $active = 0, ?string $hash = NULL): object + { + $this->requireUser(); + + if(!in_array($filter, ["all", "friends", "groups"])) + $this->fail(8, "Invalid filter $filter"); + + $broadcastList = $this->getUser()->getBroadcastList($filter); + $items = []; + foreach($broadcastList as $res) { + $struct = $res->toVkApiStruct(); + $status = $res->getCurrentAudioStatus(); + + $struct->status_audio = $status ? $this->toSafeAudioStruct($status) : NULL; + $items[] = $struct; + } + + return (object) [ + "count" => sizeof($items), + "items" => $items, + ]; + } + + function edit(int $owner_id, int $audio_id, ?string $artist = NULL, ?string $title = NULL, ?string $text = NULL, ?int $genre_id = NULL, ?string $genre_str = NULL, int $no_search = 0): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $audio = (new Audios)->getByOwnerAndVID($owner_id, $audio_id); + if(!$audio) + $this->fail(0404, "Not Found"); + else if(!$audio->canBeModifiedBy($this->getUser())) + $this->fail(201, "Insufficient permissions to edit this audio"); + + if(!is_null($genre_id)) { + $genre = array_flip(AEntity::vkGenres)[$genre_id] ?? NULL; + if(!$genre) + $this->fail(8, "Invalid genre ID $genre_id"); + + $audio->setGenre($genre); + } else if(!is_null($genre_str)) { + if(!in_array($genre_str, AEntity::genres)) + $this->fail(8, "Invalid genre ID $genre_str"); + + $audio->setGenre($genre_str); + } + + $lyrics = 0; + if(!is_null($text)) { + $audio->setLyrics($text); + $lyrics = $audio->getId(); + } + + if(!is_null($artist)) + $audio->setPerformer($artist); + + if(!is_null($title)) + $audio->setName($title); + + $audio->setSearchability(!((bool) $no_search)); + $audio->setEdited(time()); + $audio->save(); + + return $lyrics; + } + + function add(int $audio_id, int $owner_id, ?int $group_id = NULL, ?int $album_id = NULL): string + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + if(!is_null($album_id)) + $this->fail(10, "album_id not implemented"); + + // TODO get rid of dups + $to = $this->getUser(); + if(!is_null($group_id)) { + $group = (new Clubs)->get($group_id); + if(!$group) + $this->fail(0404, "Invalid group_id"); + else if(!$group->canBeModifiedBy($this->getUser())) + $this->fail(203, "Insufficient rights to this group"); + + $to = $group; + } + + $audio = (new Audios)->getByOwnerAndVID($owner_id, $audio_id); + if(!$audio) + $this->fail(0404, "Not found"); + else if(!$audio->canBeViewedBy($this->getUser())) + $this->fail(201, "Access denied to audio(owner=$owner_id, vid=$audio_id)"); + + try { + $audio->add($to); + } catch(\OverflowException $ex) { + $this->fail(300, "Album is full"); + } + + return $audio->getPrettyId(); + } + + function delete(int $audio_id, int $owner_id, ?int $group_id = NULL): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $from = $this->getUser(); + if(!is_null($group_id)) { + $group = (new Clubs)->get($group_id); + if(!$group) + $this->fail(0404, "Invalid group_id"); + else if(!$group->canBeModifiedBy($this->getUser())) + $this->fail(203, "Insufficient rights to this group"); + + $from = $group; + } + + $audio = (new Audios)->getByOwnerAndVID($owner_id, $audio_id); + if(!$audio) + $this->fail(0404, "Not found"); + + $audio->remove($from); + + return 1; + } + + function restore(int $audio_id, int $owner_id, ?int $group_id = NULL, ?string $hash = NULL): object + { + $this->requireUser(); + + $vid = $this->add($audio_id, $owner_id, $group_id); + + return $this->getById($vid, $hash)->items[0]; + } + + function getAlbums(int $owner_id = 0, int $offset = 0, int $count = 50, int $drop_private = 1): object + { + $this->requireUser(); + + $owner_id = $owner_id == 0 ? $this->getUser()->getId() : $owner_id; + $playlists = []; + + if($owner_id > 0 && $owner_id != $this->getUser()->getId()) { + $user = (new \openvk\Web\Models\Repositories\Users)->get($owner_id); + + if(!$user->getPrivacyPermission("audios.read", $this->getUser())) + $this->fail(50, "Access to playlists denied"); + } + + foreach((new Audios)->getPlaylistsByEntityId($owner_id, $offset, $count) as $playlist) { + if(!$playlist->canBeViewedBy($this->getUser())) { + if($drop_private == 1) + continue; + + $playlists[] = NULL; + continue; + } + + $playlists[] = $playlist->toVkApiStruct($this->getUser()); + } + + return (object) [ + "count" => sizeof($playlists), + "items" => $playlists, + ]; + } + + function searchAlbums(string $query, int $offset = 0, int $limit = 25, int $drop_private = 0): object + { + $this->requireUser(); + + $playlists = []; + $search = (new Audios)->searchPlaylists($query)->offsetLimit($offset, $limit); + foreach($search as $playlist) { + if(!$playlist->canBeViewedBy($this->getUser())) { + if($drop_private == 0) + $playlists[] = NULL; + + continue; + } + + $playlists[] = $playlist->toVkApiStruct($this->getUser()); + } + + return (object) [ + "count" => sizeof($playlists), + "items" => $playlists, + ]; + } + + function addAlbum(string $title, ?string $description = NULL, int $group_id = 0): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $group = NULL; + if($group_id != 0) { + $group = (new Clubs)->get($group_id); + if(!$group) + $this->fail(0404, "Invalid group_id"); + else if(!$group->canBeModifiedBy($this->getUser())) + $this->fail(600, "Insufficient rights to this group"); + } + + $album = new Playlist; + $album->setName($title); + if(!is_null($group)) + $album->setOwner($group_id * -1); + else + $album->setOwner($this->getUser()->getId()); + + if(!is_null($description)) + $album->setDescription($description); + + $album->save(); + if(!is_null($group)) + $album->bookmark($group); + else + $album->bookmark($this->getUser()); + + return $album->getId(); + } + + function editAlbum(int $album_id, ?string $title = NULL, ?string $description = NULL): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $album = (new Audios)->getPlaylist($album_id); + if(!$album) + $this->fail(0404, "Album not found"); + else if(!$album->canBeModifiedBy($this->getUser())) + $this->fail(600, "Insufficient rights to this album"); + + if(!is_null($title)) + $album->setName($title); + + if(!is_null($description)) + $album->setDescription($description); + + $album->setEdited(time()); + $album->save(); + + return (int) !(!$title && !$description); + } + + function deleteAlbum(int $album_id): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $album = (new Audios)->getPlaylist($album_id); + if(!$album) + $this->fail(0404, "Album not found"); + else if(!$album->canBeModifiedBy($this->getUser())) + $this->fail(600, "Insufficient rights to this album"); + + $album->delete(); + + return 1; + } + + function moveToAlbum(int $album_id, string $audio_ids): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $album = (new Audios)->getPlaylist($album_id); + if(!$album) + $this->fail(0404, "Album not found"); + else if(!$album->canBeModifiedBy($this->getUser())) + $this->fail(600, "Insufficient rights to this album"); + + $audios = []; + $audio_ids = array_unique(explode(",", $audio_ids)); + if(sizeof($audio_ids) < 1 || sizeof($audio_ids) > 1000) + $this->fail(8, "audio_ids must contain at least 1 audio and at most 1000"); + + foreach($audio_ids as $audio_id) { + $audio = $this->audioFromAnyId($audio_id); + if(!$audio) + continue; + else if(!$audio->canBeViewedBy($this->getUser())) + continue; + + $audios[] = $audio; + } + + if(sizeof($audios) < 1) + return 0; + + $res = 1; + try { + foreach ($audios as $audio) + $res = min($res, (int) $album->add($audio)); + } catch(\OutOfBoundsException $ex) { + return 0; + } + + return $res; + } + + function removeFromAlbum(int $album_id, string $audio_ids): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $album = (new Audios)->getPlaylist($album_id); + if(!$album) + $this->fail(0404, "Album not found"); + else if(!$album->canBeModifiedBy($this->getUser())) + $this->fail(600, "Insufficient rights to this album"); + + $audios = []; + $audio_ids = array_unique(explode(",", $audio_ids)); + if(sizeof($audio_ids) < 1 || sizeof($audio_ids) > 1000) + $this->fail(8, "audio_ids must contain at least 1 audio and at most 1000"); + + foreach($audio_ids as $audio_id) { + $audio = $this->audioFromAnyId($audio_id); + if(!$audio) + continue; + else if($audio->canBeViewedBy($this->getUser())) + continue; + + $audios[] = $audio; + } + + if(sizeof($audios) < 1) + return 0; + + foreach($audios as $audio) + $album->remove($audio); + + return 1; + } + + function copyToAlbum(int $album_id, string $audio_ids): int + { + return $this->moveToAlbum($album_id, $audio_ids); + } + + function bookmarkAlbum(int $id): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $album = (new Audios)->getPlaylist($id); + if(!$album) + $this->fail(0404, "Not found"); + + if(!$album->canBeViewedBy($this->getUser())) + $this->fail(600, "Access error"); + + return (int) $album->bookmark($this->getUser()); + } + + function unBookmarkAlbum(int $id): int + { + $this->requireUser(); + $this->willExecuteWriteAction(); + + $album = (new Audios)->getPlaylist($id); + if(!$album) + $this->fail(0404, "Not found"); + + if(!$album->canBeViewedBy($this->getUser())) + $this->fail(600, "Access error"); + + return (int) $album->unbookmark($this->getUser()); + } } diff --git a/VKAPI/Handlers/Groups.php b/VKAPI/Handlers/Groups.php index 3123a43f..85a251da 100644 --- a/VKAPI/Handlers/Groups.php +++ b/VKAPI/Handlers/Groups.php @@ -292,7 +292,8 @@ final class Groups extends VKAPIRequestHandler int $topics = NULL, int $adminlist = NULL, int $topicsAboveWall = NULL, - int $hideFromGlobalFeed = NULL) + int $hideFromGlobalFeed = NULL, + int $audio = NULL) { $this->requireUser(); $this->willExecuteWriteAction(); @@ -303,17 +304,22 @@ final class Groups extends VKAPIRequestHandler if(!$club || !$club->canBeModifiedBy($this->getUser())) $this->fail(15, "You can't modify this group."); if(!empty($screen_name) && !$club->setShortcode($screen_name)) $this->fail(103, "Invalid shortcode."); - !is_null($title) ? $club->setName($title) : NULL; - !is_null($description) ? $club->setAbout($description) : NULL; - !is_null($screen_name) ? $club->setShortcode($screen_name) : NULL; - !is_null($website) ? $club->setWebsite((!parse_url($website, PHP_URL_SCHEME) ? "https://" : "") . $website) : NULL; - !is_null($wall) ? $club->setWall($wall) : NULL; - !is_null($topics) ? $club->setEveryone_Can_Create_Topics($topics) : NULL; - !is_null($adminlist) ? $club->setAdministrators_List_Display($adminlist) : NULL; - !is_null($topicsAboveWall) ? $club->setDisplay_Topics_Above_Wall($topicsAboveWall) : NULL; - !is_null($hideFromGlobalFeed) ? $club->setHide_From_Global_Feed($hideFromGlobalFeed) : NULL; + !empty($title) ? $club->setName($title) : NULL; + !empty($description) ? $club->setAbout($description) : NULL; + !empty($screen_name) ? $club->setShortcode($screen_name) : NULL; + !empty($website) ? $club->setWebsite((!parse_url($website, PHP_URL_SCHEME) ? "https://" : "") . $website) : NULL; + !empty($wall) ? $club->setWall($wall) : NULL; + !empty($topics) ? $club->setEveryone_Can_Create_Topics($topics) : NULL; + !empty($adminlist) ? $club->setAdministrators_List_Display($adminlist) : NULL; + !empty($topicsAboveWall) ? $club->setDisplay_Topics_Above_Wall($topicsAboveWall) : NULL; + !empty($hideFromGlobalFeed) ? $club->setHide_From_Global_Feed($hideFromGlobalFeed) : NULL; + in_array($audio, [0, 1]) ? $club->setEveryone_can_upload_audios($audio) : NULL; - $club->save(); + try { + $club->save(); + } catch(\TypeError $e) { + $this->fail(8, "Nothing changed"); + } return 1; } @@ -370,7 +376,7 @@ final class Groups extends VKAPIRequestHandler $arr->items[$i]->can_see_all_posts = 1; break; case "can_see_audio": - $arr->items[$i]->can_see_audio = 0; + $arr->items[$i]->can_see_audio = 1; break; case "can_write_private_message": $arr->items[$i]->can_write_private_message = 0; @@ -469,7 +475,7 @@ final class Groups extends VKAPIRequestHandler "wall" => $club->canPost() == true ? 1 : 0, "photos" => 1, "video" => 0, - "audio" => 0, + "audio" => $club->isEveryoneCanUploadAudios() ? 1 : 0, "docs" => 0, "topics" => $club->isEveryoneCanCreateTopics() == true ? 1 : 0, "wiki" => 0, diff --git a/VKAPI/Handlers/Status.php b/VKAPI/Handlers/Status.php index 843f42bd..a1b104a2 100644 --- a/VKAPI/Handlers/Status.php +++ b/VKAPI/Handlers/Status.php @@ -8,13 +8,23 @@ final class Status extends VKAPIRequestHandler function get(int $user_id = 0, int $group_id = 0) { $this->requireUser(); - if($user_id == 0 && $group_id == 0) { - return $this->getUser()->getStatus(); - } else { - if($group_id > 0) - $this->fail(501, "Group statuses are not implemented"); - else - return (new UsersRepo)->get($user_id)->getStatus(); + + if($user_id == 0 && $group_id == 0) + $user_id = $this->getUser()->getId(); + + if($group_id > 0) + $this->fail(501, "Group statuses are not implemented"); + else { + $user = (new UsersRepo)->get($user_id); + $audioStatus = $user->getCurrentAudioStatus(); + if($audioStatus) { + return [ + "status" => $user->getStatus(), + "audio" => $audioStatus->toVkApiStruct(), + ]; + } + + return $user->getStatus(); } } diff --git a/VKAPI/Handlers/Users.php b/VKAPI/Handlers/Users.php index a4298787..68bf828f 100644 --- a/VKAPI/Handlers/Users.php +++ b/VKAPI/Handlers/Users.php @@ -95,6 +95,12 @@ final class Users extends VKAPIRequestHandler case "status": if($usr->getStatus() != NULL) $response[$i]->status = $usr->getStatus(); + + $audioStatus = $usr->getCurrentAudioStatus(); + + if($audioStatus) + $response[$i]->status_audio = $audioStatus->toVkApiStruct(); + break; case "screen_name": if($usr->getShortCode() != NULL) diff --git a/VKAPI/Handlers/Wall.php b/VKAPI/Handlers/Wall.php index 6b78a0b0..693ca3cb 100644 --- a/VKAPI/Handlers/Wall.php +++ b/VKAPI/Handlers/Wall.php @@ -15,6 +15,7 @@ use openvk\Web\Models\Entities\Video; use openvk\Web\Models\Repositories\Videos as VideosRepo; use openvk\Web\Models\Entities\Note; use openvk\Web\Models\Repositories\Notes as NotesRepo; +use openvk\Web\Models\Repositories\Audios as AudiosRepo; final class Wall extends VKAPIRequestHandler { @@ -58,6 +59,8 @@ final class Wall extends VKAPIRequestHandler $attachments[] = $attachment->getApiStructure(); } else if ($attachment instanceof \openvk\Web\Models\Entities\Note) { $attachments[] = $attachment->toVkApiStruct(); + } else if ($attachment instanceof \openvk\Web\Models\Entities\Audio) { + $attachments[] = $attachment->toVkApiStruct($this->getUser()); } else if ($attachment instanceof \openvk\Web\Models\Entities\Post) { $repostAttachments = []; @@ -233,6 +236,8 @@ final class Wall extends VKAPIRequestHandler $attachments[] = $attachment->getApiStructure(); } else if ($attachment instanceof \openvk\Web\Models\Entities\Note) { $attachments[] = $attachment->toVkApiStruct(); + } else if ($attachment instanceof \openvk\Web\Models\Entities\Audio) { + $attachments[] = $attachment->toVkApiStruct($this->getUser()); } else if ($attachment instanceof \openvk\Web\Models\Entities\Post) { $repostAttachments = []; @@ -450,6 +455,8 @@ final class Wall extends VKAPIRequestHandler $attachmentType = "video"; elseif(str_contains($attac, "note")) $attachmentType = "note"; + elseif(str_contains($attac, "audio")) + $attachmentType = "audio"; else $this->fail(205, "Unknown attachment type"); @@ -483,6 +490,12 @@ final class Wall extends VKAPIRequestHandler if(!$attacc->getOwner()->getPrivacyPermission('notes.read', $this->getUser())) $this->fail(11, "Access to note denied"); + $post->attach($attacc); + } elseif($attachmentType == "audio") { + $attacc = (new AudiosRepo)->getByOwnerAndVID($attachmentOwner, $attachmentId); + if(!$attacc || $attacc->isDeleted()) + $this->fail(100, "Audio does not exist"); + $post->attach($attacc); } } @@ -562,6 +575,8 @@ final class Wall extends VKAPIRequestHandler $attachments[] = $this->getApiPhoto($attachment); } elseif($attachment instanceof \openvk\Web\Models\Entities\Note) { $attachments[] = $attachment->toVkApiStruct(); + } elseif($attachment instanceof \openvk\Web\Models\Entities\Audio) { + $attachments[] = $attachment->toVkApiStruct($this->getUser()); } } @@ -628,6 +643,8 @@ final class Wall extends VKAPIRequestHandler foreach($comment->getChildren() as $attachment) { if($attachment instanceof \openvk\Web\Models\Entities\Photo) { $attachments[] = $this->getApiPhoto($attachment); + } elseif($attachment instanceof \openvk\Web\Models\Entities\Audio) { + $attachments[] = $attachment->toVkApiStruct($this->getUser()); } } @@ -719,6 +736,8 @@ final class Wall extends VKAPIRequestHandler $attachmentType = "photo"; elseif(str_contains($attac, "video")) $attachmentType = "video"; + elseif(str_contains($attac, "audio")) + $attachmentType = "audio"; else $this->fail(205, "Unknown attachment type"); @@ -744,6 +763,12 @@ final class Wall extends VKAPIRequestHandler if(!$attacc->getOwner()->getPrivacyPermission('videos.read', $this->getUser())) $this->fail(11, "Access to video denied"); + $comment->attach($attacc); + } elseif($attachmentType == "audio") { + $attacc = (new AudiosRepo)->getByOwnerAndVID($attachmentOwner, $attachmentId); + if(!$attacc || $attacc->isDeleted()) + $this->fail(100, "Audio does not exist"); + $comment->attach($attacc); } } diff --git a/Web/Models/Entities/Audio.php b/Web/Models/Entities/Audio.php new file mode 100644 index 00000000..11d8502b --- /dev/null +++ b/Web/Models/Entities/Audio.php @@ -0,0 +1,469 @@ +<?php declare(strict_types=1); +namespace openvk\Web\Models\Entities; +use Chandler\Database\DatabaseConnection; +use openvk\Web\Util\Shell\Exceptions\UnknownCommandException; +use openvk\Web\Util\Shell\Shell; + +/** + * @method setName(string) + * @method setPerformer(string) + * @method setLyrics(string) + * @method setExplicit(bool) + */ +class Audio extends Media +{ + protected $tableName = "audios"; + protected $fileExtension = "mpd"; + + # Taken from winamp :D + const genres = [ + 'Blues','Big Band','Classic Rock','Chorus','Country','Easy Listening','Dance','Acoustic','Disco','Humour','Funk','Speech','Grunge','Chanson','Hip-Hop','Opera','Jazz','Chamber Music','Metal','Sonata','New Age','Symphony','Oldies','Booty Bass','Other','Primus','Pop','Porn Groove','R&B','Satire','Rap','Slow Jam','Reggae','Club','Rock','Tango','Techno','Samba','Industrial','Folklore','Alternative','Ballad','Ska','Power Ballad','Death Metal','Rhythmic Soul','Pranks','Freestyle','Soundtrack','Duet','Euro-Techno','Punk Rock','Ambient','Drum Solo','Trip-Hop','A Cappella','Vocal','Euro-House','Jazz+Funk','Dance Hall','Fusion','Goa','Trance','Drum & Bass','Classical','Club-House','Instrumental','Hardcore','Acid','Terror','House','Indie','Game','BritPop','Sound Clip','Negerpunk','Gospel','Polsk Punk','Noise','Beat','AlternRock','Christian Gangsta Rap','Bass','Heavy Metal','Soul','Black Metal','Punk','Crossover','Space','Contemporary Christian','Meditative','Christian Rock','Instrumental Pop','Merengue','Instrumental Rock','Salsa','Ethnic','Thrash Metal','Gothic','Anime','Darkwave','JPop','Techno-Industrial','Synthpop','Electronic','Abstract','Pop-Folk','Art Rock','Eurodance','Baroque','Dream','Bhangra','Southern Rock','Big Beat','Comedy','Breakbeat','Cult','Chillout','Gangsta Rap','Downtempo','Top 40','Dub','Christian Rap','EBM','Pop / Funk','Eclectic','Jungle','Electro','Native American','Electroclash','Cabaret','Emo','New Wave','Experimental','Psychedelic','Garage','Rave','Global','Showtunes','IDM','Trailer','Illbient','Lo-Fi','Industro-Goth','Tribal','Jam Band','Acid Punk','Krautrock','Acid Jazz','Leftfield','Polka','Lounge','Retro','Math Rock','Musical','New Romantic','Rock & Roll','Nu-Breakz','Hard Rock','Post-Punk','Folk','Post-Rock','Folk-Rock','Psytrance','National Folk','Shoegaze','Swing','Space Rock','Fast Fusion','Trop Rock','Bebob','World Music','Latin','Neoclassical','Revival','Audiobook','Celtic','Audio Theatre','Bluegrass','Neue Deutsche Welle','Avantgarde','Podcast','Gothic Rock','Indie Rock','Progressive Rock','G-Funk','Psychedelic Rock','Dubstep','Symphonic Rock','Garage Rock','Slow Rock','Psybient','Psychobilly','Touhou' + ]; + + # Taken from: https://web.archive.org/web/20220322153107/https://dev.vk.com/reference/objects/audio-genres + const vkGenres = [ + "Rock" => 1, + "Pop" => 2, + "Rap" => 3, + "Hip-Hop" => 3, # VK API lists №3 as Rap & Hip-Hop, but these genres are distinct in OpenVK + "Easy Listening" => 4, + "House" => 5, + "Dance" => 5, + "Instrumental" => 6, + "Metal" => 7, + "Alternative" => 21, + "Dubstep" => 8, + "Jazz" => 1001, + "Blues" => 1001, + "Drum & Bass" => 10, + "Trance" => 11, + "Chanson" => 12, + "Ethnic" => 13, + "Acoustic" => 14, + "Vocal" => 14, + "Reggae" => 15, + "Classical" => 16, + "Indie Pop" => 17, + "Speech" => 19, + "Disco" => 22, + "Other" => 18, + ]; + + private function fileLength(string $filename): int + { + if(!Shell::commandAvailable("ffmpeg") || !Shell::commandAvailable("ffprobe")) + throw new \Exception(); + + $error = NULL; + $streams = Shell::ffprobe("-i", $filename, "-show_streams", "-select_streams a", "-loglevel error")->execute($error); + if($error !== 0) + throw new \DomainException("$filename is not recognized as media container"); + else if(empty($streams) || ctype_space($streams)) + throw new \DomainException("$filename does not contain any audio streams"); + + $vstreams = Shell::ffprobe("-i", $filename, "-show_streams", "-select_streams v", "-loglevel error")->execute($error); + + # check if audio has cover (attached_pic) + preg_match("%attached_pic=([0-1])%", $vstreams, $hasCover); + if(!empty($vstreams) && !ctype_space($vstreams) && ((int)($hasCover[1]) !== 1)) + throw new \DomainException("$filename is a video"); + + $durations = []; + preg_match_all('%duration=([0-9\.]++)%', $streams, $durations); + if(sizeof($durations[1]) === 0) + throw new \DomainException("$filename does not contain any meaningful audio streams"); + + $length = 0; + foreach($durations[1] as $duration) { + $duration = floatval($duration); + if($duration < 1.0 || $duration > 65536.0) + throw new \DomainException("$filename does not contain any meaningful audio streams"); + else + $length = max($length, $duration); + } + + return (int) round($length, 0, PHP_ROUND_HALF_EVEN); + } + + /** + * @throws \Exception + */ + protected function saveFile(string $filename, string $hash): bool + { + $duration = $this->fileLength($filename); + + $kid = openssl_random_pseudo_bytes(16); + $key = openssl_random_pseudo_bytes(16); + $tok = openssl_random_pseudo_bytes(28); + $ss = ceil($duration / 15); + + $this->stateChanges("kid", $kid); + $this->stateChanges("key", $key); + $this->stateChanges("token", $tok); + $this->stateChanges("segment_size", $ss); + $this->stateChanges("length", $duration); + + try { + $args = [ + str_replace("enabled", "available", OPENVK_ROOT), + str_replace("enabled", "available", $this->getBaseDir()), + $hash, + $filename, + + bin2hex($kid), + bin2hex($key), + bin2hex($tok), + $ss, + ]; + + if(Shell::isPowershell()) { + Shell::powershell("-executionpolicy bypass", "-File", __DIR__ . "/../shell/processAudio.ps1", ...$args) + ->start(); + } else { + Shell::bash(__DIR__ . "/../shell/processAudio.sh", ...$args) // Pls workkkkk + ->start(); // idk, not tested :") + } + + # Wait until processAudio will consume the file + $start = time(); + while(file_exists($filename)) + if(time() - $start > 5) + throw new \RuntimeException("Timed out waiting FFMPEG"); + + } catch(UnknownCommandException $ucex) { + exit(OPENVK_ROOT_CONF["openvk"]["debug"] ? "bash/pwsh is not installed" : VIDEOS_FRIENDLY_ERROR); + } + + return true; + } + + function getTitle(): string + { + return $this->getRecord()->name; + } + + function getPerformer(): string + { + return $this->getRecord()->performer; + } + + function getName(): string + { + return $this->getPerformer() . " — " . $this->getTitle(); + } + + function getGenre(): ?string + { + return $this->getRecord()->genre; + } + + function getLyrics(): ?string + { + return !is_null($this->getRecord()->lyrics) ? htmlspecialchars($this->getRecord()->lyrics, ENT_DISALLOWED | ENT_XHTML) : NULL; + } + + function getLength(): int + { + return $this->getRecord()->length; + } + + function getFormattedLength(): string + { + $len = $this->getLength(); + $mins = floor($len / 60); + $secs = $len - ($mins * 60); + + return ( + str_pad((string) $mins, 2, "0", STR_PAD_LEFT) + . ":" . + str_pad((string) $secs, 2, "0", STR_PAD_LEFT) + ); + } + + function getSegmentSize(): float + { + return $this->getRecord()->segment_size; + } + + function getListens(): int + { + return $this->getRecord()->listens; + } + + function getOriginalURL(bool $force = false): string + { + $disallowed = !OPENVK_ROOT_CONF["openvk"]["preferences"]["music"]["exposeOriginalURLs"] && !$force; + if(!$this->isAvailable() || $disallowed) + return ovk_scheme(true) + . $_SERVER["HTTP_HOST"] . ":" + . $_SERVER["HTTP_PORT"] + . "/assets/packages/static/openvk/audio/nomusic.mp3"; + + $key = bin2hex($this->getRecord()->token); + + return str_replace(".mpd", "_fragments", $this->getURL()) . "/original_$key.mp3"; + } + + function getURL(?bool $force = false): string + { + if ($this->isWithdrawn()) return ""; + + return parent::getURL(); + } + + function getKeys(): array + { + $keys[bin2hex($this->getRecord()->kid)] = bin2hex($this->getRecord()->key); + + return $keys; + } + + function isAnonymous(): bool + { + return false; + } + + function isExplicit(): bool + { + return (bool) $this->getRecord()->explicit; + } + + function isWithdrawn(): bool + { + return (bool) $this->getRecord()->withdrawn; + } + + function isUnlisted(): bool + { + return (bool) $this->getRecord()->unlisted; + } + + # NOTICE may flush model to DB if it was just processed + function isAvailable(): bool + { + if($this->getRecord()->processed) + return true; + + # throttle requests to isAvailable to prevent DoS attack if filesystem is actually an S3 storage + if(time() - $this->getRecord()->checked < 5) + return false; + + try { + $fragments = str_replace(".mpd", "_fragments", $this->getFileName()); + $original = "original_" . bin2hex($this->getRecord()->token) . ".mp3"; + if(file_exists("$fragments/$original")) { + # Original gets uploaded after fragments + $this->stateChanges("processed", 0x01); + + return true; + } + } finally { + $this->stateChanges("checked", time()); + $this->save(); + } + + return false; + } + + function isInLibraryOf($entity): bool + { + return sizeof(DatabaseConnection::i()->getContext()->table("audio_relations")->where([ + "entity" => $entity->getId() * ($entity instanceof Club ? -1 : 1), + "audio" => $this->getId(), + ])) != 0; + } + + function add($entity): bool + { + if($this->isInLibraryOf($entity)) + return false; + + $entityId = $entity->getId() * ($entity instanceof Club ? -1 : 1); + $audioRels = DatabaseConnection::i()->getContext()->table("audio_relations"); + if(sizeof($audioRels->where("entity", $entityId)) > 65536) + throw new \OverflowException("Can't have more than 65536 audios in a playlist"); + + $audioRels->insert([ + "entity" => $entityId, + "audio" => $this->getId(), + ]); + + return true; + } + + function remove($entity): bool + { + if(!$this->isInLibraryOf($entity)) + return false; + + DatabaseConnection::i()->getContext()->table("audio_relations")->where([ + "entity" => $entity->getId() * ($entity instanceof Club ? -1 : 1), + "audio" => $this->getId(), + ])->delete(); + + return true; + } + + function listen($entity, Playlist $playlist = NULL): bool + { + $listensTable = DatabaseConnection::i()->getContext()->table("audio_listens"); + $lastListen = $listensTable->where([ + "entity" => $entity->getRealId(), + "audio" => $this->getId(), + ])->order("index DESC")->fetch(); + + if(!$lastListen || (time() - $lastListen->time >= $this->getLength())) { + $listensTable->insert([ + "entity" => $entity->getRealId(), + "audio" => $this->getId(), + "time" => time(), + "playlist" => $playlist ? $playlist->getId() : NULL, + ]); + + if($entity instanceof User) { + $this->stateChanges("listens", ($this->getListens() + 1)); + $this->save(); + + if($playlist) { + $playlist->incrementListens(); + $playlist->save(); + } + } + + $entity->setLast_played_track($this->getId()); + $entity->save(); + + return true; + } + + $lastListen->update([ + "time" => time(), + ]); + + return false; + } + + /** + * Returns compatible with VK API 4.x, 5.x structure. + * + * Always sets album(_id) to NULL at this time. + * If genre is not present in VK genre list, fallbacks to "Other". + * The url and manifest properties will be set to false if the audio can't be played (processing, removed). + * + * Aside from standard VK properties, this method will also return some OVK extended props: + * 1. added - Is in the library of $user? + * 2. editable - Can be edited by $user? + * 3. withdrawn - Removed due to copyright request? + * 4. ready - Can be played at this time? + * 5. genre_str - Full name of genre, NULL if it's undefined + * 6. manifest - URL to MPEG-DASH manifest + * 7. keys - ClearKey DRM keys + * 8. explicit - Marked as NSFW? + * 9. searchable - Can be found via search? + * 10. unique_id - Unique ID of audio + * + * @notice that in case if exposeOriginalURLs is set to false in config, "url" will always contain link to nomusic.mp3, + * unless $forceURLExposure is set to true. + * + * @notice may trigger db flush if the audio is not processed yet, use with caution on unsaved models. + * + * @param ?User $user user, relative to whom "added", "editable" will be set + * @param bool $forceURLExposure force set "url" regardless of config + */ + function toVkApiStruct(?User $user = NULL, bool $forceURLExposure = false): object + { + $obj = (object) []; + $obj->unique_id = base64_encode((string) $this->getId()); + $obj->id = $obj->aid = $this->getVirtualId(); + $obj->artist = $this->getPerformer(); + $obj->title = $this->getTitle(); + $obj->duration = $this->getLength(); + $obj->album_id = $obj->album = NULL; # i forgor to implement + $obj->url = false; + $obj->manifest = false; + $obj->keys = false; + $obj->genre_id = $obj->genre = self::vkGenres[$this->getGenre() ?? ""] ?? 18; # return Other if no match + $obj->genre_str = $this->getGenre(); + $obj->owner_id = $this->getOwner()->getId(); + if($this->getOwner() instanceof Club) + $obj->owner_id *= -1; + + $obj->lyrics = NULL; + if(!is_null($this->getLyrics())) + $obj->lyrics = $this->getId(); + + $obj->added = $user && $this->isInLibraryOf($user); + $obj->editable = $user && $this->canBeModifiedBy($user); + $obj->searchable = !$this->isUnlisted(); + $obj->explicit = $this->isExplicit(); + $obj->withdrawn = $this->isWithdrawn(); + $obj->ready = $this->isAvailable() && !$obj->withdrawn; + if($obj->ready) { + $obj->url = $this->getOriginalURL($forceURLExposure); + $obj->manifest = $this->getURL(); + $obj->keys = $this->getKeys(); + } + + return $obj; + } + + function setOwner(int $oid): void + { + # WARNING: API implementation won't be able to handle groups like that, don't remove + if($oid <= 0) + throw new \OutOfRangeException("Only users can be owners of audio!"); + + $this->stateChanges("owner", $oid); + } + + function setGenre(string $genre): void + { + if(!in_array($genre, Audio::genres)) { + $this->stateChanges("genre", NULL); + return; + } + + $this->stateChanges("genre", $genre); + } + + function setCopyrightStatus(bool $withdrawn = true): void { + $this->stateChanges("withdrawn", $withdrawn); + } + + function setSearchability(bool $searchable = true): void { + $this->stateChanges("unlisted", !$searchable); + } + + function setToken(string $tok): void { + throw new \LogicException("Changing keys is not supported."); + } + + function setKid(string $kid): void { + throw new \LogicException("Changing keys is not supported."); + } + + function setKey(string $key): void { + throw new \LogicException("Changing keys is not supported."); + } + + function setLength(int $len): void { + throw new \LogicException("Changing length is not supported."); + } + + function setSegment_Size(int $len): void { + throw new \LogicException("Changing length is not supported."); + } + + function delete(bool $softly = true): void + { + $ctx = DatabaseConnection::i()->getContext(); + $ctx->table("audio_relations")->where("audio", $this->getId()) + ->delete(); + $ctx->table("audio_listens")->where("audio", $this->getId()) + ->delete(); + $ctx->table("playlist_relations")->where("media", $this->getId()) + ->delete(); + + parent::delete($softly); + } +} diff --git a/Web/Models/Entities/Club.php b/Web/Models/Entities/Club.php index fbdc503b..b8b8838a 100644 --- a/Web/Models/Entities/Club.php +++ b/Web/Models/Entities/Club.php @@ -371,36 +371,60 @@ class Club extends RowModel { return $this->getRecord()->alert; } + + function getRealId(): int + { + return $this->getId() * -1; + } + + function isEveryoneCanUploadAudios(): bool + { + return (bool) $this->getRecord()->everyone_can_upload_audios; + } + + function canUploadAudio(?User $user): bool + { + if(!$user) + return NULL; + + return $this->isEveryoneCanUploadAudios() || $this->canBeModifiedBy($user); + } + + function getAudiosCollectionSize() + { + return (new \openvk\Web\Models\Repositories\Audios)->getClubCollectionSize($this); + } function toVkApiStruct(?User $user = NULL): object { - $res = []; + $res = (object)[]; $res->id = $this->getId(); $res->name = $this->getName(); $res->screen_name = $this->getShortCode(); $res->is_closed = 0; $res->deactivated = NULL; - $res->is_admin = $this->canBeModifiedBy($user); + $res->is_admin = $user && $this->canBeModifiedBy($user); - if($this->canBeModifiedBy($user)) { + if($user && $this->canBeModifiedBy($user)) { $res->admin_level = 3; } - $res->is_member = $this->getSubscriptionStatus($user) ? 1 : 0; + $res->is_member = $user && $this->getSubscriptionStatus($user) ? 1 : 0; $res->type = "group"; $res->photo_50 = $this->getAvatarUrl("miniscule"); $res->photo_100 = $this->getAvatarUrl("tiny"); $res->photo_200 = $this->getAvatarUrl("normal"); - $res->can_create_topic = $this->canBeModifiedBy($user) ? 1 : ($this->isEveryoneCanCreateTopics() ? 1 : 0); + $res->can_create_topic = $user && $this->canBeModifiedBy($user) ? 1 : ($this->isEveryoneCanCreateTopics() ? 1 : 0); - $res->can_post = $this->canBeModifiedBy($user) ? 1 : ($this->canPost() ? 1 : 0); + $res->can_post = $user && $this->canBeModifiedBy($user) ? 1 : ($this->canPost() ? 1 : 0); - return (object) $res; + return $res; } use Traits\TBackDrops; use Traits\TSubscribable; + use Traits\TAudioStatuses; } diff --git a/Web/Models/Entities/MediaCollection.php b/Web/Models/Entities/MediaCollection.php index 05f3835c..1f061988 100644 --- a/Web/Models/Entities/MediaCollection.php +++ b/Web/Models/Entities/MediaCollection.php @@ -17,7 +17,17 @@ abstract class MediaCollection extends RowModel protected $specialNames = []; - private $relations; + protected $relations; + + /** + * Maximum amount of items Collection can have + */ + const MAX_ITEMS = INF; + + /** + * Maximum amount of Collections with same "owner" allowed + */ + const MAX_COUNT = INF; function __construct(?ActiveRow $ar = NULL) { @@ -70,18 +80,29 @@ abstract class MediaCollection extends RowModel } abstract function getCoverURL(): ?string; - - function fetch(int $page = 1, ?int $perPage = NULL): \Traversable + + function fetchClassic(int $offset = 0, ?int $limit = NULL): \Traversable { - $related = $this->getRecord()->related("$this->relTableName.collection")->page($page, $perPage ?? OPENVK_DEFAULT_PER_PAGE)->order("media ASC"); + $related = $this->getRecord()->related("$this->relTableName.collection") + ->limit($limit ?? OPENVK_DEFAULT_PER_PAGE, $offset) + ->order("media ASC"); + foreach($related as $rel) { $media = $rel->ref($this->entityTableName, "media"); if(!$media) continue; - + yield new $this->entityClassName($media); } } + + function fetch(int $page = 1, ?int $perPage = NULL): \Traversable + { + $page = max(1, $page); + $perPage ??= OPENVK_DEFAULT_PER_PAGE; + + return $this->fetchClassic($perPage * ($page - 1), $perPage); + } function size(): int { @@ -110,7 +131,7 @@ abstract class MediaCollection extends RowModel { return $this->getRecord()->special_type !== 0; } - + function add(RowModel $entity): bool { $this->entitySuitable($entity); @@ -118,6 +139,10 @@ abstract class MediaCollection extends RowModel if(!$this->allowDuplicates) if($this->has($entity)) return false; + + if(self::MAX_ITEMS != INF) + if(sizeof($this->relations->where("collection", $this->getId())) > self::MAX_ITEMS) + throw new \OutOfBoundsException("Collection is full"); $this->relations->insert([ "collection" => $this->getId(), @@ -127,14 +152,14 @@ abstract class MediaCollection extends RowModel return true; } - function remove(RowModel $entity): void + function remove(RowModel $entity): bool { $this->entitySuitable($entity); - $this->relations->where([ + return $this->relations->where([ "collection" => $this->getId(), "media" => $entity->getId(), - ])->delete(); + ])->delete() > 0; } function has(RowModel $entity): bool @@ -148,6 +173,33 @@ abstract class MediaCollection extends RowModel return !is_null($rel); } - + + function save(?bool $log = false): void + { + $thisTable = DatabaseConnection::i()->getContext()->table($this->tableName); + if(self::MAX_COUNT != INF) + if(isset($this->changes["owner"])) + if(sizeof($thisTable->where("owner", $this->changes["owner"])) > self::MAX_COUNT) + throw new \OutOfBoundsException("Maximum amount of collections"); + + if(is_null($this->getRecord())) + if(!isset($this->changes["created"])) + $this->stateChanges("created", time()); + else + $this->stateChanges("edited", time()); + + parent::save($log); + } + + function delete(bool $softly = true): void + { + if(!$softly) { + $this->relations->where("collection", $this->getId()) + ->delete(); + } + + parent::delete($softly); + } + use Traits\TOwnable; } diff --git a/Web/Models/Entities/Playlist.php b/Web/Models/Entities/Playlist.php new file mode 100644 index 00000000..c027a038 --- /dev/null +++ b/Web/Models/Entities/Playlist.php @@ -0,0 +1,256 @@ +<?php declare(strict_types=1); +namespace openvk\Web\Models\Entities; +use Chandler\Database\DatabaseConnection; +use Nette\Database\Table\ActiveRow; +use openvk\Web\Models\Repositories\Audios; +use openvk\Web\Models\Repositories\Photos; +use openvk\Web\Models\RowModel; +use openvk\Web\Models\Entities\Photo; + +/** + * @method setName(string $name) + * @method setDescription(?string $desc) + */ +class Playlist extends MediaCollection +{ + protected $tableName = "playlists"; + protected $relTableName = "playlist_relations"; + protected $entityTableName = "audios"; + protected $entityClassName = 'openvk\Web\Models\Entities\Audio'; + protected $allowDuplicates = false; + + private $importTable; + + const MAX_COUNT = 1000; + const MAX_ITEMS = 10000; + + function __construct(?ActiveRow $ar = NULL) + { + parent::__construct($ar); + + $this->importTable = DatabaseConnection::i()->getContext()->table("playlist_imports"); + } + + function getCoverURL(string $size = "normal"): ?string + { + $photo = (new Photos)->get((int) $this->getRecord()->cover_photo_id); + return is_null($photo) ? "/assets/packages/static/openvk/img/song.jpg" : $photo->getURLBySizeId($size); + } + + function getLength(): int + { + return $this->getRecord()->length; + } + + function getAudios(int $offset = 0, ?int $limit = NULL, ?int $shuffleSeed = NULL): \Traversable + { + if(!$shuffleSeed) { + foreach ($this->fetchClassic($offset, $limit) as $e) + yield $e; # No, I can't return, it will break with [] + + return; + } + + $ids = []; + foreach($this->relations->select("media AS i")->where("collection", $this->getId()) as $rel) + $ids[] = $rel->i; + + $ids = knuth_shuffle($ids, $shuffleSeed); + $ids = array_slice($ids, $offset, $limit ?? OPENVK_DEFAULT_PER_PAGE); + foreach($ids as $id) + yield (new Audios)->get($id); + } + + function add(RowModel $audio): bool + { + if($res = parent::add($audio)) { + $this->stateChanges("length", $this->getRecord()->length + $audio->getLength()); + $this->save(); + } + + return $res; + } + + function remove(RowModel $audio): bool + { + if($res = parent::remove($audio)) { + $this->stateChanges("length", $this->getRecord()->length - $audio->getLength()); + $this->save(); + } + + return $res; + } + + function isBookmarkedBy(RowModel $entity): bool + { + $id = $entity->getId(); + if($entity instanceof Club) + $id *= -1; + + return !is_null($this->importTable->where([ + "entity" => $id, + "playlist" => $this->getId(), + ])->fetch()); + } + + function bookmark(RowModel $entity): bool + { + if($this->isBookmarkedBy($entity)) + return false; + + $id = $entity->getId(); + if($entity instanceof Club) + $id *= -1; + + if($this->importTable->where("entity", $id)->count() > self::MAX_COUNT) + throw new \OutOfBoundsException("Maximum amount of playlists"); + + $this->importTable->insert([ + "entity" => $id, + "playlist" => $this->getId(), + ]); + + return true; + } + + function unbookmark(RowModel $entity): bool + { + $id = $entity->getId(); + if($entity instanceof Club) + $id *= -1; + + $count = $this->importTable->where([ + "entity" => $id, + "playlist" => $this->getId(), + ])->delete(); + + return $count > 0; + } + + function getDescription(): ?string + { + return $this->getRecord()->description; + } + + function getDescriptionHTML(): ?string + { + return htmlspecialchars($this->getRecord()->description, ENT_DISALLOWED | ENT_XHTML); + } + + function getListens() + { + return $this->getRecord()->listens; + } + + function toVkApiStruct(?User $user = NULL): object + { + $oid = $this->getOwner()->getId(); + if($this->getOwner() instanceof Club) + $oid *= -1; + + return (object) [ + "id" => $this->getId(), + "owner_id" => $oid, + "title" => $this->getName(), + "description" => $this->getDescription(), + "size" => $this->size(), + "length" => $this->getLength(), + "created" => $this->getCreationTime()->timestamp(), + "modified" => $this->getEditTime() ? $this->getEditTime()->timestamp() : NULL, + "accessible" => $this->canBeViewedBy($user), + "editable" => $this->canBeModifiedBy($user), + "bookmarked" => $this->isBookmarkedBy($user), + "listens" => $this->getListens(), + "cover_url" => $this->getCoverURL(), + ]; + } + + function setLength(): void + { + throw new \LogicException("Can't set length of playlist manually"); + } + + function resetLength(): bool + { + $this->stateChanges("length", 0); + + return true; + } + + function delete(bool $softly = true): void + { + $ctx = DatabaseConnection::i()->getContext(); + $ctx->table("playlist_imports")->where("playlist", $this->getId()) + ->delete(); + + parent::delete($softly); + } + + function hasAudio(Audio $audio): bool + { + $ctx = DatabaseConnection::i()->getContext(); + return !is_null($ctx->table("playlist_relations")->where([ + "collection" => $this->getId(), + "media" => $audio->getId() + ])->fetch()); + } + + function getCoverPhotoId(): ?int + { + return $this->getRecord()->cover_photo_id; + } + + function canBeModifiedBy(User $user): bool + { + if(!$user) + return false; + + if($this->getOwner() instanceof User) + return $user->getId() == $this->getOwner()->getId(); + else + return $this->getOwner()->canBeModifiedBy($user); + } + + function getLengthInMinutes(): int + { + return (int)round($this->getLength() / 60, PHP_ROUND_HALF_DOWN); + } + + function fastMakeCover(int $owner, array $file) + { + $cover = new Photo; + $cover->setOwner($owner); + $cover->setDescription("Playlist cover image"); + $cover->setFile($file); + $cover->setCreated(time()); + $cover->save(); + + $this->setCover_photo_id($cover->getId()); + + return $cover; + } + + function getURL(): string + { + return "/playlist" . $this->getOwner()->getRealId() . "_" . $this->getId(); + } + + function incrementListens() + { + $this->stateChanges("listens", ($this->getListens() + 1)); + } + + function getMetaDescription(): string + { + $length = $this->getLengthInMinutes(); + + $props = []; + $props[] = tr("audios_count", $this->size()); + $props[] = "<span id='listensCount'>" . tr("listens_count", $this->getListens()) . "</span>"; + if($length > 0) $props[] = tr("minutes_count", $length); + $props[] = tr("created_playlist") . " " . $this->getPublicationTime(); + # if($this->getEditTime()) $props[] = tr("updated_playlist") . " " . $this->getEditTime(); + + return implode(" • ", $props); + } +} diff --git a/Web/Models/Entities/Report.php b/Web/Models/Entities/Report.php index d449a2e8..5487056f 100644 --- a/Web/Models/Entities/Report.php +++ b/Web/Models/Entities/Report.php @@ -5,7 +5,7 @@ use Nette\Database\Table\ActiveRow; use openvk\Web\Models\RowModel; use openvk\Web\Models\Entities\Club; use Chandler\Database\DatabaseConnection; -use openvk\Web\Models\Repositories\{Applications, Comments, Notes, Reports, Users, Posts, Photos, Videos, Clubs}; +use openvk\Web\Models\Repositories\{Applications, Comments, Notes, Reports, Audios, Users, Posts, Photos, Videos, Clubs}; use Chandler\Database\DatabaseConnection as DB; use Nette\InvalidStateException as ISE; use Nette\Database\Table\Selection; @@ -74,6 +74,7 @@ class Report extends RowModel else if ($this->getContentType() == "note") return (new Notes)->get($this->getContentId()); else if ($this->getContentType() == "app") return (new Applications)->get($this->getContentId()); else if ($this->getContentType() == "user") return (new Users)->get($this->getContentId()); + else if ($this->getContentType() == "audio") return (new Audios)->get($this->getContentId()); else return null; } diff --git a/Web/Models/Entities/Traits/TAudioStatuses.php b/Web/Models/Entities/Traits/TAudioStatuses.php new file mode 100644 index 00000000..f957a104 --- /dev/null +++ b/Web/Models/Entities/Traits/TAudioStatuses.php @@ -0,0 +1,38 @@ +<?php declare(strict_types=1); +namespace openvk\Web\Models\Entities\Traits; +use openvk\Web\Models\Repositories\Audios; +use Chandler\Database\DatabaseConnection; + +trait TAudioStatuses +{ + function isBroadcastEnabled(): bool + { + if($this->getRealId() < 0) return true; + return (bool) $this->getRecord()->audio_broadcast_enabled; + } + + function getCurrentAudioStatus() + { + if(!$this->isBroadcastEnabled()) return NULL; + + $audioId = $this->getRecord()->last_played_track; + + if(!$audioId) return NULL; + $audio = (new Audios)->get($audioId); + + if(!$audio || $audio->isDeleted()) + return NULL; + + $listensTable = DatabaseConnection::i()->getContext()->table("audio_listens"); + $lastListen = $listensTable->where([ + "entity" => $this->getRealId(), + "audio" => $audio->getId(), + "time >" => (time() - $audio->getLength()) - 10, + ])->fetch(); + + if($lastListen) + return $audio; + + return NULL; + } +} diff --git a/Web/Models/Entities/Traits/TOwnable.php b/Web/Models/Entities/Traits/TOwnable.php index 9dc9ce2a..08e5fde3 100644 --- a/Web/Models/Entities/Traits/TOwnable.php +++ b/Web/Models/Entities/Traits/TOwnable.php @@ -4,6 +4,12 @@ use openvk\Web\Models\Entities\User; trait TOwnable { + function canBeViewedBy(?User $user): bool + { + // TODO implement normal check in master + return true; + } + function canBeModifiedBy(User $user): bool { if(method_exists($this, "isCreatedBySystem")) diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php index dc227039..291dfb6a 100644 --- a/Web/Models/Entities/User.php +++ b/Web/Models/Entities/User.php @@ -4,7 +4,7 @@ use morphos\Gender; use openvk\Web\Themes\{Themepack, Themepacks}; use openvk\Web\Util\DateTime; use openvk\Web\Models\RowModel; -use openvk\Web\Models\Entities\{Photo, Message, Correspondence, Gift}; +use openvk\Web\Models\Entities\{Photo, Message, Correspondence, Gift, Audio}; use openvk\Web\Models\Repositories\{Applications, Bans, Comments, Notes, Posts, Users, Clubs, Albums, Gifts, Notifications, Videos, Photos}; use openvk\Web\Models\Exceptions\InvalidUserNameException; use Nette\Database\Table\ActiveRow; @@ -190,7 +190,7 @@ class User extends RowModel function getMorphedName(string $case = "genitive", bool $fullName = true): string { $name = $fullName ? ($this->getLastName() . " " . $this->getFirstName()) : $this->getFirstName(); - if(!preg_match("%^[А-яё\-]+$%", $name)) + if(!preg_match("%[А-яё\-]+$%", $name)) return $name; # name is probably not russian $inflected = inflectName($name, $case, $this->isFemale() ? Gender::FEMALE : Gender::MALE); @@ -455,6 +455,7 @@ class User extends RowModel "length" => 1, "mappings" => [ "photos", + "audios", "videos", "messages", "notes", @@ -462,7 +463,7 @@ class User extends RowModel "news", "links", "poster", - "apps" + "apps", ], ])->get($id); } @@ -482,6 +483,7 @@ class User extends RowModel "friends.add", "wall.write", "messages.write", + "audios.read", ], ])->get($id); } @@ -1010,6 +1012,7 @@ class User extends RowModel "friends.add", "wall.write", "messages.write", + "audios.read", ], ])->set($id, $status)->toInteger()); } @@ -1020,6 +1023,7 @@ class User extends RowModel "length" => 1, "mappings" => [ "photos", + "audios", "videos", "messages", "notes", @@ -1027,7 +1031,7 @@ class User extends RowModel "news", "links", "poster", - "apps" + "apps", ], ])->set($id, (int) $status)->toInteger(); @@ -1223,6 +1227,11 @@ class User extends RowModel return $response; } + function getRealId() + { + return $this->getId(); + } + function toVkApiStruct(): object { $res = (object) []; @@ -1239,7 +1248,45 @@ class User extends RowModel return $res; } + + function getAudiosCollectionSize() + { + return (new \openvk\Web\Models\Repositories\Audios)->getUserCollectionSize($this); + } + + function getBroadcastList(string $filter = "friends", bool $shuffle = false) + { + $dbContext = DatabaseConnection::i()->getContext(); + $entityIds = []; + $query = $dbContext->table("subscriptions")->where("follower", $this->getRealId()); + + if($filter != "all") + $query = $query->where("model = ?", "openvk\\Web\\Models\\Entities\\" . ($filter == "groups" ? "Club" : "User")); + + foreach($query as $_rel) { + $entityIds[] = $_rel->model == "openvk\\Web\\Models\\Entities\\Club" ? $_rel->target * -1 : $_rel->target; + } + + if($shuffle) { + $shuffleSeed = openssl_random_pseudo_bytes(6); + $shuffleSeed = hexdec(bin2hex($shuffleSeed)); + $entityIds = knuth_shuffle($entityIds, $shuffleSeed); + } + + $returnArr = []; + + foreach($entityIds as $id) { + $entit = $id > 0 ? (new Users)->get($id) : (new Clubs)->get(abs($id)); + + if($id > 0 && $entit->isDeleted()) return; + $returnArr[] = $entit; + } + + return $returnArr; + } + use Traits\TBackDrops; use Traits\TSubscribable; + use Traits\TAudioStatuses; } diff --git a/Web/Models/Entities/Video.php b/Web/Models/Entities/Video.php index cef48e27..db520ee1 100644 --- a/Web/Models/Entities/Video.php +++ b/Web/Models/Entities/Video.php @@ -1,7 +1,7 @@ <?php declare(strict_types=1); namespace openvk\Web\Models\Entities; use openvk\Web\Util\Shell\Shell; -use openvk\Web\Util\Shell\Shell\Exceptions\{ShellUnavailableException, UnknownCommandException}; +use openvk\Web\Util\Shell\Exceptions\{ShellUnavailableException, UnknownCommandException}; use openvk\Web\Models\VideoDrivers\VideoDriver; use Nette\InvalidStateException as ISE; diff --git a/Web/Models/Repositories/Audios.php b/Web/Models/Repositories/Audios.php new file mode 100644 index 00000000..ad5e822e --- /dev/null +++ b/Web/Models/Repositories/Audios.php @@ -0,0 +1,296 @@ +<?php declare(strict_types=1); +namespace openvk\Web\Models\Repositories; +use Chandler\Database\DatabaseConnection; +use openvk\Web\Models\Entities\Audio; +use openvk\Web\Models\Entities\Club; +use openvk\Web\Models\Entities\Playlist; +use openvk\Web\Models\Entities\User; +use openvk\Web\Models\Repositories\Util\EntityStream; + +class Audios +{ + private $context; + private $audios; + private $rels; + private $playlists; + private $playlistImports; + private $playlistRels; + + const ORDER_NEW = 0; + const ORDER_POPULAR = 1; + + const VK_ORDER_NEW = 0; + const VK_ORDER_LENGTH = 1; + const VK_ORDER_POPULAR = 2; + + function __construct() + { + $this->context = DatabaseConnection::i()->getContext(); + $this->audios = $this->context->table("audios"); + $this->rels = $this->context->table("audio_relations"); + + $this->playlists = $this->context->table("playlists"); + $this->playlistImports = $this->context->table("playlist_imports"); + $this->playlistRels = $this->context->table("playlist_relations"); + } + + function get(int $id): ?Audio + { + $audio = $this->audios->get($id); + if(!$audio) + return NULL; + + return new Audio($audio); + } + + function getPlaylist(int $id): ?Playlist + { + $playlist = $this->playlists->get($id); + if(!$playlist) + return NULL; + + return new Playlist($playlist); + } + + function getByOwnerAndVID(int $owner, int $vId): ?Audio + { + $audio = $this->audios->where([ + "owner" => $owner, + "virtual_id" => $vId, + ])->fetch(); + if(!$audio) return NULL; + + return new Audio($audio); + } + + function getPlaylistByOwnerAndVID(int $owner, int $vId): ?Playlist + { + $playlist = $this->playlists->where([ + "owner" => $owner, + "id" => $vId, + ])->fetch(); + if(!$playlist) return NULL; + + return new Playlist($playlist); + } + + function getByEntityID(int $entity, int $offset = 0, ?int $limit = NULL, ?int& $deleted = nullptr): \Traversable + { + $limit ??= OPENVK_DEFAULT_PER_PAGE; + $iter = $this->rels->where("entity", $entity)->limit($limit, $offset); + foreach($iter as $rel) { + $audio = $this->get($rel->audio); + if(!$audio || $audio->isDeleted()) { + $deleted++; + continue; + } + + yield $audio; + } + } + + function getPlaylistsByEntityId(int $entity, int $offset = 0, ?int $limit = NULL, ?int& $deleted = nullptr): \Traversable + { + $limit ??= OPENVK_DEFAULT_PER_PAGE; + $iter = $this->playlistImports->where("entity", $entity)->limit($limit, $offset); + foreach($iter as $rel) { + $playlist = $this->getPlaylist($rel->playlist); + if(!$playlist || $playlist->isDeleted()) { + $deleted++; + continue; + } + + yield $playlist; + } + } + + function getByUser(User $user, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable + { + return $this->getByEntityID($user->getId(), ($perPage * ($page - 1)), $perPage, $deleted); + } + + function getRandomThreeAudiosByEntityId(int $id): Array + { + $iter = $this->rels->where("entity", $id); + $ids = []; + + foreach($iter as $it) + $ids[] = $it->audio; + + $shuffleSeed = openssl_random_pseudo_bytes(6); + $shuffleSeed = hexdec(bin2hex($shuffleSeed)); + + $ids = knuth_shuffle($ids, $shuffleSeed); + $ids = array_slice($ids, 0, 3); + $audios = []; + + foreach($ids as $id) { + $audio = $this->get((int)$id); + + if(!$audio || $audio->isDeleted()) + continue; + + $audios[] = $audio; + } + + return $audios; + } + + function getByClub(Club $club, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable + { + return $this->getByEntityID($club->getId() * -1, ($perPage * ($page - 1)), $perPage, $deleted); + } + + function getPlaylistsByUser(User $user, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable + { + return $this->getPlaylistsByEntityId($user->getId(), ($perPage * ($page - 1)), $perPage, $deleted); + } + + function getPlaylistsByClub(Club $club, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable + { + return $this->getPlaylistsByEntityId($club->getId() * -1, ($perPage * ($page - 1)), $perPage, $deleted); + } + + function getCollectionSizeByEntityId(int $id): int + { + return sizeof($this->rels->where("entity", $id)); + } + + function getUserCollectionSize(User $user): int + { + return sizeof($this->rels->where("entity", $user->getId())); + } + + function getClubCollectionSize(Club $club): int + { + return sizeof($this->rels->where("entity", $club->getId() * -1)); + } + + function getUserPlaylistsCount(User $user): int + { + return sizeof($this->playlistImports->where("entity", $user->getId())); + } + + function getClubPlaylistsCount(Club $club): int + { + return sizeof($this->playlistImports->where("entity", $club->getId() * -1)); + } + + function getByUploader(User $user): EntityStream + { + $search = $this->audios->where([ + "owner" => $user->getId(), + "deleted" => 0, + ]); + + return new EntityStream("Audio", $search); + } + + function getGlobal(int $order, ?string $genreId = NULL): EntityStream + { + $search = $this->audios->where([ + "deleted" => 0, + "unlisted" => 0, + "withdrawn" => 0, + ])->order($order == Audios::ORDER_NEW ? "created DESC" : "listens DESC"); + + if(!is_null($genreId)) + $search = $search->where("genre", $genreId); + + return new EntityStream("Audio", $search); + } + + function search(string $query, int $sortMode = 0, bool $performerOnly = false, bool $withLyrics = false): EntityStream + { + $columns = $performerOnly ? "performer" : "performer, name"; + $order = (["created", "length", "listens"][$sortMode] ?? "") . " DESC"; + + $search = $this->audios->where([ + "unlisted" => 0, + "deleted" => 0, + ])->where("MATCH ($columns) AGAINST (? WITH QUERY EXPANSION)", $query)->order($order); + + if($withLyrics) + $search = $search->where("lyrics IS NOT NULL"); + + return new EntityStream("Audio", $search); + } + + function searchPlaylists(string $query): EntityStream + { + $search = $this->playlists->where([ + "deleted" => 0, + ])->where("MATCH (`name`, `description`) AGAINST (? IN BOOLEAN MODE)", $query); + + return new EntityStream("Playlist", $search); + } + + function getNew(): EntityStream + { + return new EntityStream("Audio", $this->audios->where("created >= " . (time() - 259200))->where(["withdrawn" => 0, "deleted" => 0, "unlisted" => 0])->order("created DESC")->limit(25)); + } + + function getPopular(): EntityStream + { + return new EntityStream("Audio", $this->audios->where("listens > 0")->where(["withdrawn" => 0, "deleted" => 0, "unlisted" => 0])->order("listens DESC")->limit(25)); + } + + function isAdded(int $user_id, int $audio_id): bool + { + return !is_null($this->rels->where([ + "entity" => $user_id, + "audio" => $audio_id + ])->fetch()); + } + + function find(string $query, array $pars = [], string $sort = "id DESC", int $page = 1, ?int $perPage = NULL): \Traversable + { + $query = "%$query%"; + $result = $this->audios->where([ + "unlisted" => 0, + "deleted" => 0, + ]); + + $notNullParams = []; + + foreach($pars as $paramName => $paramValue) + if($paramName != "before" && $paramName != "after" && $paramName != "only_performers") + $paramValue != NULL ? $notNullParams+=["$paramName" => "%$paramValue%"] : NULL; + else + $paramValue != NULL ? $notNullParams+=["$paramName" => "$paramValue"] : NULL; + + $nnparamsCount = sizeof($notNullParams); + + if($notNullParams["only_performers"] == "1") { + $result->where("performer LIKE ?", $query); + } else { + $result->where("name LIKE ? OR performer LIKE ?", $query, $query); + } + + if($nnparamsCount > 0) { + foreach($notNullParams as $paramName => $paramValue) { + switch($paramName) { + case "before": + $result->where("created < ?", $paramValue); + break; + case "after": + $result->where("created > ?", $paramValue); + break; + case "with_lyrics": + $result->where("lyrics IS NOT NULL"); + break; + } + } + } + + return new Util\EntityStream("Audio", $result->order($sort)); + } + + function findPlaylists(string $query, int $page = 1, ?int $perPage = NULL): \Traversable + { + $query = "%$query%"; + $result = $this->playlists->where("name LIKE ?", $query); + + return new Util\EntityStream("Playlist", $result); + } +} diff --git a/Web/Models/shell/processAudio.ps1 b/Web/Models/shell/processAudio.ps1 new file mode 100644 index 00000000..206e11e1 --- /dev/null +++ b/Web/Models/shell/processAudio.ps1 @@ -0,0 +1,39 @@ +$ovkRoot = $args[0] +$storageDir = $args[1] +$fileHash = $args[2] +$hashPart = $fileHash.substring(0, 2) +$filename = $args[3] +$audioFile = [System.IO.Path]::GetTempFileName() +$temp = [System.IO.Path]::GetTempFileName() + +$keyID = $args[4] +$key = $args[5] +$token = $args[6] +$seg = $args[7] + +$shell = Get-WmiObject Win32_process -filter "ProcessId = $PID" +$shell.SetPriority(16384) # because there's no "nice" program in Windows we just set a lower priority for entire tree + +Remove-Item $temp +Remove-Item $audioFile +New-Item -ItemType "directory" $temp +New-Item -ItemType "directory" ("$temp/$fileHash" + '_fragments') +New-Item -ItemType "directory" ("$storageDir/$hashPart/$fileHash" + '_fragments') +Set-Location -Path $temp + +Move-Item $filename $audioFile +ffmpeg -i $audioFile -f dash -encryption_scheme cenc-aes-ctr -encryption_key $key ` + -encryption_kid $keyID -map 0:a -c:a aac -ar 44100 -seg_duration $seg ` + -use_timeline 1 -use_template 1 -init_seg_name ($fileHash + '_fragments/0_0.$ext$') ` + -media_seg_name ($fileHash + '_fragments/chunk$Number%06d$_$RepresentationID$.$ext$') -adaptation_sets 'id=0,streams=a' ` + "$fileHash.mpd" + +ffmpeg -i $audioFile -vn -ar 44100 "original_$token.mp3" +Move-Item "original_$token.mp3" ($fileHash + '_fragments') + +Get-ChildItem -Path ($fileHash + '_fragments/*') | Move-Item -Destination ("$storageDir/$hashPart/$fileHash" + '_fragments') +Move-Item -Path ("$fileHash.mpd") -Destination "$storageDir/$hashPart" + +cd .. +Remove-Item -Recurse $temp +Remove-Item $audioFile diff --git a/Web/Models/shell/processAudio.sh b/Web/Models/shell/processAudio.sh new file mode 100644 index 00000000..ab5a5c55 --- /dev/null +++ b/Web/Models/shell/processAudio.sh @@ -0,0 +1,35 @@ +ovkRoot=$1 +storageDir=$2 +fileHash=$3 +hashPart=$(echo $fileHash | cut -c1-2) +filename=$4 +audioFile=$(mktemp) +temp=$(mktemp -d) + +keyID=$5 +key=$6 +token=$7 +seg=$8 + +trap 'rm -f "$temp" "$audioFile"' EXIT + +mkdir -p "$temp/$fileHash"_fragments +mkdir -p "$storageDir/$hashPart/$fileHash"_fragments +cd "$temp" + +mv "$filename" "$audioFile" +ffmpeg -i "$audioFile" -f dash -encryption_scheme cenc-aes-ctr -encryption_key "$key" \ + -encryption_kid "$keyID" -map 0 -c:a aac -ar 44100 -seg_duration "$seg" \ + -use_timeline 1 -use_template 1 -init_seg_name "$fileHash"_fragments/0_0."\$ext\$" \ + -media_seg_name "$fileHash"_fragments/chunk"\$Number"%06d\$_"\$RepresentationID\$"."\$ext\$" -adaptation_sets 'id=0,streams=a' \ + "$fileHash.mpd" + +ffmpeg -i "$audioFile" -vn -ar 44100 "original_$token.mp3" +mv "original_$token.mp3" "$fileHash"_fragments + +mv "$fileHash"_fragments "$storageDir/$hashPart" +mv "$fileHash.mpd" "$storageDir/$hashPart" + +cd .. +rm -rf "$temp" +rm -f "$audioFile" diff --git a/Web/Presenters/AdminPresenter.php b/Web/Presenters/AdminPresenter.php index 14fbbc74..658d2f4b 100644 --- a/Web/Presenters/AdminPresenter.php +++ b/Web/Presenters/AdminPresenter.php @@ -3,7 +3,19 @@ namespace openvk\Web\Presenters; use Chandler\Database\Log; use Chandler\Database\Logs; use openvk\Web\Models\Entities\{Voucher, Gift, GiftCategory, User, BannedLink}; -use openvk\Web\Models\Repositories\{Bans, ChandlerGroups, ChandlerUsers, Photos, Posts, Users, Clubs, Videos, Vouchers, Gifts, BannedLinks}; +use openvk\Web\Models\Repositories\{Audios, + ChandlerGroups, + ChandlerUsers, + Users, + Clubs, + Util\EntityStream, + Vouchers, + Gifts, + BannedLinks, + Bans, + Photos, + Posts, + Videos}; use Chandler\Database\DatabaseConnection; final class AdminPresenter extends OpenVKPresenter @@ -14,9 +26,10 @@ final class AdminPresenter extends OpenVKPresenter private $gifts; private $bannedLinks; private $chandlerGroups; + private $audios; private $logs; - function __construct(Users $users, Clubs $clubs, Vouchers $vouchers, Gifts $gifts, BannedLinks $bannedLinks, ChandlerGroups $chandlerGroups) + function __construct(Users $users, Clubs $clubs, Vouchers $vouchers, Gifts $gifts, BannedLinks $bannedLinks, ChandlerGroups $chandlerGroups, Audios $audios) { $this->users = $users; $this->clubs = $clubs; @@ -24,8 +37,9 @@ final class AdminPresenter extends OpenVKPresenter $this->gifts = $gifts; $this->bannedLinks = $bannedLinks; $this->chandlerGroups = $chandlerGroups; + $this->audios = $audios; $this->logs = DatabaseConnection::i()->getContext()->table("ChandlerLogs"); - + parent::__construct(); } @@ -43,6 +57,15 @@ final class AdminPresenter extends OpenVKPresenter $count = $repo->find($query)->size(); return $repo->find($query)->page($page, 20); } + + private function searchPlaylists(&$count) + { + $query = $this->queryParam("q") ?? ""; + $page = (int) ($this->queryParam("p") ?? 1); + + $count = $this->audios->findPlaylists($query)->size(); + return $this->audios->findPlaylists($query)->page($page, 20); + } function onStartup(): void { @@ -578,6 +601,54 @@ final class AdminPresenter extends OpenVKPresenter $this->redirect("/admin/users/id" . $user->getId()); } + function renderMusic(): void + { + $this->template->mode = in_array($this->queryParam("act"), ["audios", "playlists"]) ? $this->queryParam("act") : "audios"; + if ($this->template->mode === "audios") + $this->template->audios = $this->searchResults($this->audios, $this->template->count); + else + $this->template->playlists = $this->searchPlaylists($this->template->count); + } + + function renderEditMusic(int $audio_id): void + { + $audio = $this->audios->get($audio_id); + $this->template->audio = $audio; + + try { + $this->template->owner = $audio->getOwner()->getId(); + } catch(\Throwable $e) { + $this->template->owner = 1; + } + + if ($_SERVER["REQUEST_METHOD"] === "POST") { + $audio->setName($this->postParam("name")); + $audio->setPerformer($this->postParam("performer")); + $audio->setLyrics($this->postParam("text")); + $audio->setGenre($this->postParam("genre")); + $audio->setOwner((int) $this->postParam("owner")); + $audio->setExplicit(!empty($this->postParam("explicit"))); + $audio->setDeleted(!empty($this->postParam("deleted"))); + $audio->setWithdrawn(!empty($this->postParam("withdrawn"))); + $audio->save(); + } + } + + function renderEditPlaylist(int $playlist_id): void + { + $playlist = $this->audios->getPlaylist($playlist_id); + $this->template->playlist = $playlist; + + if ($_SERVER["REQUEST_METHOD"] === "POST") { + $playlist->setName($this->postParam("name")); + $playlist->setDescription($this->postParam("description")); + $playlist->setCover_Photo_Id((int) $this->postParam("photo")); + $playlist->setOwner((int) $this->postParam("owner")); + $playlist->setDeleted(!empty($this->postParam("deleted"))); + $playlist->save(); + } + } + function renderLogs(): void { $filter = []; diff --git a/Web/Presenters/AudioPresenter.php b/Web/Presenters/AudioPresenter.php new file mode 100644 index 00000000..3bae710c --- /dev/null +++ b/Web/Presenters/AudioPresenter.php @@ -0,0 +1,696 @@ +<?php declare(strict_types=1); +namespace openvk\Web\Presenters; +use Chandler\Database\DatabaseConnection; +use openvk\Web\Models\Entities\Audio; +use openvk\Web\Models\Entities\Club; +use openvk\Web\Models\Entities\Photo; +use openvk\Web\Models\Entities\Playlist; +use openvk\Web\Models\Repositories\Audios; +use openvk\Web\Models\Repositories\Clubs; +use openvk\Web\Models\Repositories\Users; + +final class AudioPresenter extends OpenVKPresenter +{ + private $audios; + protected $presenterName = "audios"; + + const MAX_AUDIO_SIZE = 25000000; + + function __construct(Audios $audios) + { + $this->audios = $audios; + } + + function renderPopular(): void + { + $this->renderList(NULL, "popular"); + } + + function renderNew(): void + { + $this->renderList(NULL, "new"); + } + + function renderList(?int $owner = NULL, ?string $mode = "list"): void + { + $this->template->_template = "Audio/List.xml"; + $page = (int)($this->queryParam("p") ?? 1); + $audios = []; + + if ($mode === "list") { + $entity = NULL; + if ($owner < 0) { + $entity = (new Clubs)->get($owner * -1); + if (!$entity || $entity->isBanned()) + $this->redirect("/audios" . $this->user->id); + + $audios = $this->audios->getByClub($entity, $page, 10); + $audiosCount = $this->audios->getClubCollectionSize($entity); + } else { + $entity = (new Users)->get($owner); + if (!$entity || $entity->isDeleted() || $entity->isBanned()) + $this->redirect("/audios" . $this->user->id); + + if(!$entity->getPrivacyPermission("audios.read", $this->user->identity)) + $this->flashFail("err", tr("forbidden"), tr("forbidden_comment")); + + $audios = $this->audios->getByUser($entity, $page, 10); + $audiosCount = $this->audios->getUserCollectionSize($entity); + } + + if (!$entity) + $this->notFound(); + + $this->template->owner = $entity; + $this->template->ownerId = $owner; + $this->template->club = $owner < 0 ? $entity : NULL; + $this->template->isMy = ($owner > 0 && ($entity->getId() === $this->user->id)); + $this->template->isMyClub = ($owner < 0 && $entity->canBeModifiedBy($this->user->identity)); + } else if ($mode === "new") { + $audios = $this->audios->getNew(); + $audiosCount = $audios->size(); + } else if ($mode === "playlists") { + if($owner < 0) { + $entity = (new Clubs)->get(abs($owner)); + if (!$entity || $entity->isBanned()) + $this->redirect("/playlists" . $this->user->id); + + $playlists = $this->audios->getPlaylistsByClub($entity, $page, 10); + $playlistsCount = $this->audios->getClubPlaylistsCount($entity); + } else { + $entity = (new Users)->get($owner); + if (!$entity || $entity->isDeleted() || $entity->isBanned()) + $this->redirect("/playlists" . $this->user->id); + + if(!$entity->getPrivacyPermission("audios.read", $this->user->identity)) + $this->flashFail("err", tr("forbidden"), tr("forbidden_comment")); + + $playlists = $this->audios->getPlaylistsByUser($entity, $page, 9); + $playlistsCount = $this->audios->getUserPlaylistsCount($entity); + } + + $this->template->playlists = iterator_to_array($playlists); + $this->template->playlistsCount = $playlistsCount; + $this->template->owner = $entity; + $this->template->ownerId = $owner; + $this->template->club = $owner < 0 ? $entity : NULL; + $this->template->isMy = ($owner > 0 && ($entity->getId() === $this->user->id)); + $this->template->isMyClub = ($owner < 0 && $entity->canBeModifiedBy($this->user->identity)); + } else { + $audios = $this->audios->getPopular(); + $audiosCount = $audios->size(); + } + + // $this->renderApp("owner=$owner"); + if ($audios !== []) { + $this->template->audios = iterator_to_array($audios); + $this->template->audiosCount = $audiosCount; + } + + $this->template->mode = $mode; + $this->template->page = $page; + + if(in_array($mode, ["list", "new", "popular"]) && $this->user->identity) + $this->template->friendsAudios = $this->user->identity->getBroadcastList("all", true); + } + + function renderEmbed(int $owner, int $id): void + { + $audio = $this->audios->getByOwnerAndVID($owner, $id); + if(!$audio) { + header("HTTP/1.1 404 Not Found"); + exit("<b>" . tr("audio_embed_not_found") . ".</b>"); + } else if($audio->isDeleted()) { + header("HTTP/1.1 410 Not Found"); + exit("<b>" . tr("audio_embed_deleted") . ".</b>"); + } else if($audio->isWithdrawn()) { + header("HTTP/1.1 451 Unavailable for legal reasons"); + exit("<b>" . tr("audio_embed_withdrawn") . ".</b>"); + } else if(!$audio->canBeViewedBy(NULL)) { + header("HTTP/1.1 403 Forbidden"); + exit("<b>" . tr("audio_embed_forbidden") . ".</b>"); + } else if(!$audio->isAvailable()) { + header("HTTP/1.1 425 Too Early"); + exit("<b>" . tr("audio_embed_processing") . ".</b>"); + } + + $this->template->audio = $audio; + } + + function renderUpload(): void + { + $this->assertUserLoggedIn(); + + $group = NULL; + $isAjax = $this->postParam("ajax", false) == 1; + if(!is_null($this->queryParam("gid"))) { + $gid = (int) $this->queryParam("gid"); + $group = (new Clubs)->get($gid); + if(!$group) + $this->flashFail("err", tr("forbidden"), tr("not_enough_permissions_comment"), null, $isAjax); + + if(!$group->canUploadAudio($this->user->identity)) + $this->flashFail("err", tr("forbidden"), tr("not_enough_permissions_comment"), null, $isAjax); + } + + $this->template->group = $group; + + if($_SERVER["REQUEST_METHOD"] !== "POST") + return; + + $upload = $_FILES["blob"]; + if(isset($upload) && file_exists($upload["tmp_name"])) { + if($upload["size"] > self::MAX_AUDIO_SIZE) + $this->flashFail("err", tr("error"), tr("media_file_corrupted_or_too_large"), null, $isAjax); + } else { + $err = !isset($upload) ? 65536 : $upload["error"]; + $err = str_pad(dechex($err), 9, "0", STR_PAD_LEFT); + $readableError = tr("error_generic"); + + switch($upload["error"]) { + default: + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + $readableError = tr("file_too_big"); + break; + case UPLOAD_ERR_PARTIAL: + $readableError = tr("file_loaded_partially"); + break; + case UPLOAD_ERR_NO_FILE: + $readableError = tr("file_not_uploaded"); + break; + case UPLOAD_ERR_NO_TMP_DIR: + $readableError = "Missing a temporary folder."; + break; + case UPLOAD_ERR_CANT_WRITE: + case UPLOAD_ERR_EXTENSION: + $readableError = "Failed to write file to disk. "; + break; + } + + $this->flashFail("err", tr("error"), $readableError . " " . tr("error_code", $err), null, $isAjax); + } + + $performer = $this->postParam("performer"); + $name = $this->postParam("name"); + $lyrics = $this->postParam("lyrics"); + $genre = empty($this->postParam("genre")) ? "Other" : $this->postParam("genre"); + $nsfw = ($this->postParam("explicit") ?? "off") === "on"; + if(empty($performer) || empty($name) || iconv_strlen($performer . $name) > 128) # FQN of audio must not be more than 128 chars + $this->flashFail("err", tr("error"), tr("error_insufficient_info"), null, $isAjax); + + $audio = new Audio; + $audio->setOwner($this->user->id); + $audio->setName($name); + $audio->setPerformer($performer); + $audio->setLyrics(empty($lyrics) ? NULL : $lyrics); + $audio->setGenre($genre); + $audio->setExplicit($nsfw); + + try { + $audio->setFile($upload); + } catch(\DomainException $ex) { + $e = $ex->getMessage(); + $this->flashFail("err", tr("error"), tr("media_file_corrupted_or_too_large") . " $e.", null, $isAjax); + } catch(\RuntimeException $ex) { + $this->flashFail("err", tr("error"), tr("ffmpeg_timeout"), null, $isAjax); + } catch(\BadMethodCallException $ex) { + $this->flashFail("err", tr("error"), "Загрузка аудио под Linux на данный момент не реализована. Следите за обновлениями: <a href='https://github.com/openvk/openvk/pull/512/commits'>https://github.com/openvk/openvk/pull/512/commits</a>", null, $isAjax); + } catch(\Exception $ex) { + $this->flashFail("err", tr("error"), tr("ffmpeg_not_installed"), null, $isAjax); + } + + $audio->save(); + $audio->add($group ?? $this->user->identity); + + if(!$isAjax) + $this->redirect(is_null($group) ? "/audios" . $this->user->id : "/audios-" . $group->getId()); + else { + $redirectLink = "/audios"; + + if(!is_null($group)) + $redirectLink .= $group->getRealId(); + else + $redirectLink .= $this->user->id; + + $pagesCount = (int)ceil((new Audios)->getCollectionSizeByEntityId(isset($group) ? $group->getRealId() : $this->user->id) / 10); + $redirectLink .= "?p=".$pagesCount; + + $this->returnJson([ + "success" => true, + "redirect_link" => $redirectLink, + ]); + } + } + + function renderListen(int $id): void + { + if ($_SERVER["REQUEST_METHOD"] === "POST") { + $this->assertNoCSRF(); + + if(is_null($this->user)) + $this->returnJson(["success" => false]); + + $audio = $this->audios->get($id); + + if ($audio && !$audio->isDeleted() && !$audio->isWithdrawn()) { + if(!empty($this->postParam("playlist"))) { + $playlist = (new Audios)->getPlaylist((int)$this->postParam("playlist")); + + if(!$playlist || $playlist->isDeleted() || !$playlist->canBeViewedBy($this->user->identity) || !$playlist->hasAudio($audio)) + $playlist = NULL; + } + + $listen = $audio->listen($this->user->identity, $playlist); + + $returnArr = ["success" => $listen]; + + if($playlist) + $returnArr["new_playlists_listens"] = $playlist->getListens(); + + $this->returnJson($returnArr); + } + + $this->returnJson(["success" => false]); + } else { + $this->redirect("/"); + } + } + + function renderSearch(): void + { + $this->redirect("/search?type=audios"); + } + + function renderNewPlaylist(): void + { + $this->assertUserLoggedIn(); + $this->willExecuteWriteAction(true); + + $owner = $this->user->id; + + if ($this->requestParam("gid")) { + $club = (new Clubs)->get((int) abs((int)$this->requestParam("gid"))); + if (!$club || $club->isBanned() || !$club->canBeModifiedBy($this->user->identity)) + $this->redirect("/audios" . $this->user->id); + + $owner = ($club->getId() * -1); + + $this->template->club = $club; + } + + $this->template->owner = $owner; + + if ($_SERVER["REQUEST_METHOD"] === "POST") { + $title = $this->postParam("title"); + $description = $this->postParam("description"); + $audios = !empty($this->postParam("audios")) ? array_slice(explode(",", $this->postParam("audios")), 0, 100) : []; + + if(empty($title) || iconv_strlen($title) < 1) + $this->flashFail("err", tr("error"), tr("set_playlist_name")); + + $playlist = new Playlist; + $playlist->setOwner($owner); + $playlist->setName(substr($title, 0, 125)); + $playlist->setDescription(substr($description, 0, 2045)); + + if($_FILES["cover"]["error"] === UPLOAD_ERR_OK) { + if(!str_starts_with($_FILES["cover"]["type"], "image")) + $this->flashFail("err", tr("error"), tr("not_a_photo")); + + try { + $playlist->fastMakeCover($this->user->id, $_FILES["cover"]); + } catch(\Throwable $e) { + $this->flashFail("err", tr("error"), tr("invalid_cover_photo")); + } + } + + $playlist->save(); + + foreach($audios as $audio) { + $audio = $this->audios->get((int)$audio); + + if(!$audio || $audio->isDeleted() || !$audio->canBeViewedBy($this->user->identity)) + continue; + + $playlist->add($audio); + } + + $playlist->bookmark(isset($club) ? $club : $this->user->identity); + $this->redirect("/playlist" . $owner . "_" . $playlist->getId()); + } else { + if(isset($club)) { + $this->template->audios = iterator_to_array($this->audios->getByClub($club, 1, 10)); + $count = (new Audios)->getClubCollectionSize($club); + } else { + $this->template->audios = iterator_to_array($this->audios->getByUser($this->user->identity, 1, 10)); + $count = (new Audios)->getUserCollectionSize($this->user->identity); + } + + $this->template->pagesCount = ceil($count / 10); + } + } + + function renderPlaylistAction(int $id) { + $this->assertUserLoggedIn(); + $this->willExecuteWriteAction(true); + $this->assertNoCSRF(); + + if ($_SERVER["REQUEST_METHOD"] !== "POST") { + header("HTTP/1.1 405 Method Not Allowed"); + $this->redirect("/"); + } + + $playlist = $this->audios->getPlaylist($id); + + if(!$playlist || $playlist->isDeleted()) + $this->flashFail("err", "error", tr("invalid_playlist"), null, true); + + switch ($this->queryParam("act")) { + case "bookmark": + if(!$playlist->isBookmarkedBy($this->user->identity)) + $playlist->bookmark($this->user->identity); + else + $this->flashFail("err", "error", tr("playlist_already_bookmarked"), null, true); + + break; + case "unbookmark": + if($playlist->isBookmarkedBy($this->user->identity)) + $playlist->unbookmark($this->user->identity); + else + $this->flashFail("err", "error", tr("playlist_not_bookmarked"), null, true); + + break; + case "delete": + if($playlist->canBeModifiedBy($this->user->identity)) { + $tmOwner = $playlist->getOwner(); + $playlist->delete(); + } else + $this->flashFail("err", "error", tr("access_denied"), null, true); + + $this->returnJson(["success" => true, "id" => $tmOwner->getRealId()]); + break; + default: + break; + } + + $this->returnJson(["success" => true]); + } + + function renderEditPlaylist(int $owner_id, int $virtual_id) + { + $this->assertUserLoggedIn(); + $this->willExecuteWriteAction(); + + $playlist = $this->audios->getPlaylistByOwnerAndVID($owner_id, $virtual_id); + $page = (int)($this->queryParam("p") ?? 1); + if (!$playlist || $playlist->isDeleted() || !$playlist->canBeModifiedBy($this->user->identity)) + $this->notFound(); + + $this->template->playlist = $playlist; + $this->template->page = $page; + + $audios = iterator_to_array($playlist->fetch(1, $playlist->size())); + $this->template->audios = array_slice($audios, 0, 10); + $audiosIds = []; + + foreach($audios as $aud) + $audiosIds[] = $aud->getId(); + + $this->template->audiosIds = implode(",", array_unique($audiosIds)) . ","; + $this->template->ownerId = $owner_id; + $this->template->owner = $playlist->getOwner(); + $this->template->pagesCount = $pagesCount = ceil($playlist->size() / 10); + + if($_SERVER["REQUEST_METHOD"] !== "POST") + return; + + $title = $this->postParam("title"); + $description = $this->postParam("description"); + $new_audios = !empty($this->postParam("audios")) ? explode(",", rtrim($this->postParam("audios"), ",")) : []; + + if(empty($title) || iconv_strlen($title) < 1) + $this->flashFail("err", tr("error"), tr("set_playlist_name")); + + $playlist->setName(ovk_proc_strtr($title, 125)); + $playlist->setDescription(ovk_proc_strtr($description, 2045)); + $playlist->setEdited(time()); + $playlist->resetLength(); + + if($_FILES["new_cover"]["error"] === UPLOAD_ERR_OK) { + if(!str_starts_with($_FILES["new_cover"]["type"], "image")) + $this->flashFail("err", tr("error"), tr("not_a_photo")); + + try { + $playlist->fastMakeCover($this->user->id, $_FILES["new_cover"]); + } catch(\Throwable $e) { + $this->flashFail("err", tr("error"), tr("invalid_cover_photo")); + } + } + + $playlist->save(); + + DatabaseConnection::i()->getContext()->table("playlist_relations")->where([ + "collection" => $playlist->getId() + ])->delete(); + + foreach ($new_audios as $new_audio) { + $audio = (new Audios)->get((int)$new_audio); + + if(!$audio || $audio->isDeleted()) + continue; + + $playlist->add($audio); + } + + $this->redirect("/playlist".$playlist->getPrettyId()); + } + + function renderPlaylist(int $owner_id, int $virtual_id): void + { + $playlist = $this->audios->getPlaylistByOwnerAndVID($owner_id, $virtual_id); + $page = (int)($this->queryParam("p") ?? 1); + if (!$playlist || $playlist->isDeleted()) + $this->notFound(); + + $this->template->playlist = $playlist; + $this->template->page = $page; + $this->template->audios = iterator_to_array($playlist->fetch($page, 10)); + $this->template->ownerId = $owner_id; + $this->template->owner = $playlist->getOwner(); + $this->template->isBookmarked = $playlist->isBookmarkedBy($this->user->identity); + $this->template->isMy = $playlist->getOwner()->getId() === $this->user->id; + $this->template->canEdit = $playlist->canBeModifiedBy($this->user->identity); + } + + function renderAction(int $audio_id): void + { + $this->assertUserLoggedIn(); + $this->willExecuteWriteAction(true); + $this->assertNoCSRF(); + + if ($_SERVER["REQUEST_METHOD"] !== "POST") { + header("HTTP/1.1 405 Method Not Allowed"); + $this->redirect("/"); + } + + $audio = $this->audios->get($audio_id); + + if(!$audio || $audio->isDeleted()) + $this->flashFail("err", "error", tr("invalid_audio"), null, true); + + switch ($this->queryParam("act")) { + case "add": + if($audio->isWithdrawn()) + $this->flashFail("err", "error", tr("invalid_audio"), null, true); + + if(!$audio->isInLibraryOf($this->user->identity)) + $audio->add($this->user->identity); + else + $this->flashFail("err", "error", tr("do_have_audio"), null, true); + + break; + + case "remove": + if($audio->isInLibraryOf($this->user->identity)) + $audio->remove($this->user->identity); + else + $this->flashFail("err", "error", tr("do_not_have_audio"), null, true); + + break; + case "remove_club": + $club = (new Clubs)->get((int)$this->postParam("club")); + + if(!$club || !$club->canBeModifiedBy($this->user->identity)) + $this->flashFail("err", "error", tr("access_denied"), null, true); + + if($audio->isInLibraryOf($club)) + $audio->remove($club); + else + $this->flashFail("err", "error", tr("group_hasnt_audio"), null, true); + + break; + case "add_to_club": + $club = (new Clubs)->get((int)$this->postParam("club")); + + if(!$club || !$club->canBeModifiedBy($this->user->identity)) + $this->flashFail("err", "error", tr("access_denied"), null, true); + + if(!$audio->isInLibraryOf($club)) + $audio->add($club); + else + $this->flashFail("err", "error", tr("group_has_audio"), null, true); + + break; + case "delete": + if($audio->canBeModifiedBy($this->user->identity)) + $audio->delete(); + else + $this->flashFail("err", "error", tr("access_denied"), null, true); + + break; + case "edit": + $audio = $this->audios->get($audio_id); + if (!$audio || $audio->isDeleted() || $audio->isWithdrawn()) + $this->flashFail("err", "error", tr("invalid_audio"), null, true); + + if ($audio->getOwner()->getId() !== $this->user->id) + $this->flashFail("err", "error", tr("access_denied"), null, true); + + $performer = $this->postParam("performer"); + $name = $this->postParam("name"); + $lyrics = $this->postParam("lyrics"); + $genre = empty($this->postParam("genre")) ? "undefined" : $this->postParam("genre"); + $nsfw = (int)($this->postParam("explicit") ?? 0) === 1; + $unlisted = (int)($this->postParam("unlisted") ?? 0) === 1; + if(empty($performer) || empty($name) || iconv_strlen($performer . $name) > 128) # FQN of audio must not be more than 128 chars + $this->flashFail("err", tr("error"), tr("error_insufficient_info"), null, true); + + $audio->setName($name); + $audio->setPerformer($performer); + $audio->setLyrics(empty($lyrics) ? NULL : $lyrics); + $audio->setGenre($genre); + $audio->setExplicit($nsfw); + $audio->setSearchability($unlisted); + $audio->setEdited(time()); + $audio->save(); + + $this->returnJson(["success" => true, "new_info" => [ + "name" => ovk_proc_strtr($audio->getTitle(), 40), + "performer" => ovk_proc_strtr($audio->getPerformer(), 40), + "lyrics" => nl2br($audio->getLyrics() ?? ""), + "lyrics_unformatted" => $audio->getLyrics() ?? "", + "explicit" => $audio->isExplicit(), + "genre" => $audio->getGenre(), + "unlisted" => $audio->isUnlisted(), + ]]); + break; + + default: + break; + } + + $this->returnJson(["success" => true]); + } + + function renderPlaylists(int $owner) + { + $this->renderList($owner, "playlists"); + } + + function renderApiGetContext() + { + if ($_SERVER["REQUEST_METHOD"] !== "POST") { + header("HTTP/1.1 405 Method Not Allowed"); + $this->redirect("/"); + } + + $ctx_type = $this->postParam("context"); + $ctx_id = (int)($this->postParam("context_entity")); + $page = (int)($this->postParam("page") ?? 1); + $perPage = 10; + + switch($ctx_type) { + default: + case "entity_audios": + if($ctx_id >= 0) { + $entity = $ctx_id != 0 ? (new Users)->get($ctx_id) : $this->user->identity; + + if(!$entity || !$entity->getPrivacyPermission("audios.read", $this->user->identity)) + $this->flashFail("err", "Error", "Can't get queue", 80, true); + + $audios = $this->audios->getByUser($entity, $page, $perPage); + $audiosCount = $this->audios->getUserCollectionSize($entity); + } else { + $entity = (new Clubs)->get(abs($ctx_id)); + + if(!$entity || $entity->isBanned()) + $this->flashFail("err", "Error", "Can't get queue", 80, true); + + $audios = $this->audios->getByClub($entity, $page, $perPage); + $audiosCount = $this->audios->getClubCollectionSize($entity); + } + break; + case "new_audios": + $audios = $this->audios->getNew(); + $audiosCount = $audios->size(); + break; + case "popular_audios": + $audios = $this->audios->getPopular(); + $audiosCount = $audios->size(); + break; + case "playlist_context": + $playlist = $this->audios->getPlaylist($ctx_id); + + if (!$playlist || $playlist->isDeleted()) + $this->flashFail("err", "Error", "Can't get queue", 80, true); + + $audios = $playlist->fetch($page, 10); + $audiosCount = $playlist->size(); + break; + case "search_context": + $stream = $this->audios->search($this->postParam("query"), 2, $this->postParam("type") === "by_performer"); + $audios = $stream->page($page, 10); + $audiosCount = $stream->size(); + break; + } + + $pagesCount = ceil($audiosCount / $perPage); + + # костылёк для получения плееров в пикере аудиозаписей + if((int)($this->postParam("returnPlayers")) === 1) { + $this->template->audios = $audios; + $this->template->page = $page; + $this->template->pagesCount = $pagesCount; + $this->template->count = $audiosCount; + + return 0; + } + + $audiosArr = []; + + foreach($audios as $audio) { + $audiosArr[] = [ + "id" => $audio->getId(), + "name" => $audio->getTitle(), + "performer" => $audio->getPerformer(), + "keys" => $audio->getKeys(), + "url" => $audio->getUrl(), + "length" => $audio->getLength(), + "available" => $audio->isAvailable(), + "withdrawn" => $audio->isWithdrawn(), + ]; + } + + $resultArr = [ + "success" => true, + "page" => $page, + "perPage" => $perPage, + "pagesCount" => $pagesCount, + "count" => $audiosCount, + "items" => $audiosArr, + ]; + + $this->returnJson($resultArr); + } +} diff --git a/Web/Presenters/BlobPresenter.php b/Web/Presenters/BlobPresenter.php index 7bb3e2be..5987281d 100644 --- a/Web/Presenters/BlobPresenter.php +++ b/Web/Presenters/BlobPresenter.php @@ -18,6 +18,8 @@ final class BlobPresenter extends OpenVKPresenter function renderFile(/*string*/ $dir, string $name, string $format) { + header("Access-Control-Allow-Origin: *"); + $dir = $this->getDirName($dir); $base = realpath(OPENVK_ROOT . "/storage/$dir"); $path = realpath(OPENVK_ROOT . "/storage/$dir/$name.$format"); @@ -37,5 +39,5 @@ final class BlobPresenter extends OpenVKPresenter readfile($path); exit; - } + } } diff --git a/Web/Presenters/CommentPresenter.php b/Web/Presenters/CommentPresenter.php index e005af86..86ccb126 100644 --- a/Web/Presenters/CommentPresenter.php +++ b/Web/Presenters/CommentPresenter.php @@ -2,7 +2,7 @@ namespace openvk\Web\Presenters; use openvk\Web\Models\Entities\{Comment, Notifications\MentionNotification, Photo, Video, User, Topic, Post}; use openvk\Web\Models\Entities\Notifications\CommentNotification; -use openvk\Web\Models\Repositories\{Comments, Clubs, Videos, Photos}; +use openvk\Web\Models\Repositories\{Comments, Clubs, Videos, Photos, Audios}; final class CommentPresenter extends OpenVKPresenter { @@ -103,8 +103,27 @@ final class CommentPresenter extends OpenVKPresenter } } } + + $audios = []; + + if(!empty($this->postParam("audios"))) { + $un = rtrim($this->postParam("audios"), ","); + $arr = explode(",", $un); + + if(sizeof($arr) < 11) { + foreach($arr as $dat) { + $ids = explode("_", $dat); + $audio = (new Audios)->getByOwnerAndVID((int)$ids[0], (int)$ids[1]); + + if(!$audio || $audio->isDeleted()) + continue; + + $audios[] = $audio; + } + } + } - if(empty($this->postParam("text")) && sizeof($photos) < 1 && sizeof($videos) < 1) + if(empty($this->postParam("text")) && sizeof($photos) < 1 && sizeof($videos) < 1 && sizeof($audios) < 1) $this->flashFail("err", tr("error_when_publishing_comment"), tr("error_comment_empty")); try { @@ -126,6 +145,9 @@ final class CommentPresenter extends OpenVKPresenter if(sizeof($videos) > 0) foreach($videos as $vid) $comment->attach($vid); + + foreach($audios as $audio) + $comment->attach($audio); if($entity->getOwner()->getId() !== $this->user->identity->getId()) if(($owner = $entity->getOwner()) instanceof User) diff --git a/Web/Presenters/GroupPresenter.php b/Web/Presenters/GroupPresenter.php index d3a46fd5..eb8f446c 100644 --- a/Web/Presenters/GroupPresenter.php +++ b/Web/Presenters/GroupPresenter.php @@ -3,7 +3,7 @@ namespace openvk\Web\Presenters; use openvk\Web\Models\Entities\{Club, Photo, Post}; use Nette\InvalidStateException; use openvk\Web\Models\Entities\Notifications\ClubModeratorNotification; -use openvk\Web\Models\Repositories\{Clubs, Users, Albums, Managers, Topics}; +use openvk\Web\Models\Repositories\{Clubs, Users, Albums, Managers, Topics, Audios}; use Chandler\Security\Authenticator; final class GroupPresenter extends OpenVKPresenter @@ -31,6 +31,8 @@ final class GroupPresenter extends OpenVKPresenter $this->template->albumsCount = (new Albums)->getClubAlbumsCount($club); $this->template->topics = (new Topics)->getLastTopics($club, 3); $this->template->topicsCount = (new Topics)->getClubTopicsCount($club); + $this->template->audios = (new Audios)->getRandomThreeAudiosByEntityId($club->getRealId()); + $this->template->audiosCount = (new Audios)->getClubCollectionSize($club); } $this->template->club = $club; @@ -218,6 +220,7 @@ final class GroupPresenter extends OpenVKPresenter $club->setAdministrators_List_Display(empty($this->postParam("administrators_list_display")) ? 0 : $this->postParam("administrators_list_display")); $club->setEveryone_Can_Create_Topics(empty($this->postParam("everyone_can_create_topics")) ? 0 : 1); $club->setDisplay_Topics_Above_Wall(empty($this->postParam("display_topics_above_wall")) ? 0 : 1); + $club->setEveryone_can_upload_audios(empty($this->postParam("upload_audios")) ? 0 : 1); $club->setHide_From_Global_Feed(empty($this->postParam("hide_from_global_feed")) ? 0 : 1); $website = $this->postParam("website") ?? ""; diff --git a/Web/Presenters/ReportPresenter.php b/Web/Presenters/ReportPresenter.php index a87154c8..0c4cbd83 100644 --- a/Web/Presenters/ReportPresenter.php +++ b/Web/Presenters/ReportPresenter.php @@ -23,7 +23,7 @@ final class ReportPresenter extends OpenVKPresenter if ($_SERVER["REQUEST_METHOD"] === "POST") $this->assertNoCSRF(); - $act = in_array($this->queryParam("act"), ["post", "photo", "video", "group", "comment", "note", "app", "user"]) ? $this->queryParam("act") : NULL; + $act = in_array($this->queryParam("act"), ["post", "photo", "video", "group", "comment", "note", "app", "user", "audio"]) ? $this->queryParam("act") : NULL; if (!$this->queryParam("orig")) { $this->template->reports = $this->reports->getReports(0, (int)($this->queryParam("p") ?? 1), $act, $_SERVER["REQUEST_METHOD"] !== "POST"); @@ -88,7 +88,7 @@ final class ReportPresenter extends OpenVKPresenter if(!$id) exit(json_encode([ "error" => tr("error_segmentation") ])); - if(in_array($this->queryParam("type"), ["post", "photo", "video", "group", "comment", "note", "app", "user"])) { + if(in_array($this->queryParam("type"), ["post", "photo", "video", "group", "comment", "note", "app", "user", "audio"])) { if (count(iterator_to_array($this->reports->getDuplicates($this->queryParam("type"), $id, NULL, $this->user->id))) <= 0) { $report = new Report; $report->setUser_id($this->user->id); diff --git a/Web/Presenters/SearchPresenter.php b/Web/Presenters/SearchPresenter.php index fadf9954..d80c06c3 100644 --- a/Web/Presenters/SearchPresenter.php +++ b/Web/Presenters/SearchPresenter.php @@ -1,7 +1,7 @@ <?php declare(strict_types=1); namespace openvk\Web\Presenters; use openvk\Web\Models\Entities\{User, Club}; -use openvk\Web\Models\Repositories\{Users, Clubs, Posts, Comments, Videos, Applications, Notes}; +use openvk\Web\Models\Repositories\{Users, Clubs, Posts, Comments, Videos, Applications, Notes, Audios}; use Chandler\Database\DatabaseConnection; final class SearchPresenter extends OpenVKPresenter @@ -13,6 +13,7 @@ final class SearchPresenter extends OpenVKPresenter private $videos; private $apps; private $notes; + private $audios; function __construct(Users $users, Clubs $clubs) { @@ -23,22 +24,21 @@ final class SearchPresenter extends OpenVKPresenter $this->videos = new Videos; $this->apps = new Applications; $this->notes = new Notes; + $this->audios = new Audios; parent::__construct(); } function renderIndex(): void { + $this->assertUserLoggedIn(); + $query = $this->queryParam("query") ?? ""; $type = $this->queryParam("type") ?? "users"; $sorter = $this->queryParam("sort") ?? "id"; $invert = $this->queryParam("invert") == 1 ? "ASC" : "DESC"; $page = (int) ($this->queryParam("p") ?? 1); - $this->willExecuteWriteAction(); - if($query != "") - $this->assertUserLoggedIn(); - # https://youtu.be/pSAWM5YuXx8 $repos = [ @@ -47,7 +47,7 @@ final class SearchPresenter extends OpenVKPresenter "posts" => "posts", "comments" => "comments", "videos" => "videos", - "audios" => "posts", + "audios" => "audios", "apps" => "apps", "notes" => "notes" ]; @@ -62,7 +62,17 @@ final class SearchPresenter extends OpenVKPresenter break; case "rating": $sort = "rating " . $invert; - break; + break; + case "length": + if($type != "audios") break; + + $sort = "length " . $invert; + break; + case "listens": + if($type != "audios") break; + + $sort = "listens " . $invert; + break; } $parameters = [ @@ -86,18 +96,21 @@ final class SearchPresenter extends OpenVKPresenter "hometown" => $this->queryParam("hometown") != "" ? $this->queryParam("hometown") : NULL, "before" => $this->queryParam("datebefore") != "" ? strtotime($this->queryParam("datebefore")) : NULL, "after" => $this->queryParam("dateafter") != "" ? strtotime($this->queryParam("dateafter")) : NULL, - "gender" => $this->queryParam("gender") != "" && $this->queryParam("gender") != 2 ? $this->queryParam("gender") : NULL + "gender" => $this->queryParam("gender") != "" && $this->queryParam("gender") != 2 ? $this->queryParam("gender") : NULL, + "only_performers" => $this->queryParam("only_performers") == "on" ? "1" : NULL, + "with_lyrics" => $this->queryParam("with_lyrics") == "on" ? true : NULL, ]; $repo = $repos[$type] or $this->throwError(400, "Bad Request", "Invalid search entity $type."); $results = $this->{$repo}->find($query, $parameters, $sort); - $iterator = $results->page($page); + $iterator = $results->page($page, 14); $count = $results->size(); $this->template->iterator = iterator_to_array($iterator); $this->template->count = $count; $this->template->type = $type; $this->template->page = $page; + $this->template->perPage = 14; } } diff --git a/Web/Presenters/UserPresenter.php b/Web/Presenters/UserPresenter.php index e645f888..56d5685b 100644 --- a/Web/Presenters/UserPresenter.php +++ b/Web/Presenters/UserPresenter.php @@ -5,7 +5,7 @@ use openvk\Web\Util\Sms; use openvk\Web\Themes\Themepacks; use openvk\Web\Models\Entities\{Photo, Post, EmailChangeVerification}; use openvk\Web\Models\Entities\Notifications\{CoinsTransferNotification, RatingUpNotification}; -use openvk\Web\Models\Repositories\{Users, Clubs, Albums, Videos, Notes, Vouchers, EmailChangeVerifications}; +use openvk\Web\Models\Repositories\{Users, Clubs, Albums, Videos, Notes, Vouchers, EmailChangeVerifications, Audios}; use openvk\Web\Models\Exceptions\InvalidUserNameException; use openvk\Web\Util\Validator; use Chandler\Security\Authenticator; @@ -45,7 +45,10 @@ final class UserPresenter extends OpenVKPresenter $this->template->videosCount = (new Videos)->getUserVideosCount($user); $this->template->notes = (new Notes)->getUserNotes($user, 1, 4); $this->template->notesCount = (new Notes)->getUserNotesCount($user); - + $this->template->audios = (new Audios)->getRandomThreeAudiosByEntityId($user->getId()); + $this->template->audiosCount = (new Audios)->getUserCollectionSize($user); + $this->template->audioStatus = $user->getCurrentAudioStatus(); + $this->template->user = $user; } } @@ -169,6 +172,7 @@ final class UserPresenter extends OpenVKPresenter if ($this->postParam("gender") <= 1 && $this->postParam("gender") >= 0) $user->setSex($this->postParam("gender")); + $user->setAudio_broadcast_enabled($this->checkbox("broadcast_music")); if(!empty($this->postParam("phone")) && $this->postParam("phone") !== $user->getPhone()) { if(!OPENVK_ROOT_CONF["openvk"]["credentials"]["smsc"]["enable"]) @@ -241,6 +245,7 @@ final class UserPresenter extends OpenVKPresenter } $user->setStatus(empty($this->postParam("status")) ? NULL : $this->postParam("status")); + $user->setAudio_broadcast_enabled($this->postParam("broadcast") == 1); $user->save(); $this->returnJson([ @@ -430,6 +435,7 @@ final class UserPresenter extends OpenVKPresenter "friends.add", "wall.write", "messages.write", + "audios.read", ]; foreach($settings as $setting) { $input = $this->postParam(str_replace(".", "_", $setting)); @@ -474,6 +480,7 @@ final class UserPresenter extends OpenVKPresenter } else if($_GET['act'] === "lMenu") { $settings = [ "menu_bildoj" => "photos", + "menu_muziko" => "audios", "menu_filmetoj" => "videos", "menu_mesagoj" => "messages", "menu_notatoj" => "notes", diff --git a/Web/Presenters/WallPresenter.php b/Web/Presenters/WallPresenter.php index d89b722a..a01c8262 100644 --- a/Web/Presenters/WallPresenter.php +++ b/Web/Presenters/WallPresenter.php @@ -3,7 +3,7 @@ namespace openvk\Web\Presenters; use openvk\Web\Models\Exceptions\TooMuchOptionsException; use openvk\Web\Models\Entities\{Poll, Post, Photo, Video, Club, User}; use openvk\Web\Models\Entities\Notifications\{MentionNotification, RepostNotification, WallPostNotification}; -use openvk\Web\Models\Repositories\{Posts, Users, Clubs, Albums, Notes, Videos, Comments, Photos}; +use openvk\Web\Models\Repositories\{Posts, Users, Clubs, Albums, Notes, Videos, Comments, Photos, Audios}; use Chandler\Database\DatabaseConnection; use Nette\InvalidStateException as ISE; use Bhaktaraz\RSSGenerator\Item; @@ -311,8 +311,27 @@ final class WallPresenter extends OpenVKPresenter } } } + + $audios = []; + + if(!empty($this->postParam("audios"))) { + $un = rtrim($this->postParam("audios"), ","); + $arr = explode(",", $un); + + if(sizeof($arr) < 11) { + foreach($arr as $dat) { + $ids = explode("_", $dat); + $audio = (new Audios)->getByOwnerAndVID((int)$ids[0], (int)$ids[1]); + + if(!$audio || $audio->isDeleted()) + continue; + + $audios[] = $audio; + } + } + } - if(empty($this->postParam("text")) && sizeof($photos) < 1 && sizeof($videos) < 1 && !$poll && !$note) + if(empty($this->postParam("text")) && sizeof($photos) < 1 && sizeof($videos) < 1 && sizeof($audios) < 1 && !$poll && !$note) $this->flashFail("err", tr("failed_to_publish_post"), tr("post_is_empty_or_too_big")); try { @@ -341,6 +360,9 @@ final class WallPresenter extends OpenVKPresenter if(!is_null($note)) $post->attach($note); + + foreach($audios as $audio) + $post->attach($audio); if($wall > 0 && $wall !== $this->user->identity->getId()) (new WallPostNotification($wallOwner, $post, $this->user->identity))->emit(); diff --git a/Web/Presenters/templates/@layout.xml b/Web/Presenters/templates/@layout.xml index f8a975e0..31e0bc5c 100644 --- a/Web/Presenters/templates/@layout.xml +++ b/Web/Presenters/templates/@layout.xml @@ -16,6 +16,8 @@ {script "js/node_modules/umbrellajs/umbrella.min.js"} {script "js/l10n.js"} {script "js/openvk.cls.js"} + {script "js/node_modules/dashjs/dist/dash.all.min.js"} + {script "js/al_music.js"} {css "js/node_modules/tippy.js/dist/backdrop.css"} {css "js/node_modules/tippy.js/dist/border.css"} @@ -122,6 +124,7 @@ <option value="comments">{_s_by_comments}</option> <option value="videos">{_s_by_videos}</option> <option value="apps">{_s_by_apps}</option> + <option value="audios">{_s_by_audios}</option> </select> </form> <div class="searchTips" id="srcht" hidden> @@ -140,13 +143,13 @@ <option value="comments" {if str_contains($_SERVER['REQUEST_URI'], "type=comments")}selected{/if}>{_s_by_comments}</option> <option value="videos" {if str_contains($_SERVER['REQUEST_URI'], "type=videos")}selected{/if}>{_s_by_videos}</option> <option value="apps" {if str_contains($_SERVER['REQUEST_URI'], "type=apps")}selected{/if}>{_s_by_apps}</option> + <option value="audios" {if str_contains($_SERVER['REQUEST_URI'], "type=audios")}selected{/if}>{_s_by_audios}</option> </select> <button class="searchBtn"><span style="color:white;font-weight: 600;font-size:12px;">{_header_search}</span></button> </form> <script> let els = document.querySelectorAll("div.dec") - for(const element of els) - { + for(const element of els) { element.style.display = "none" } </script> @@ -182,6 +185,7 @@ </a> <a n:if="$thisUser->getLeftMenuItemStatus('photos')" href="/albums{$thisUser->getId()}" class="link">{_my_photos}</a> <a n:if="$thisUser->getLeftMenuItemStatus('videos')" href="/videos{$thisUser->getId()}" class="link">{_my_videos}</a> + <a n:if="$thisUser->getLeftMenuItemStatus('audios')" href="/audios{$thisUser->getId()}" class="link">{_my_audios}</a> <a n:if="$thisUser->getLeftMenuItemStatus('messages')" href="/im" class="link">{_my_messages} <object type="internal/link" n:if="$thisUser->getUnreadMessagesCount() > 0"> (<b>{$thisUser->getUnreadMessagesCount()}</b>) @@ -426,6 +430,12 @@ //]]> </script> + <script> + window.openvk = { + "audio_genres": {\openvk\Web\Models\Entities\Audio::genres} + } + </script> + {ifset bodyScripts} {include bodyScripts} {/ifset} diff --git a/Web/Presenters/templates/About/Tour.xml b/Web/Presenters/templates/About/Tour.xml index cc484c66..0c02b1ca 100755 --- a/Web/Presenters/templates/About/Tour.xml +++ b/Web/Presenters/templates/About/Tour.xml @@ -178,11 +178,29 @@ <h2>{_tour_section_6_title_1|noescape}</h2> <ul class="listing"> - <li><span>{_tour_section_6_text_1|noescape}</span></li> + <li><span>{_tour_section_6_text_1|noescape}</span></li> + <li><span>{_tour_section_6_text_2|noescape}</span></li> + <li><span>{_tour_section_6_text_3|noescape}</span></li> + <img src="assets/packages/static/openvk/img/tour/audios.png" width="440"> </ul> + + <ul class="listing"> + <li><span>{_tour_section_6_text_4|noescape}</span></li> + <img src="assets/packages/static/openvk/img/tour/audios_search.png" width="440"> + <li><span>{_tour_section_6_text_5|noescape}</span></li> + <img src="assets/packages/static/openvk/img/tour/audios_upload.png" width="440"> + </ul> - - + <p class="big">{_tour_section_6_bottom_text_1|noescape}</p> + + <h2>{_tour_section_6_title_2|noescape}</h2> + + <ul class="listing"> + <li><span>{_tour_section_6_text_6|noescape}</span></li> + <li><span>{_tour_section_6_text_7|noescape}</span></li> + <img src="assets/packages/static/openvk/img/tour/audios_playlists.png" width="440"> + </ul> + <br> </div> diff --git a/Web/Presenters/templates/Admin/@layout.xml b/Web/Presenters/templates/Admin/@layout.xml index f254dd9a..055bd0a0 100644 --- a/Web/Presenters/templates/Admin/@layout.xml +++ b/Web/Presenters/templates/Admin/@layout.xml @@ -97,6 +97,9 @@ <li> <a href="/admin/bannedLinks">{_admin_banned_links}</a> </li> + <li> + <a href="/admin/music">{_admin_music}</a> + </li> </ul> <div class="aui-nav-heading"> <strong>Chandler</strong> diff --git a/Web/Presenters/templates/Admin/EditMusic.xml b/Web/Presenters/templates/Admin/EditMusic.xml new file mode 100644 index 00000000..c2584760 --- /dev/null +++ b/Web/Presenters/templates/Admin/EditMusic.xml @@ -0,0 +1,81 @@ +{extends "@layout.xml"} + +{block title} + {_edit} {$audio->getName()} +{/block} + +{block heading} + {$audio->getName()} +{/block} + +{block content} + <div class="aui-tabs horizontal-tabs"> + <form class="aui" method="POST"> + <div class="field-group"> + <label for="id">ID</label> + <input class="text medium-field" type="number" id="id" disabled value="{$audio->getId()}" /> + </div> + <div class="field-group"> + <label>{_created}</label> + {$audio->getPublicationTime()} + </div> + <div class="field-group"> + <label>{_edited}</label> + {$audio->getEditTime() ?? "never"} + </div> + <div class="field-group"> + <label for="name">{_name}</label> + <input class="text medium-field" type="text" id="name" name="name" value="{$audio->getTitle()}" /> + </div> + <div class="field-group"> + <label for="performer">{_performer}</label> + <input class="text medium-field" type="text" id="performer" name="performer" value="{$audio->getPerformer()}" /> + </div> + <div class="field-group"> + <label for="ext">{_lyrics}</label> + <textarea class="text medium-field" type="text" id="text" name="text" style="resize: vertical;">{$audio->getLyrics()}</textarea> + </div> + <div class="field-group"> + <label>{_admin_audio_length}</label> + {$audio->getFormattedLength()} + </div> + <div class="field-group"> + <label for="ext">{_genre}</label> + <select class="select medium-field" name="genre"> + <option n:foreach='\openvk\Web\Models\Entities\Audio::genres as $genre' + n:attr="selected: $genre == $audio->getGenre()" value="{$genre}"> + {$genre} + </option> + </select> + </div> + <div class="field-group"> + <label>{_admin_original_file}</label> + <audio controls src="{$audio->getOriginalURL(true)}"> + </div> + <hr /> + <div class="field-group"> + <label for="owner">{_owner}</label> + <input class="text medium-field" type="number" id="owner_id" name="owner" value="{$owner}" /> + </div> + <div class="field-group"> + <label for="explicit">Explicit</label> + <input class="toggle-large" type="checkbox" id="explicit" name="explicit" value="1" {if $audio->isExplicit()} checked {/if} /> + </div> + <div class="field-group"> + <label for="deleted">{_deleted}</label> + <input class="toggle-large" type="checkbox" id="deleted" name="deleted" value="1" {if $audio->isDeleted()} checked {/if} /> + </div> + <div class="field-group"> + <label for="withdrawn">{_withdrawn}</label> + <input class="toggle-large" type="checkbox" id="withdrawn" name="withdrawn" value="1" {if $audio->isWithdrawn()} checked {/if} /> + </div> + <hr /> + <div class="buttons-container"> + <div class="buttons"> + <input type="hidden" name="hash" value="{$csrfToken}" /> + <input class="button submit" type="submit" value="{_save}"> + </div> + </div> + </form> + </div> +{/block} diff --git a/Web/Presenters/templates/Admin/EditPlaylist.xml b/Web/Presenters/templates/Admin/EditPlaylist.xml new file mode 100644 index 00000000..b0bd823f --- /dev/null +++ b/Web/Presenters/templates/Admin/EditPlaylist.xml @@ -0,0 +1,54 @@ +{extends "@layout.xml"} + +{block title} + {_edit} {$playlist->getName()} +{/block} + +{block heading} + {$playlist->getName()} +{/block} + +{block content} + <div class="aui-tabs horizontal-tabs"> + <form class="aui" method="POST"> + <div class="field-group"> + <label for="id">ID</label> + <input class="text medium-field" type="number" id="id" disabled value="{$playlist->getId()}" /> + </div> + <div class="field-group"> + <label for="name">{_name}</label> + <input class="text medium-field" type="text" id="name" name="name" value="{$playlist->getName()}" /> + </div> + <div class="field-group"> + <label for="ext">{_description}</label> + <textarea class="text medium-field" type="text" id="description" name="description" style="resize: vertical;">{$playlist->getDescription()}</textarea> + </div> + <div class="field-group"> + <label for="ext">{_admin_cover_id}</label> + <span id="avatar" class="aui-avatar aui-avatar-project aui-avatar-xlarge"> + <span class="aui-avatar-inner"> + <img src="{$playlist->getCoverUrl()}" style="object-fit: cover;"></img> + </span> + </span> + <br /> + <input class="text medium-field" type="number" id="photo" name="photo" value="{$playlist->getCoverPhotoId()}" /> + </div> + <hr /> + <div class="field-group"> + <label for="owner">{_owner}</label> + <input class="text medium-field" type="number" id="owner_id" name="owner" value="{$playlist->getOwner()->getId()}" /> + </div> + <div class="field-group"> + <label for="deleted">{_deleted}</label> + <input class="toggle-large" type="checkbox" id="deleted" name="deleted" value="1" {if $playlist->isDeleted()} checked {/if} /> + </div> + <hr /> + <div class="buttons-container"> + <div class="buttons"> + <input type="hidden" name="hash" value="{$csrfToken}" /> + <input class="button submit" type="submit" value="{_save}"> + </div> + </div> + </form> + </div> +{/block} diff --git a/Web/Presenters/templates/Admin/Music.xml b/Web/Presenters/templates/Admin/Music.xml new file mode 100644 index 00000000..448ee54d --- /dev/null +++ b/Web/Presenters/templates/Admin/Music.xml @@ -0,0 +1,135 @@ +{extends "@layout.xml"} +{var $search = $mode === "audios"} + +{block title} + {_audios} +{/block} + +{block heading} + {_audios} +{/block} + +{block searchTitle} + {include title} +{/block} + +{block content} + <nav class="aui-navgroup aui-navgroup-horizontal"> + <div class="aui-navgroup-inner"> + <div class="aui-navgroup-primary"> + <ul class="aui-nav" resolved=""> + <li n:attr="class => $mode === 'audios' ? 'aui-nav-selected' : ''"> + <a href="?act=audios">{_audios}</a> + </li> + <li n:attr="class => $mode === 'playlists' ? 'aui-nav-selected' : ''"> + <a href="?act=playlists">{_playlists}</a> + </li> + </ul> + </div> + </div> + </nav> + + <table class="aui aui-table-list"> + {if $mode === "audios"} + {var $audios = iterator_to_array($audios)} + {var $amount = sizeof($audios)} + <thead> + <tr> + <th>ID</th> + <th>{_admin_author}</th> + <th>{_peformer}</th> + <th>{_admin_title}</th> + <th>{_genre}</th> + <th>Explicit</th> + <th>{_withdrawn}</th> + <th>{_deleted}</th> + <th>{_created}</th> + <th>{_actions}</th> + </tr> + </thead> + <tbody> + <tr n:foreach="$audios as $audio"> + <td>{$audio->getId()}</td> + <td> + {var $owner = $audio->getOwner()} + <span class="aui-avatar aui-avatar-xsmall"> + <span class="aui-avatar-inner"> + <img src="{$owner->getAvatarUrl('miniscule')}" alt="{$owner->getCanonicalName()}" style="object-fit: cover;" role="presentation" /> + </span> + </span> + + <a href="{$owner->getURL()}">{$owner->getCanonicalName()}</a> + + <span n:if="$owner->isBanned()" class="aui-lozenge aui-lozenge-subtle aui-lozenge-removed">{_admin_banned}</span> + </td> + <td>{$audio->getPerformer()}</td> + <td>{$audio->getTitle()}</td> + <td>{$audio->getGenre()}</td> + <td>{$audio->isExplicit() ? tr("yes") : tr("no")}</td> + <td n:attr="style => $audio->isWithdrawn() ? 'color: red;' : ''"> + {$audio->isWithdrawn() ? tr("yes") : tr("no")} + </td> + <td n:attr="style => $audio->isDeleted() ? 'color: red;' : ''"> + {$audio->isDeleted() ? tr("yes") : tr("no")} + </td> + <td>{$audio->getPublicationTime()}</td> + <td> + <a class="aui-button aui-button-primary" href="/admin/music/{$audio->getId()}/edit"> + <span class="aui-icon aui-icon-small aui-iconfont-new-edit">Редактировать</span> + </a> + </td> + </tr> + </tbody> + {else} + {var $playlists = iterator_to_array($playlists)} + {var $amount = sizeof($playlists)} + <thead> + <tr> + <th>ID</th> + <th>{_admin_author}</th> + <th>{_name}</th> + <th>{_created_playlist}</th> + <th>{_actions}</th> + </tr> + </thead> + <tbody> + <tr n:foreach="$playlists as $playlist"> + <td>{$playlist->getId()}</td> + <td> + {var $owner = $playlist->getOwner()} + <span class="aui-avatar aui-avatar-xsmall"> + <span class="aui-avatar-inner"> + <img src="{$owner->getAvatarUrl('miniscule')}" alt="{$owner->getCanonicalName()}" style="object-fit: cover;" role="presentation" /> + </span> + </span> + + <a href="{$owner->getURL()}">{$owner->getCanonicalName()}</a> + + <span n:if="$owner->isBanned()" class="aui-lozenge aui-lozenge-subtle aui-lozenge-removed">{_admin_banned}</span> + </td> + <td> + <span class="aui-avatar aui-avatar-xsmall"> + <span class="aui-avatar-inner"> + <img src="{$playlist->getCoverURL()}" alt="{$owner->getCanonicalName()}" style="object-fit: cover;" role="presentation" /> + </span> + </span> + {ovk_proc_strtr($playlist->getName(), 30)} + </td> + <td>{$playlist->getCreationTime()}</td> + <td> + <a class="aui-button aui-button-primary" href="/admin/playlist/{$playlist->getId()}/edit"> + <span class="aui-icon aui-icon-small aui-iconfont-new-edit">{_edit}</span> + </a> + </td> + </tr> + </tbody> + {/if} + </table> + <br/> + <div align="right"> + {var $isLast = ((10 * (($_GET['p'] ?? 1) - 1)) + $amount) < $count} + + <a n:if="($_GET['p'] ?? 1) > 1" class="aui-button" href="/admin/music?act={($_GET['act'] ?? 'audios')}&p={($_GET['p'] ?? 1) - 1}">«</a> + <a n:if="$isLast" class="aui-button" href="/admin/music?act={($_GET['act'] ?? 'audios')}&p={($_GET['p'] ?? 1) + 1}">»</a> + </div> +{/block} diff --git a/Web/Presenters/templates/Audio/ApiGetContext.xml b/Web/Presenters/templates/Audio/ApiGetContext.xml new file mode 100644 index 00000000..77b99990 --- /dev/null +++ b/Web/Presenters/templates/Audio/ApiGetContext.xml @@ -0,0 +1,7 @@ +<input type="hidden" name="count" value="{$count}"> +<input type="hidden" name="pagesCount" value="{$pagesCount}"> +<input type="hidden" name="page" value="{$page}"> + +{foreach $audios as $audio} + {include "player.xml", audio => $audio, hideButtons => true} +{/foreach} diff --git a/Web/Presenters/templates/Audio/EditPlaylist.xml b/Web/Presenters/templates/Audio/EditPlaylist.xml new file mode 100644 index 00000000..50dfcc2a --- /dev/null +++ b/Web/Presenters/templates/Audio/EditPlaylist.xml @@ -0,0 +1,95 @@ +{extends "../@layout.xml"} + +{block title}{_edit_playlist}{/block} + +{block header} + <a href="{$owner->getURL()}">{$owner->getCanonicalName()}</a> + » + <a href="/audios{$ownerId}">{_audios}</a> + » + <a href="/playlist{$playlist->getPrettyId()}">{_playlist}</a> + » + {_edit_playlist} +{/block} + +{block content} + <div class="playlistBlock" style="display: flex;margin-top: 0px;"> + <div class="playlistCover"> + <a> + <img src="{$playlist->getCoverURL('normal')}" alt="{_playlist_cover}"> + </a> + + <div class="profile_links" style="width: 139px;"> + <a class="profile_link" style="width: 98%;" id="_deletePlaylist" data-id="{$playlist->getId()}">{_delete_playlist}</a> + </div> + </div> + + <div style="padding-left: 13px;width:75%"> + <div class="playlistInfo"> + <input value="{$playlist->getName()}" type="text" name="title" maxlength="125"> + </div> + + <div class="moreInfo"> + <textarea name="description" maxlength="2045" style="margin-top: 11px;">{$playlist->getDescription()}</textarea> + </div> + </div> + </div> + + <div style="margin-top: 19px;"> + <input id="playlist_query" type="text" style="height: 26px;" placeholder="{_header_search}"> + <div class="playlistAudiosContainer editContainer"> + <div id="newPlaylistAudios" n:foreach="$audios as $audio"> + <div class="playerContainer"> + {include "player.xml", audio => $audio, hideButtons => true} + </div> + <div class="attachAudio addToPlaylist" data-id="{$audio->getId()}"> + <span>{_remove_from_playlist}</span> + </div> + </div> + </div> + + <div class="showMoreAudiosPlaylist" data-page="2" data-playlist="{$playlist->getId()}" n:if="$pagesCount > 1"> + {_show_more_audios} + </div> + </div> + + <form method="post" id="editPlaylistForm" data-id="{$playlist->getId()}" enctype="multipart/form-data"> + <input type="hidden" name="title" maxlength="128" /> + <input type="hidden" name="hash" value="{$csrfToken}" /> + <textarea style="display:none;" name="description" maxlength="2048" /> + <input type="hidden" name="audios"> + <input type="file" style="display:none;" name="new_cover" accept=".jpg,.png"> + + <div style="float:right;margin-top: 8px;"> + <button class="button" type="submit">{_save}</button> + </div> + </form> + + <script> + document.querySelector("input[name='audios']").value = {$audiosIds} + + u("#editPlaylistForm").on("submit", (e) => { + document.querySelector("#editPlaylistForm input[name='title']").value = document.querySelector(".playlistInfo input[name='title']").value + document.querySelector("#editPlaylistForm textarea[name='description']").value = document.querySelector(".playlistBlock textarea[name='description']").value + }) + + u("#editPlaylistForm input[name='new_cover']").on("change", (e) => { + if(!e.currentTarget.files[0].type.startsWith("image/")) { + fastError(tr("not_a_photo")) + return + } + + let image = URL.createObjectURL(e.currentTarget.files[0]) + + document.querySelector(".playlistCover img").src = image + }) + + u(".playlistCover img").on("click", (e) => { + document.querySelector("input[name='new_cover']").click() + }) + + document.querySelector("#editPlaylistForm input[name='new_cover']").value = "" + </script> + + {script "js/al_playlists.js"} +{/block} diff --git a/Web/Presenters/templates/Audio/Embed.xml b/Web/Presenters/templates/Audio/Embed.xml new file mode 100644 index 00000000..b7540358 --- /dev/null +++ b/Web/Presenters/templates/Audio/Embed.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"> + <head> + <meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8" /> + <link rel="icon"> + <title>{$audio->getName()}</title> + + {css "css/main.css"} + {css "css/audios.css"} + {script "js/node_modules/dashjs/dist/dash.all.min.js"} + {script "js/node_modules/jquery/dist/jquery.min.js"} + {script "js/node_modules/umbrellajs/umbrella.min.js"} + </head> + <body id="audioEmbed"> + {include "player.xml", audio => $audio} + + {script "js/al_music.js"} + </body> +</html> diff --git a/Web/Presenters/templates/Audio/List.xml b/Web/Presenters/templates/Audio/List.xml new file mode 100644 index 00000000..ec24f3b3 --- /dev/null +++ b/Web/Presenters/templates/Audio/List.xml @@ -0,0 +1,126 @@ +{extends "../@layout.xml"} + +{block title} + {if $mode == 'list'} + {if $ownerId > 0} + {_audios} {$owner->getMorphedName("genitive", false)} + {else} + {_audios_group} + {/if} + {elseif $mode == 'new'} + {_audio_new} + {elseif $mode == 'popular'} + {_audio_popular} + {else} + {if $ownerId > 0} + {_playlists} {$owner->getMorphedName("genitive", false)} + {else} + {_playlists_group} + {/if} + {/if} +{/block} + +{block header} + <div n:if="$mode == 'list'"> + <div n:if="$isMy">{_my_audios_small}</div> + <div n:if="!$isMy"> + <a href="{$owner->getURL()}">{$owner->getCanonicalName()}</a> + » + {_audios} + </div> + </div> + + <div n:if="$mode == 'new'"> + {_audios} + » + {_audio_new} + </div> + + <div n:if="$mode == 'popular'"> + {_audios} + » + {_audio_popular} + </div> + + <div n:if="$mode == 'playlists'"> + {_audios} + » + {if $isMy}{_my_playlists}{else}{_playlists}{/if} + </div> +{/block} + +{block content} + {* ref: https://archive.li/P32em *} + + {include "bigplayer.xml"} + + <input n:if="$mode == 'list'" type="hidden" name="bigplayer_context" data-type="entity_audios" data-entity="{$ownerId}" data-page="{$page}"> + <input n:if="$mode == 'new'" type="hidden" name="bigplayer_context" data-type="new_audios" data-entity="0" data-page="1"> + <input n:if="$mode == 'popular'" type="hidden" name="bigplayer_context" data-type="popular_audios" data-entity="0" data-page="1"> + <div class="bigPlayerDetector"></div> + + <div style="width: 100%;display: flex;margin-bottom: -10px;" class="audiosDiv"> + <div style="width: 74%;" class="audiosContainer" n:if="$mode != 'playlists'"> + <div style="padding: 8px;"> + <div n:if="$audiosCount <= 0"> + {include "../components/error.xml", description => $ownerId > 0 ? ($ownerId == $thisUser->getId() ? tr("no_audios_thisuser") : tr("no_audios_user")) : tr("no_audios_club")} + </div> + <div n:if="$audiosCount > 0" class="infContainer"> + <div class="infObj" n:foreach="$audios as $audio"> + {include "player.xml", audio => $audio, club => $club} + </div> + </div> + + <div n:if="$mode != 'new' && $mode != 'popular'"> + {include "../components/paginator.xml", conf => (object) [ + "page" => $page, + "count" => $audiosCount, + "amount" => sizeof($audios), + "perPage" => $perPage ?? OPENVK_DEFAULT_PER_PAGE, + "atBottom" => true, + ]} + </div> + </div> + </div> + + <div style="width: 74%;" n:if="$mode == 'playlists'"> + <div style="padding: 8px;"> + <div n:if="$playlistsCount <= 0"> + {include "../components/error.xml", description => $ownerId > 0 ? ($ownerId == $thisUser->getId() ? tr("no_playlists_thisuser") : tr("no_playlists_user")) : tr("no_playlists_club")} + </div> + + <div class="infContainer playlistContainer" n:if="$playlistsCount > 0"> + <div class="infObj" n:foreach="$playlists as $playlist"> + <a href="/playlist{$playlist->getPrettyId()}"> + <div class="playlistCover"> + <img src="{$playlist->getCoverURL()}" alt="{_playlist_cover}"> + </div> + </a> + + + <div class="playlistInfo"> + <a href="/playlist{$playlist->getPrettyId()}"> + <span style="font-size: 12px" class="playlistName"> + {ovk_proc_strtr($playlist->getName(), 15)} + </span> + </a> + + <a href="{$playlist->getOwner()->getURL()}">{ovk_proc_strtr($playlist->getOwner()->getCanonicalName(), 20)}</a> + </div> + </div> + </div> + + <div> + {include "../components/paginator.xml", conf => (object) [ + "page" => $page, + "count" => $playlistsCount, + "amount" => sizeof($playlists), + "perPage" => $perPage ?? OPENVK_DEFAULT_PER_PAGE, + "atBottom" => true, + ]} + </div> + </div> + </div> + {include "tabs.xml"} + </div> +{/block} diff --git a/Web/Presenters/templates/Audio/NewPlaylist.xml b/Web/Presenters/templates/Audio/NewPlaylist.xml new file mode 100644 index 00000000..42efae7f --- /dev/null +++ b/Web/Presenters/templates/Audio/NewPlaylist.xml @@ -0,0 +1,109 @@ +{extends "../@layout.xml"} + +{block title} + {_new_playlist} +{/block} + +{block header} + {if !$_GET["gid"]} + <a href="{$thisUser->getURL()}">{$thisUser->getCanonicalName()}</a> + » + <a href="/audios{$thisUser->getId()}">{_audios}</a> + {else} + <a href="{$club->getURL()}">{$club->getCanonicalName()}</a> + » + <a href="/audios-{$club->getId()}">{_audios}</a> + {/if} + » + {_new_playlist} +{/block} + +{block content} + <style> + textarea[name='description'] { + padding: 4px; + } + + .playlistInfo { + width: 76%; + margin-left: 8px; + } + </style> + + <div style="display:flex;"> + <div class="playlistCover" onclick="document.querySelector(`#newPlaylistForm input[name='cover']`).click()"> + <a> + <img src="/assets/packages/static/openvk/img/song.jpg" alt="{_playlist_cover}"> + </a> + </div> + + <div style="padding-left: 17px;width: 75%;" class="plinfo"> + <div> + <input type="text" name="title" placeholder="{_title}" maxlength="125" /> + </div> + <div class="moreInfo" style="margin-top: 11px;"> + <textarea placeholder="{_description}" name="description" maxlength="2045" /> + </div> + </div> + </div> + + <div style="margin-top: 19px;"> + <input id="playlist_query" type="text" style="height: 26px;" placeholder="{_header_search}"> + <div class="playlistAudiosContainer editContainer"> + <div id="newPlaylistAudios" n:foreach="$audios as $audio"> + <div style="width: 78%;float: left;"> + {include "player.xml", audio => $audio, hideButtons => true} + </div> + <div class="attachAudio addToPlaylist" data-id="{$audio->getId()}"> + <span>{_add_to_playlist}</span> + </div> + </div> + </div> + + <div class="showMoreAudiosPlaylist" data-page="2" {if !is_null($_GET["gid"])}data-club="{abs($_GET['gid'])}"{/if} n:if="$pagesCount > 1"> + {_show_more_audios} + </div> + </div> + + <form method="post" id="newPlaylistForm" enctype="multipart/form-data"> + <input type="hidden" name="title" maxlength="125" /> + <input type="hidden" name="hash" value="{$csrfToken}" /> + <textarea style="display:none;" name="description" maxlength="2045" /> + <input type="hidden" name="audios"> + <input type="file" style="display:none;" name="cover" accept=".jpg,.png"> + + <div style="float: right;margin-top: 9px;"> + <button class="button" type="submit">{_create}</button> + </div> + </form> + + <script> + document.querySelector("input[name='audios']").value = "" + + u("#newPlaylistForm").on("submit", (e) => { + document.querySelector("#newPlaylistForm input[name='title']").value = document.querySelector(".plinfo input[name='title']").value + document.querySelector("#newPlaylistForm textarea[name='description']").value = document.querySelector(".plinfo textarea[name='description']").value + }) + + u("#newPlaylistForm input[name='cover']").on("change", (e) => { + if(!e.currentTarget.files[0].type.startsWith("image/")) { + fastError(tr("not_a_photo")) + return + } + + let image = URL.createObjectURL(e.currentTarget.files[0]) + + document.querySelector(".playlistCover img").src = image + document.querySelector(".playlistCover img").style.display = "block" + }) + + u(".playlistCover img").on("click", (e) => { + document.querySelector("#newPlaylistForm input[name='cover']").value = "" + e.currentTarget.href = "" + }) + + document.querySelector("#newPlaylistForm input[name='cover']").value = "" + </script> + + {script "js/al_playlists.js"} +{/block} diff --git a/Web/Presenters/templates/Audio/Playlist.xml b/Web/Presenters/templates/Audio/Playlist.xml new file mode 100644 index 00000000..f14b6ac1 --- /dev/null +++ b/Web/Presenters/templates/Audio/Playlist.xml @@ -0,0 +1,81 @@ +{extends "../@layout.xml"} + +{block title}{_playlist}{/block} + +{block headIncludes} + <meta property="og:type" content="music.album"> + <meta property="og:title" content="{$playlist->getName()}"> + <meta property="og:url" content="{$playlist->getURL()}"> + <meta property="og:description" content="{$playlist->getDescription()}"> + <meta property="og:image" content="{$playlist->getCoverURL()}"> + + <script type="application/ld+json"> + { + "@context": "http://schema.org/", + "type": "MusicAlbum", + "name": {$playlist->getName()}, + "url": {$playlist->getURL()} + } + </script> +{/block} + +{block header} + <a href="{$owner->getURL()}">{$owner->getCanonicalName()}</a> + » + <a href="/audios{$ownerId}">{_audios}</a> + » + {_playlist} +{/block} + +{block content} + {include "bigplayer.xml"} + + {php $count = $playlist->size()} + + <input type="hidden" name="bigplayer_context" data-type="playlist_context" data-entity="{$playlist->getId()}" data-page="{$page}"> + <div class="playlistBlock"> + <div class="playlistCover" style="float: left;"> + <a href="{$playlist->getCoverURL()}" target="_blank"> + <img src="{$playlist->getCoverURL('normal')}" alt="{_playlist_cover}"> + </a> + + <div class="profile_links" style="width: 139px;"> + <a class="profile_link" style="width: 98%;" href="/playlist{$playlist->getPrettyId()}/edit" n:if="$playlist->canBeModifiedBy($thisUser)">{_edit_playlist}</a> + <a class="profile_link" style="width: 98%;" id="bookmarkPlaylist" data-id="{$playlist->getId()}" n:if="!$isBookmarked">{_bookmark}</a> + <a class="profile_link" style="width: 98%;" id="unbookmarkPlaylist" data-id="{$playlist->getId()}" n:if="$isBookmarked">{_unbookmark}</a> + </div> + </div> + + <div style="float: left;padding-left: 13px;width:75%"> + <div class="playlistInfo"> + <h4 style="border-bottom:unset;">{$playlist->getName()}</h4> + + <div class="moreInfo"> + {$playlist->getMetaDescription()|noescape} + + <div style="margin-top: 11px;"> + <span>{nl2br($playlist->getDescriptionHTML())|noescape}</span> + </div> + <hr style="color: #f7f7f7;"> + </div> + </div> + <div class="audiosContainer infContainer" style="margin-top: 14px;"> + {if $count < 1} + {_empty_playlist} + {else} + <div class="infObj" n:foreach="$audios as $audio"> + {include "player.xml", audio => $audio} + </div> + + {include "../components/paginator.xml", conf => (object) [ + "page" => $page, + "count" => $count, + "amount" => sizeof($audios), + "perPage" => $perPage ?? OPENVK_DEFAULT_PER_PAGE, + "atBottom" => true, + ]} + {/if} + </div> + </div> + </div> +{/block} diff --git a/Web/Presenters/templates/Audio/Upload.xml b/Web/Presenters/templates/Audio/Upload.xml new file mode 100644 index 00000000..ecc4aa30 --- /dev/null +++ b/Web/Presenters/templates/Audio/Upload.xml @@ -0,0 +1,212 @@ +{extends "../@layout.xml"} + +{block title} + {_upload_audio} +{/block} + +{block header} + {if !is_null($group)} + <a href="{$group->getURL()}">{$group->getCanonicalName()}</a> + » + <a href="/audios-{$group->getId()}">{_audios}</a> + {else} + <a href="{$thisUser->getURL()}">{$thisUser->getCanonicalName()}</a> + » + <a href="/audios{$thisUser->getId()}">{_audios}</a> + {/if} + + » + {_upload_audio} +{/block} + +{block content} + <div class="container_gray" style="border: 0;margin-top: -10px;"> + <div id="upload_container"> + <div id="firstStep"> + <h4>{_select_audio}</h4><br/> + <b><a href="javascript:void(0)">{_limits}</a></b> + <ul> + <li>{tr("audio_requirements", 1, 30, 25)}</li> + <li>{tr("audio_requirements_2")}</li> + </ul> + <div id="audio_upload"> + <form enctype="multipart/form-data" method="POST"> + <input type="hidden" name="name" /> + <input type="hidden" name="performer" /> + <input type="hidden" name="lyrics" /> + <input type="hidden" name="genre" /> + <input type="hidden" name="explicit" /> + + <input type="hidden" name="hash" value="{$csrfToken}" /> + <input id="audio_input" type="file" name="blob" accept="audio/*" style="display:none" /> + <input value="{_upload_button}" class="button" type="button" onclick="document.querySelector('#audio_input').click()"> + </form> + </div><br/> + + <span>{_you_can_also_add_audio_using} <b><a href="/search?type=audios">{_search_audio_inst}</a></b>.<span> + </div> + + <div id="lastStep" style="display:none;"> + <table cellspacing="7" cellpadding="0" border="0" align="center"> + <tbody> + <tr> + <td width="120" valign="top"><span class="nobold">{_audio_name}:</span></td> + <td><input type="text" name="name" autocomplete="off" maxlength="80" /></td> + </tr> + <tr> + <td width="120" valign="top"><span class="nobold">{_performer}:</span></td> + <td><input name="performer" type="text" autocomplete="off" maxlength="80" /></td> + </tr> + <tr> + <td width="120" valign="top"><span class="nobold">{_genre}:</span></td> + <td> + <select name="genre"> + <option n:foreach='\openvk\Web\Models\Entities\Audio::genres as $genre' n:attr="selected: $genre == 'Other'" value="{$genre}"> + {$genre} + </option> + </select> + </td> + </tr> + <tr> + <td width="120" valign="top"><span class="nobold">{_lyrics}:</span></td> + <td><textarea name="lyrics" style="resize: vertical;max-height: 300px;"></textarea></td> + </tr> + <tr> + <td width="120" valign="top"></td> + <td> + <label><input type="checkbox" name="explicit">{_audios_explicit}</label> + </td> + </tr> + <tr> + <td width="120" valign="top"></td> + <td> + <input class="button" type="button" id="uploadMuziko" value="{_upload_button}"> + <input class="button" type="button" id="backToUpload" value="{_select_another_file}"> + </td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + + <script type="module"> + import * as id3 from "/assets/packages/static/openvk/js/node_modules/id3js/lib/id3.js"; + + u("#audio_input").on("change", async function(e) { + let files = e.currentTarget.files + if(files.length <= 0) + return; + + document.querySelector("#firstStep").style.display = "none" + document.querySelector("#lastStep").style.display = "block" + + let tags = await id3.fromFile(files[0]); + if(tags != null) { + console.log("ID" + tags.kind + " detected, setting values..."); + + if(tags.title != null) + document.querySelector("#lastStep input[name=name]").value = tags.title; + else + document.querySelector("#lastStep input[name=name]").value = files[0].name + + if(tags.artist != null) + document.querySelector("#lastStep input[name=performer]").value = tags.artist; + else + document.querySelector("#lastStep input[name=performer]").value = tr("track_unknown"); + + if(tags.genre != null) { + if(document.querySelector("#lastStep select[name=genre] > option[value='" + tags.genre + "']") != null) { + document.querySelector("#lastStep select[name=genre]").value = tags.genre; + } else { + console.warn("Unknown genre: " + tags.genre); + document.querySelector("#lastStep select[name=genre]").value = "Other" + } + } + } else { + document.querySelector("#lastStep input[name=name]").value = files[0].name + document.querySelector("#lastStep select[name=genre]").value = "Other" + document.querySelector("#lastStep input[name=performer]").value = tr("track_unknown"); + } + }); + + u("#backToUpload").on("click", (e) => { + document.querySelector("#firstStep").style.display = "block" + document.querySelector("#lastStep").style.display = "none" + + document.querySelector("#lastStep input[name=name]").value = "" + document.querySelector("#lastStep input[name=performer]").value = "" + document.querySelector("#lastStep select[name=genre]").value = "" + document.querySelector("#lastStep textarea[name=lyrics]").value = "" + document.querySelector("#audio_input").value = "" + }) + + u("#uploadMuziko").on("click", (e) => { + var name_ = document.querySelector("#audio_upload input[name=name]"); + var perf_ = document.querySelector("#audio_upload input[name=performer]"); + var genre_ = document.querySelector("#audio_upload input[name=genre]"); + var lyrics_ = document.querySelector("#audio_upload input[name=lyrics]"); + var explicit_ = document.querySelector("#audio_upload input[name=explicit]"); + + name_.value = document.querySelector("#lastStep input[name=name]").value + perf_.value = document.querySelector("#lastStep input[name=performer]").value + genre_.value = document.querySelector("#lastStep select[name=genre]").value + lyrics_.value = document.querySelector("#lastStep textarea[name=lyrics]").value + explicit_.value = document.querySelector("#lastStep input[name=explicit]").checked ? "on" : "off" + + $("#audio_upload > form").trigger("submit"); + }) + + $(document).on("dragover drop", (e) => { + e.preventDefault() + + return false; + }) + + $(".container_gray").on("drop", (e) => { + e.originalEvent.dataTransfer.dropEffect = 'move'; + e.preventDefault() + + let file = e.originalEvent.dataTransfer.files[0] + + if(!file.type.startsWith('audio/')) { + MessageBox(tr("error"), tr("only_audios_accepted", escapeHtml(file.name)), [tr("ok")], [() => Function.noop]) + return; + } + + document.getElementById("audio_input").files = e.originalEvent.dataTransfer.files + u("#audio_input").trigger("change") + }) + + $("#audio_upload").on("submit", "form", (e) => { + e.preventDefault() + + let fd = new FormData(e.currentTarget) + fd.append("ajax", 1) + + $.ajax({ + type: "POST", + url: location.href, + contentType: false, + processData: false, + data: fd, + beforeSend: function() { + document.querySelector("#lastStep").classList.add("lagged") + document.querySelector("#upload_container").classList.add("uploading") + }, + success: (response) => { + document.querySelector("#lastStep").classList.remove("lagged") + document.querySelector("#upload_container").classList.remove("uploading") + if(response.success) { + u("#backToUpload").trigger("click") + NewNotification(tr("success"), tr("audio_successfully_uploaded"), null, () => { + window.location.assign(response.redirect_link) + }) + } else { + fastError(response.flash.message) + } + } + }) + }) + </script> +{/block} diff --git a/Web/Presenters/templates/Audio/bigplayer.xml b/Web/Presenters/templates/Audio/bigplayer.xml new file mode 100644 index 00000000..7e8ffbe9 --- /dev/null +++ b/Web/Presenters/templates/Audio/bigplayer.xml @@ -0,0 +1,56 @@ +<div class="bigPlayer"> + <audio class="audio" /> + <div class="paddingLayer"> + <div class="playButtons"> + <div class="playButton musicIcon" title="{_play_tip} [Space]"></div> + + <div class="arrowsButtons"> + <div> + <div class="nextButton musicIcon"></div> + </div> + + <div> + <div class="backButton musicIcon"></div> + </div> + </div> + </div> + + <div class="trackPanel"> + <div class="trackInfo"> + <div class="trackName"> + <b>{_track_unknown}</b> — + <span>{_track_noname}</span> + </div> + + <div class="timer" style="float:right"> + <span class="time">00:00</span> + <span>/</span> + <span class="elapsedTime" style="cursor:pointer">-00:00</span> + </div> + </div> + + <div class="track" style="margin-top: -2px;"> + <div class="bigPlayerTip">00:00</div> + <div class="selectableTrack"> + <div style="width: 95%;position: relative;"> + <div class="slider"></div> + </div> + </div> + </div> + </div> + + <div class="volumePanel"> + <div class="selectableTrack"> + <div style="position: relative;width:72%"> + <div class="slider"></div> + </div> + </div> + </div> + + <div class="additionalButtons"> + <div class="repeatButton musicIcon" title="{_repeat_tip} [R]" ></div> + <div class="shuffleButton musicIcon" title="{_shuffle_tip}"></div> + <div class="deviceButton musicIcon" title="{_mute_tip} [M]"></div> + </div> + </div> +</div> diff --git a/Web/Presenters/templates/Audio/player.xml b/Web/Presenters/templates/Audio/player.xml new file mode 100644 index 00000000..fb5862b4 --- /dev/null +++ b/Web/Presenters/templates/Audio/player.xml @@ -0,0 +1,69 @@ +{php $id = $audio->getId() . rand(0, 1000)} +{php $isWithdrawn = $audio->isWithdrawn()} +{php $editable = isset($thisUser) && $audio->canBeModifiedBy($thisUser)} +<div id="audioEmbed-{$id}" data-realid="{$audio->getId()}" {if $hideButtons}data-prettyid="{$audio->getPrettyId()}" data-name="{$audio->getName()}"{/if} data-genre="{$audio->getGenre()}" class="audioEmbed {if !$audio->isAvailable()}processed{/if} {if $isWithdrawn}withdrawn{/if}" data-length="{$audio->getLength()}" data-keys="{json_encode($audio->getKeys())}" data-url="{$audio->getURL()}"> + <audio class="audio" /> + + <div id="miniplayer" class="audioEntry" style="min-height: 39px;"> + <div style="display: flex;"> + <div class="playerButton"> + <div class="playIcon"></div> + </div> + + <div class="status" style="margin-top: 12px;"> + <div class="mediaInfo" style="margin-bottom: -8px; cursor: pointer;display:flex;width: 85%;"> + <div class="info noOverflow"> + <strong class="performer"> + <a href="/search?query=&type=audios&sort=id&only_performers=on&query={$audio->getPerformer()}">{ovk_proc_strtr($audio->getPerformer(), 30)}</a> + </strong> + — + <span class="title {if !empty($audio->getLyrics())}withLyrics{/if}">{ovk_proc_strtr($audio->getTitle(), 30)}</span> + </div> + + <div class="explicitMark" n:if="$audio->isExplicit()"></div> + </div> + </div> + + <div class="volume" style="display: flex; flex-direction: column;width:14%;"> + <span class="nobold {if !$hideButtons}hideOnHover{/if}" data-unformatted="{$audio->getLength()}" style="text-align: center;margin-top: 12px;">{$audio->getFormattedLength()}</span> + <div class="buttons" style="margin-top: 8px;"> + {php $hasAudio = isset($thisUser) && $audio->isInLibraryOf($thisUser)} + + {if !$hideButtons} + <div class="remove-icon musicIcon" data-id="{$audio->getId()}" n:if="isset($thisUser) && $hasAudio" ></div> + <div class="add-icon musicIcon hovermeicon" data-id="{$audio->getId()}" n:if="isset($thisUser) && !$hasAudio && !$isWithdrawn" ></div> + <div class="remove-icon-group musicIcon" data-id="{$audio->getId()}" data-club="{$club->getId()}" n:if="isset($thisUser) && isset($club) && $club->canBeModifiedBy($thisUser)" ></div> + <div class="add-icon-group musicIcon" data-id="{$audio->getId()}" n:if="isset($thisUser) && !$isWithdrawn" ></div> + <div class="edit-icon musicIcon" data-lyrics="{$audio->getLyrics()}" data-title="{$audio->getTitle()}" data-performer="{$audio->getPerformer()}" data-explicit="{(int)$audio->isExplicit()}" data-searchable="{(int)!$audio->isUnlisted()}" n:if="isset($thisUser) && $editable && !$isWithdrawn" ></div> + <div class="report-icon musicIcon" data-id="{$audio->getId()}" n:if="isset($thisUser) && !$editable && !$isWithdrawn" ></div> + {/if} + </div> + </div> + </div> + <div class="subTracks"> + <div style="width: 100%;"> + <div class="track lengthTrack" style="margin-top: 3px;display:none"> + <div class="selectableTrack" style="width: 100%;" n:attr="style => $isWithdrawn ? 'display: none;' : ''"> + <div style="position: relative;width: calc(100% - 18px);"> + <div class="slider"></div> + </div> + </div> + </div> + </div> + + <div style="width: 81px;margin-left: 16px;"> + <div class="track volumeTrack" style="margin-top: 3px;display:none"> + <div class="selectableTrack" style="width: 100%;" n:attr="style => $isWithdrawn ? 'display: none;' : ''"> + <div style="position: relative;width: calc(100% - 18px);"> + <div class="slider"></div> + </div> + </div> + </div> + </div> + </div> + </div> + + <div class="lyrics" n:if="!empty($audio->getLyrics())"> + {nl2br($audio->getLyrics())|noescape} + </div> +</div> diff --git a/Web/Presenters/templates/Audio/tabs.xml b/Web/Presenters/templates/Audio/tabs.xml new file mode 100644 index 00000000..ed74ca86 --- /dev/null +++ b/Web/Presenters/templates/Audio/tabs.xml @@ -0,0 +1,40 @@ +<div class="searchOptions newer"> + <div class="searchList" style="margin-top:10px"> + <a n:attr="id => $mode === 'list' && $isMy ? 'used' : 'ki'" href="/audios{$thisUser->getId()}" n:if="isset($thisUser)">{_my_music}</a> + <a href="/player/upload{if $isMyClub}?gid={abs($ownerId)}{/if}" n:if="isset($thisUser)">{_upload_audio}</a> + <a n:attr="id => $mode === 'new' ? 'used' : 'ki'" href="/audios/new">{_audio_new}</a> + <a n:attr="id => $mode === 'popular' ? 'used' : 'ki'" href="/audios/popular">{_audio_popular}</a> + <a href="/search?type=audios" n:if="isset($thisUser)">{_audio_search}</a> + + <hr n:if="isset($thisUser)"> + + <a n:attr="id => $mode === 'playlists' && $ownerId == $thisUser->getId() ? 'used' : 'ki'" href="/playlists{$thisUser->getId()}" n:if="isset($thisUser)">{_my_playlists}</a> + + <a n:if="isset($thisUser)" href="/audios/newPlaylist">{_new_playlist}</a> + + {if !$isMy && $mode !== 'popular' && $mode !== 'new'} + <hr> + + <a n:if="!$isMy" n:attr="id => $mode === 'list' ? 'used' : 'ki'" href="/audios{$ownerId}">{if $ownerId > 0}{_music_user}{else}{_music_club}{/if}</a> + <a href="/player/upload?gid={abs($ownerId)}" n:if="isset($thisUser) && isset($club) && $club->canUploadAudio($thisUser)">{_upload_audio}</a> + <a n:attr="id => $mode === 'playlists' && $ownerId != $thisUser->getId() ? 'used' : 'ki'" href="/playlists{$ownerId}" n:if="isset($thisUser) && isset($ownerId) && !$isMy">{if $ownerId > 0}{_playlists_user}{else}{_playlists_club}{/if}</a> + <a href="/audios/newPlaylist{if $isMyClub}?gid={abs($ownerId)}{/if}" n:if="isset($thisUser) && $isMyClub">{_new_playlist}</a> + {/if} + + {if $friendsAudios} + <div class="friendsAudiosList"> + <a href="/audios{$fr->getRealId()}" style="width: 94%;padding-left: 10px;" n:foreach="$friendsAudios as $fr"> + <div class="elem"> + <img src="{$fr->getAvatarURL()}" /> + + <div class="additionalInfo"> + {php $audioStatus = $fr->getCurrentAudioStatus()} + <span class="name">{$fr->getCanonicalName()}</span> + <span class="desc">{$audioStatus ? $audioStatus->getName() : tr("audios_count", $fr->getAudiosCollectionSize())}</span> + </div> + </div> + </a> + </div> + {/if} + </div> +</div> diff --git a/Web/Presenters/templates/Group/Edit.xml b/Web/Presenters/templates/Group/Edit.xml index 22f0b490..8f85e7e7 100644 --- a/Web/Presenters/templates/Group/Edit.xml +++ b/Web/Presenters/templates/Group/Edit.xml @@ -102,6 +102,15 @@ </td> </tr> + <tr> + <td width="120" valign="top"> + <span class="nobold">{_audios}: </span> + </td> + <td> + <label><input type="checkbox" name="upload_audios" value="1" n:attr="checked => $club->isEveryoneCanUploadAudios()" /> {_everyone_can_upload_audios}</label> + </td> + </tr> + <tr> <td> diff --git a/Web/Presenters/templates/Group/View.xml b/Web/Presenters/templates/Group/View.xml index 0242bbb8..de00d9b0 100644 --- a/Web/Presenters/templates/Group/View.xml +++ b/Web/Presenters/templates/Group/View.xml @@ -90,6 +90,25 @@ </div> </div> </div> + + <div> + <div class="content_title_expanded" onclick="hidePanel(this, {$audiosCount});"> + {_audios} + </div> + <div> + <div class="content_subtitle"> + {tr("audios_count", $audiosCount)} + <div style="float:right;"> + <a href="/audios-{$club->getId()}">{_all_title}</a> + </div> + </div> + <div class="content_list long"> + <div class="audio" n:foreach="$audios as $audio" style="width: 100%;"> + {include "../Audio/player.xml", audio => $audio} + </div> + </div> + </div> + </div> {presenter "openvk!Wall->wallEmbedded", -$club->getId()} </div> diff --git a/Web/Presenters/templates/Photos/UploadPhoto.xml b/Web/Presenters/templates/Photos/UploadPhoto.xml index ab81d3aa..4f5f8e70 100644 --- a/Web/Presenters/templates/Photos/UploadPhoto.xml +++ b/Web/Presenters/templates/Photos/UploadPhoto.xml @@ -35,7 +35,7 @@ <div class="limits" style="margin-top:17px"> <b style="color:#45688E">{_admin_limits}</b> - <ul class="blueList" style="margin-left: -25px;margin-top: 1px;"> + <ul style="margin-top: 6px;"> <li>{_supported_formats}</li> <li>{_max_load_photos}</li> </ul> diff --git a/Web/Presenters/templates/Report/Tabs.xml b/Web/Presenters/templates/Report/Tabs.xml index e878ccb3..6003f202 100644 --- a/Web/Presenters/templates/Report/Tabs.xml +++ b/Web/Presenters/templates/Report/Tabs.xml @@ -46,6 +46,9 @@ <div n:attr="id => ($mode === 'user' ? 'activetabs' : 'ki')" class="tab" mode="user"> <a n:attr="id => ($mode === 'user' ? 'act_tab_a' : 'ki')">Пользователи</a> </div> + <div n:attr="id => ($mode === 'audio' ? 'activetabs' : 'ki')" class="tab" mode="audio"> + <a n:attr="id => ($mode === 'audio' ? 'act_tab_a' : 'ki')">{_audios}</a> + </div> </center> <script> diff --git a/Web/Presenters/templates/Report/ViewContent.xml b/Web/Presenters/templates/Report/ViewContent.xml index 249e6b38..1f5918d5 100644 --- a/Web/Presenters/templates/Report/ViewContent.xml +++ b/Web/Presenters/templates/Report/ViewContent.xml @@ -23,6 +23,8 @@ {if $appsSoftDeleting} {include "./content/app.xml", app => $object} {/if} + {elseif $type == "audio"} + {include "../Audio/player.xml", audio => $object} {else} {include "../components/error.xml", description => tr("version_incompatibility")} {/if} diff --git a/Web/Presenters/templates/Search/Index.xml b/Web/Presenters/templates/Search/Index.xml index cb1f54bd..acc1eab7 100644 --- a/Web/Presenters/templates/Search/Index.xml +++ b/Web/Presenters/templates/Search/Index.xml @@ -205,12 +205,14 @@ </div> {elseif $type == "videos"} {foreach $data as $dat} - <div class="content"> - {include "../components/video.xml", video => $dat} - </div> + <div class="content"> + {include "../components/video.xml", video => $dat} + </div> {/foreach} {elseif $type == "audios"} - хуй + {foreach $data as $dat} + {include "../Audio/player.xml", audio => $dat} + {/foreach} {/if} {else} {ifset customErrorMessage} @@ -231,12 +233,13 @@ {block searchOptions} <div class="searchOptions"> <ul class="searchList"> - <li {if $type === "users"} id="used"{/if}><a href="/search?type=users{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id"> {_s_people} {if $type === "users"} ({$count}){/if}</a></li> - <li {if $type === "groups"} id="used"{/if}><a href="/search?type=groups{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id"> {_s_groups} {if $type === "groups"} ({$count}){/if}</a></li> - <li {if $type === "comments"}id="used"{/if}><a href="/search?type=comments{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">{_s_comments} {if $type === "comments"}({$count}){/if}</a></li> - <li {if $type === "posts"} id="used"{/if}><a href="/search?type=posts{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id"> {_s_posts} {if $type === "posts"} ({$count}){/if}</a></li> - <li {if $type === "videos"} id="used"{/if}><a href="/search?type=videos{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id"> {_s_videos} {if $type === "videos"} ({$count}){/if}</a></li> - <li {if $type === "apps"} id="used"{/if}><a href="/search?type=apps{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id"> {_s_apps} {if $type === "apps"} ({$count}){/if}</a></li> + <a {if $type === "users"} id="used"{/if} href="/search?type=users{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id"> {_s_people} {if $type === "users"} ({$count}){/if}</a> + <a {if $type === "groups"} id="used"{/if} href="/search?type=groups{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id"> {_s_groups} {if $type === "groups"} ({$count}){/if}</a> + <a {if $type === "comments"}id="used"{/if} href="/search?type=comments{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">{_s_comments} {if $type === "comments"}({$count}){/if}</a> + <a {if $type === "posts"} id="used"{/if} href="/search?type=posts{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id"> {_s_posts} {if $type === "posts"} ({$count}){/if}</a> + <a {if $type === "videos"} id="used"{/if} href="/search?type=videos{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id"> {_s_videos} {if $type === "videos"} ({$count}){/if}</a> + <a {if $type === "apps"} id="used"{/if} href="/search?type=apps{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id"> {_s_apps} {if $type === "apps"} ({$count}){/if}</a> + <a {if $type === "audios"} id="used"{/if} href="/search?type=audios{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id"> {_s_audios} {if $type === "audios"} ({$count}){/if}</a> </ul> <div class="searchOption"> @@ -250,6 +253,11 @@ <option value="rating" {if $_GET["sort"] == "rating"}selected{/if}>{_s_order_by_rating}</option> {/if} {/if} + + {if $type == "audios"} + <option value="length" n:attr="selected => $_GET['sort'] == 'length'">{_s_order_by_length}</option> + <option value="listens" n:attr="selected => $_GET['sort'] == 'listens'">{_s_order_by_listens}</option> + {/if} </select> <div id="invertor"> <input type="checkbox" name="invert" value="1" form="searcher" {if !is_null($_GET['invert']) && $_GET['invert'] == "1"}checked{/if}>{_s_order_invert} @@ -352,9 +360,19 @@ <input type="text" value="{if !is_null($_GET['fav_quote'])}{$_GET['fav_quote']}{/if}" form="searcher" placeholder="{_favorite_quotes}" name="fav_quote"> </div> </div> - <!--<input name="with_photo" type="checkbox" {if !is_null($_GET['with_photo']) && $_GET['with_photo'] == "on"}checked{/if} form="searcher">{_s_with_photo}--> {/if} - <input class="button" type="button" id="dnt" value="{_reset}" onclick="resetSearch()"> + + {if $type == "audios"} + <div class="searchOption"> + <div class="searchOptionName" id="n_main_audio" onclick="hideParams('main_audio')"><img src="/assets/packages/static/openvk/img/hide.png" class="searchHide">{_s_main}</div> + <div class="searchOptionBlock" id="s_main_audio"> + <label><input type="checkbox" name="only_performers" n:attr="checked => !empty($_GET['only_performers'])" form="searcher">{_s_only_performers}</label><br> + <label><input type="checkbox" name="with_lyrics" n:attr="checked => !empty($_GET['with_lyrics'])" form="searcher">{_s_with_lyrics}</label> + </div> + </div> + {/if} + + <input class="button" type="button" id="dnt" value="{_reset}" onclick="resetSearch()"> </div> {/block} diff --git a/Web/Presenters/templates/User/Edit.xml b/Web/Presenters/templates/User/Edit.xml index b8e8398c..0a28b879 100644 --- a/Web/Presenters/templates/User/Edit.xml +++ b/Web/Presenters/templates/User/Edit.xml @@ -160,6 +160,13 @@ </select> </td> </tr> + <tr> + <td width="120" valign="top"> + </td> + <td> + <label><input type="checkbox" name="broadcast_music" n:attr="checked => $user->isBroadcastEnabled()">{_broadcast_audio}</label> + </td> + </tr> <tr> <td> diff --git a/Web/Presenters/templates/User/Settings.xml b/Web/Presenters/templates/User/Settings.xml index c8f0f61b..6fb9322f 100644 --- a/Web/Presenters/templates/User/Settings.xml +++ b/Web/Presenters/templates/User/Settings.xml @@ -323,6 +323,19 @@ </select> </td> </tr> + <tr> + <td width="120" valign="top"> + <span class="nobold">{_privacy_setting_view_audio}</span> + </td> + <td> + <select name="audios.read" style="width: 164px;"> + <option value="3" {if $user->getPrivacySetting('audios.read') == 3}selected{/if}>{_privacy_value_anybody_dative}</option> + <option value="2" {if $user->getPrivacySetting('audios.read') == 2}selected{/if}>{_privacy_value_users}</option> + <option value="1" {if $user->getPrivacySetting('audios.read') == 1}selected{/if}>{_privacy_value_friends_dative}</option> + <option value="0" {if $user->getPrivacySetting('audios.read') == 0}selected{/if}>{_privacy_value_only_me_dative}</option> + </select> + </td> + </tr> <tr> <td width="120" valign="top"> <span class="nobold">{_privacy_setting_see_notes}</span> @@ -609,6 +622,17 @@ <span class="nobold">{_my_videos}</span> </td> </tr> + <tr> + <td width="120" valign="top" align="right"> + <input + n:attr="checked => $user->getLeftMenuItemStatus('audios')" + type="checkbox" + name="menu_muziko" /> + </td> + <td> + <span class="nobold">{_my_audios}</span> + </td> + </tr> <tr> <td width="120" valign="top" align="right"> <input diff --git a/Web/Presenters/templates/User/View.xml b/Web/Presenters/templates/User/View.xml index 9014750d..f5cb6176 100644 --- a/Web/Presenters/templates/User/View.xml +++ b/Web/Presenters/templates/User/View.xml @@ -417,6 +417,10 @@ <form name="status_popup_form" onsubmit="changeStatus(); return false;"> <div style="margin-bottom: 10px;"> <input type="text" name="status" size="50" value="{$user->getStatus()}" /> + <label style="width: 316px;display: block;"> + <input type="checkbox" name="broadcast" n:attr="checked => $user->isBroadcastEnabled()" /> + {_broadcast_audio} + </label> </div> <input type="hidden" name="hash" value="{$csrfToken}" /> <button type="submit" name="submit" class="button" style="height: 22px;">{_send}</button> @@ -425,11 +429,20 @@ <div class="accountInfo clearFix"> <div class="profileName"> <h2>{$user->getFullName()}</h2> - {if !is_null($user->getStatus())} - <div n:class="page_status, $thatIsThisUser ? page_status_edit_button" n:attr="id => $thatIsThisUser ? page_status_text : NULL">{$user->getStatus()}</div> - {elseif $thatIsThisUser} - <div class="page_status"> - <div n:class="edit_link, $thatIsThisUser ? page_status_edit_button" id="page_status_text">[ {_change_status} ]</div> + + {if !$audioStatus} + {if !is_null($user->getStatus())} + <div n:class="page_status, $thatIsThisUser ? page_status_edit_button" n:attr="id => $thatIsThisUser ? page_status_text : NULL">{$user->getStatus()}</div> + {elseif $thatIsThisUser} + <div class="page_status"> + <div n:class="edit_link, $thatIsThisUser ? page_status_edit_button" id="page_status_text">[ {_change_status} ]</div> + </div> + {/if} + {else} + <div class="page_status" style="display: flex;"> + <div n:class="audioStatus, $thatIsThisUser ? page_status_edit_button" id="page_status_text"> + {$audioStatus->getName()} + </div> </div> {/if} </div> @@ -530,7 +543,7 @@ {var $musics = explode(", ", $user->getFavoriteMusic())} {foreach $musics as $music} - <a href="/search?type=users&query=&fav_mus={urlencode($music)}">{$music}</a>{if $music != end($musics)},{/if} + <a href="/search?type=audios&query={urlencode($music)}">{$music}</a>{if $music != end($musics)},{/if} {/foreach} </td> </tr> @@ -594,6 +607,25 @@ </div> </div> + <div n:if="$audiosCount > 0 && $user->getPrivacyPermission('audios.read', $thisUser ?? NULL)"> + <div class="content_title_expanded" onclick="hidePanel(this, {$audiosCount});"> + {_audios} + </div> + <div> + <div class="content_subtitle"> + {tr("audios_count", $audiosCount)} + <div style="float:right;"> + <a href="/audios{$user->getId()}">{_all_title}</a> + </div> + </div> + <div class="content_list long"> + <div class="audio" n:foreach="$audios as $audio" style="width: 100%;"> + {include "../Audio/player.xml", audio => $audio} + </div> + </div> + </div> + </div> + <div n:if="OPENVK_ROOT_CONF['openvk']['preferences']['commerce'] && ($giftCount = $user->getGiftCount()) > 0"> <div class="content_title_expanded" onclick="hidePanel(this, {$giftCount});"> {_gifts} @@ -739,12 +771,14 @@ async function changeStatus() { const status = document.status_popup_form.status.value; + const broadcast = document.status_popup_form.broadcast.checked; document.status_popup_form.submit.innerHTML = "<div class=\"button-loading\"></div>"; document.status_popup_form.submit.disabled = true; const formData = new FormData(); formData.append("status", status); + formData.append("broadcast", Number(broadcast)); formData.append("hash", document.status_popup_form.hash.value); const response = await ky.post("/edit?act=status", {body: formData}); diff --git a/Web/Presenters/templates/_includeCSS.xml b/Web/Presenters/templates/_includeCSS.xml index 60409c27..8e1aef6a 100644 --- a/Web/Presenters/templates/_includeCSS.xml +++ b/Web/Presenters/templates/_includeCSS.xml @@ -10,6 +10,7 @@ {css "css/dialog.css"} {css "css/notifications.css"} {css "css/avataredit.css"} + {css "css/audios.css"} {if $isXmas} {css "css/xmas.css"} @@ -27,6 +28,7 @@ {css "css/dialog.css"} {css "css/notifications.css"} {css "css/avataredit.css"} + {css "css/audios.css"} {if $isXmas} {css "css/xmas.css"} @@ -50,7 +52,7 @@ {css "css/dialog.css"} {css "css/nsfw-posts.css"} {css "css/notifications.css"} - {css "css/avataredit.css"} + {css "css/audios.css"} {if $isXmas} {css "css/xmas.css"} diff --git a/Web/Presenters/templates/components/attachment.xml b/Web/Presenters/templates/components/attachment.xml index a46be837..85a33f21 100644 --- a/Web/Presenters/templates/components/attachment.xml +++ b/Web/Presenters/templates/components/attachment.xml @@ -50,6 +50,10 @@ {else} {include "post.xml", post => $attachment, compact => true} {/if} +{elseif $attachment instanceof \openvk\Web\Models\Entities\Audio} + <div style="width:100%;"> + {include "../Audio/player.xml", audio => $attachment} + </div> {else} <span style="color:red;">{_version_incompatibility}</span> {/if} diff --git a/Web/Presenters/templates/components/textArea.xml b/Web/Presenters/templates/components/textArea.xml index 8727addf..530174b1 100644 --- a/Web/Presenters/templates/components/textArea.xml +++ b/Web/Presenters/templates/components/textArea.xml @@ -22,6 +22,7 @@ </div> <div class="post-has-videos"></div> + <div class="post-has-audios"></div> <div n:if="$postOpts ?? true" class="post-opts"> {var $anonEnabled = OPENVK_ROOT_CONF['openvk']['preferences']['wall']['anonymousPosting']['enable']} @@ -62,6 +63,7 @@ </div> <input type="hidden" name="photos" value="" /> <input type="hidden" name="videos" value="" /> + <input type="hidden" name="audios" value="" /> <input type="hidden" name="poll" value="none" /> <input type="hidden" id="note" name="note" value="none" /> <input type="hidden" name="type" value="1" /> @@ -85,6 +87,10 @@ <img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/mimetypes/application-vnd.rn-realmedia.png" /> {_video} </a> + <a id="_audioAttachment"> + <img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/mimetypes/audio-ac3.png" /> + {_audio} + </a> <a n:if="$notes ?? false" href="javascript:attachNote({$textAreaId})"> <img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/mimetypes/application-x-srt.png" /> {_note} @@ -110,6 +116,7 @@ u("#post-buttons{$textAreaId} input[name='videos']")["nodes"].at(0).value = "" u("#post-buttons{$textAreaId} input[name='photos']")["nodes"].at(0).value = "" + u("#post-buttons{$textAreaId} input[name='audios']")["nodes"].at(0).value = "" </script> {if $graffiti} diff --git a/Web/di.yml b/Web/di.yml index 57d46f14..81e06117 100644 --- a/Web/di.yml +++ b/Web/di.yml @@ -7,6 +7,7 @@ services: - openvk\Web\Presenters\CommentPresenter - openvk\Web\Presenters\PhotosPresenter - openvk\Web\Presenters\VideosPresenter + - openvk\Web\Presenters\AudioPresenter - openvk\Web\Presenters\BlobPresenter - openvk\Web\Presenters\GroupPresenter - openvk\Web\Presenters\SearchPresenter @@ -33,6 +34,7 @@ services: - openvk\Web\Models\Repositories\Albums - openvk\Web\Models\Repositories\Clubs - openvk\Web\Models\Repositories\Videos + - openvk\Web\Models\Repositories\Audios - openvk\Web\Models\Repositories\Notes - openvk\Web\Models\Repositories\Tickets - openvk\Web\Models\Repositories\Messages diff --git a/Web/routes.yml b/Web/routes.yml index dea8ddd5..d12ccbc0 100644 --- a/Web/routes.yml +++ b/Web/routes.yml @@ -187,6 +187,34 @@ routes: handler: "Videos->edit" - url: "/video{num}_{num}/remove" handler: "Videos->remove" + - url: "/player/upload" + handler: "Audio->upload" + - url: "/audios{num}" + handler: "Audio->list" + - url: "/audios/popular" + handler: "Audio->popular" + - url: "/audios/new" + handler: "Audio->new" + - url: "/audio{num}_{num}/embed.xhtml" + handler: "Audio->embed" + - url: "/audio{num}/listen" + handler: "Audio->listen" + - url: "/audios/search" + handler: "Audio->search" + - url: "/audios/newPlaylist" + handler: "Audio->newPlaylist" + - url: "/audios/context" + handler: "Audio->apiGetContext" + - url: "/playlist{num}_{num}" + handler: "Audio->playlist" + - url: "/playlist{num}_{num}/edit" + handler: "Audio->editPlaylist" + - url: "/playlist{num}/action" + handler: "Audio->playlistAction" + - url: "/playlists{num}" + handler: "Audio->playlists" + - url: "/audio{num}/action" + handler: "Audio->action" - url: "/{?!club}{num}" handler: "Group->view" placeholders: @@ -221,24 +249,6 @@ routes: handler: "Topics->edit" - url: "/topic{num}_{num}/delete" handler: "Topics->delete" - - url: "/audios{num}" - handler: "Audios->app" - - url: "/audios{num}.json" - handler: "Audios->apiListSongs" - - url: "/audios/popular.json" - handler: "Audios->apiListPopSongs" - - url: "/audios/playlist{num}.json" - handler: "Audios->apiListPlaylists" - - url: "/audios/search.json" - handler: "Audios->apiSearch" - - url: "/audios/add.json" - handler: "Audios->apiAdd" - - url: "/audios/playlist.json" - handler: "Audios->apiAddPlaylist" - - url: "/audios/upload.json" - handler: "Audios->apiUpload" - - url: "/audios/beacon" - handler: "Audios->apiBeacon" - url: "/im" handler: "Messenger->index" - url: "/im/sel{num}" @@ -341,6 +351,12 @@ routes: handler: "Admin->bannedLink" - url: "/admin/bannedLink/id{num}/unban" handler: "Admin->unbanLink" + - url: "/admin/music" + handler: "Admin->music" + - url: "/admin/music/{num}/edit" + handler: "Admin->editMusic" + - url: "/admin/playlist/{num}/edit" + handler: "Admin->editPlaylist" - url: "/admin/user{num}/bans" handler: "Admin->bansHistory" - url: "/upload/photo/{text}" diff --git a/Web/static/css/audios.css b/Web/static/css/audios.css new file mode 100644 index 00000000..5decbb89 --- /dev/null +++ b/Web/static/css/audios.css @@ -0,0 +1,661 @@ +.noOverflow { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.overflowedName { + position: absolute; + z-index: 99; +} + +.musicIcon { + background-image: url('/assets/packages/static/openvk/img/audios_controls.png'); + background-repeat: no-repeat; + cursor: pointer; +} + +.musicIcon:hover { + filter: brightness(99%); +} + +.musicIcon.pressed { + filter: brightness(150%); +} + +.bigPlayer { + background-color: rgb(240, 241, 242); + margin-left: -10px; + margin-top: -10px; + width: 102.8%; + height: 46px; + border-bottom: 1px solid #d8d8d8; + box-shadow: 1px 0px 8px 0px rgba(34, 60, 80, 0.2); + position: relative; +} + +.bigPlayer.floating { + position: fixed; + z-index: 199; + width: 627px; + margin-top: -76px; +} + +.bigPlayer .paddingLayer { + padding: 0px 0px 0px 14px; +} + +.bigPlayer .paddingLayer .playButtons { + padding: 12px 0px; +} + +.bigPlayer .paddingLayer .playButtons .playButton { + width: 22px; + height: 22px; + float: left; + background-position-x: -72px; +} + +.bigPlayer .paddingLayer .playButtons .playButton.pause { + background-position-x: -168px; +} + +.bigPlayer .paddingLayer .playButtons .nextButton { + width: 16px; + height: 16px; + background-position-y: -47px; +} + +.bigPlayer .paddingLayer .playButtons .backButton { + width: 16px; + height: 16px; + background-position-y: -47px; + background-position-x: -16px; + margin-left: 6px; +} + +.bigPlayer .paddingLayer .additionalButtons { + float: left; + margin-top: -6px; + width: 11%; +} + +.bigPlayer .paddingLayer .additionalButtons .repeatButton { + width: 14px; + height: 16px; + background-position-y: -49px; + background-position-x: -31px; + margin-left: 7px; + float: left; +} + +.broadcastButton { + width: 16px; + height: 12px; + background-position-y: -50px; + background-position-x: -64px; + margin-left: 6px; + float: left; +} + +.broadcastButton.atProfile { + width: 13px; + height: 12px; + background-position-y: -50px; + background-position-x: -64px; + margin-left: 0px !important; + margin-right: 5px; + float: left; +} + +.bigPlayer .paddingLayer .additionalButtons .shuffleButton { + width: 14px; + height: 16px; + background-position: -50px -50px; + margin-left: 7px; + float: left; +} + +.bigPlayer .paddingLayer .additionalButtons .deviceButton { + width: 12px; + height: 16px; + background-position: -202px -50px; + margin-left: 7px; + float: left; +} + +.bigPlayer .paddingLayer .playButtons .arrowsButtons { + float: left; + display: flex; + padding-left: 4px; + padding-top: 1.2px; +} + +.bigPlayer .paddingLayer .trackPanel { + float: left; + margin-top: -13px; + margin-left: 13px; + width: 63%; + position: relative; +} + +.bigPlayer .paddingLayer .bigPlayerTip { + display: none; + z-index: 999; + background: #cecece; + padding: 3px; + top: -3px; + position: absolute; + transition: all .1s ease-out; + user-select: none; +} + +.bigPlayer .paddingLayer .volumePanel { + width: 73px; + float: left; +} + +.bigPlayer .paddingLayer .slider, .audioEmbed .track .slider { + width: 18px; + height: 7px; + background: #606060; + position: absolute; + bottom: 0; + top: 0px; + pointer-events: none; +} + +.bigPlayer .paddingLayer .trackInfo .timer { + float: right; + margin-right: 8px; + font-size: 10px; +} + +.bigPlayer .paddingLayer .trackInfo .trackName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 81%; + display: inline-block; +} + +.bigPlayer .paddingLayer .trackInfo .timer span { + font-size: 10px; +} + +.bigPlayer .paddingLayer .trackInfo b:hover { + text-decoration: underline; + cursor: pointer; +} + +.audioEmbed .track > .selectableTrack, .bigPlayer .selectableTrack { + margin-top: 3px; + width: calc(100% - 8px); + border-top: #606060 1px solid; + height: 6px; + position: relative; + user-select: none; +} + +#audioEmbed { + cursor: pointer; + user-select: none; + background: #eee; + height: 40px; + width: 486px; + position: absolute; + top: 50%; + left: 50%; + margin-right: -50%; + transform: translate(-50%, -50%); + overflow: hidden; + border: 1px solid #8B8B8B; +} + +.audioEntry.nowPlaying { + background: #606060; + border: 1px solid #4f4f4f; + box-sizing: border-box; +} + +.audioEntry.nowPlaying .playIcon { + background-position-y: -16px !important; +} + +.audioEntry.nowPlaying:hover { + background: #4e4e4e !important; +} + +.audioEntry.nowPlaying .performer a { + color: #f4f4f4 !important; +} + +.audioEntry .performer a { + color: #4C4C4C; +} + +.audioEntry.nowPlaying .title { + color: #fff; +} + +.audioEntry.nowPlaying .status { + color: white; +} + +.audioEntry.nowPlaying .volume .nobold { + color: white !important; +} + +.audioEntry.nowPlaying .buttons .musicIcon, .audioEntry.nowPlaying .explicitMark { + filter: brightness(187%) opacity(72%); +} + +.audioEntry { + height: 100%; + position: relative; + width: 100%; +} + +.audioEntry .playerButton { + position: relative; + padding: 10px 9px 9px 9px; + width: 16px; + height: 16px; +} + +.audioEntry .subTracks { + display: flex; + padding-bottom: 5px; + padding-left: 8px; + padding-right: 12px; +} + +.audioEntry .playerButton .playIcon { + background-image: url('/assets/packages/static/openvk/img/play_buttons.gif'); + cursor: pointer; + width: 16px; + height: 16px; + background-repeat: no-repeat; +} + +.audioEntry .playerButton .playIcon.paused { + background-position-y: -16px; +} + +.audioEntry .status { + overflow: hidden; + display: grid; + grid-template-columns: 1fr; + width: 85%; + height: 23px; +} + +.audioEntry .status strong { + color: #4C4C4C; +} + +.audioEmbed .track { + display: none; + padding: 4px 0; +} + +.audioEmbed .track, .audioEmbed.playing .track { + display: unset; +} + +.audioEntry:hover { + background: #EEF0F2; + border-radius: 2px; +} + +.audioEntry:hover .buttons { + display: block; +} + +.audioEntry:hover .volume .hideOnHover { + display: none; +} + +.audioEntry .buttons { + display: none; + width: 62px; + height: 20px; + position: absolute; + right: 3%; + top: 2px; + /* чтоб избежать заедания во время ховера кнопки добавления */ + clip-path: inset(0 0 0 0); +} + +.audioEntry .buttons .edit-icon { + width: 11px; + height: 11px; + float: right; + margin-right: 4px; + margin-top: 3px; + background-position: -137px -51px; +} + +.audioEntry .buttons .add-icon { + width: 11px; + height: 11px; + float: right; + background-position: -80px -52px; + margin-top: 3px; + margin-left: 2px; +} + +.audioEntry .buttons .add-icon-group { + width: 14px; + height: 11px; + float: right; + background-position: -94px -52px; + margin-top: 3px; + transition: margin-right 0.1s ease-out, opacity 0.1s ease-out; +} + +.audioEntry .buttons .report-icon { + width: 12px; + height: 11px; + float: right; + background-position: -67px -51px; + margin-top: 3px; + margin-right: 3px; +} + +.add-icon-noaction { + background-image: url('/assets/packages/static/openvk/img/audios_controls.png'); + width: 11px; + height: 11px; + float: right; + background-position: -94px -52px; + margin-top: 2px; + margin-right: 2px; +} + +.audioEntry .buttons .remove-icon { + margin-top: 3px; + width: 11px; + height: 11px; + margin-left: 2px; + float: right; + background-position: -108px -52px; +} + +.audioEntry .buttons .remove-icon-group { + margin-top: 3px; + width: 13px; + height: 11px; + float: right; + background-position: -122px -52px; + margin-left: 3px; + margin-right: 3px; +} + +.audioEmbed .lyrics { + display: none; + padding: 6px 33px 10px 33px; +} + +.audioEmbed .lyrics.showed { + display: block !important; +} + +.audioEntry .withLyrics { + user-select: none; + color: #507597; +} + +.audioEntry .withLyrics:hover { + text-decoration: underline; +} + +.audioEmbed.withdrawn .status > *, .audioEmbed.processed .status > *, .audioEmbed.withdrawn .playerButton > *, .audioEmbed.processed .playerButton > * { + pointer-events: none; +} + +.audioEmbed.withdrawn { + filter: opacity(0.8); +} + +.playlistCover img { + max-width: 135px; + max-height: 135px; +} + +.playlistBlock { + margin-top: 14px; +} + +.playlistContainer { + display: grid; + grid-template-columns: repeat(3, 146px); + gap: 18px 10px; +} + +.playlistContainer .playlistCover { + width: 111px; + height: 111px; + display: flex; + background: #c4c4c4; +} + +.playlistContainer .playlistCover img { + max-width: 111px; + max-height: 111px; + margin: auto; +} + +.ovk-diag-body .searchBox { + background: #e6e6e6; + padding-top: 10px; + height: 35px; + padding-left: 10px; + padding-right: 10px; + display: flex; +} + +.ovk-diag-body .searchBox input { + height: 24px; + margin-right: -1px; + width: 77%; +} + +.ovk-diag-body .searchBox select { + width: 29%; + padding-left: 8px; + height: 24px; +} + +.ovk-diag-body .audiosInsert { + height: 82%; + padding: 9px 5px 9px 9px; + overflow-y: auto; +} + +.attachAudio { + float: left; + width: 28%; + height: 26px; + padding-top: 11px; + text-align: center; +} + +.attachAudio span { + user-select: none; +} + +.attachAudio:hover { + background: rgb(236, 236, 236); + cursor: pointer; +} + +.playlistCover img { + cursor: pointer; +} + +.explicitMark { + margin-top: 2px; + margin-left: 3px; + width: 11px; + height: 11px; + background: url('/assets/packages/static/openvk/img/explicit.svg'); + background-repeat: no-repeat; +} + +.audioStatus span { + color: #2B587A; +} + +.audioStatus span:hover { + text-decoration: underline; + cursor: pointer; +} + +.audioStatus { + padding-top: 2px; + padding-bottom: 3px; +} + +/* <center> 🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣*/ +.audiosDiv center span { + color: #707070; + margin: 120px 0px !important; + display: block; +} + +.audiosDiv center { + margin-left: -10px; +} + +.playlistInfo { + display: flex; + flex-direction: column; +} + +.playlistInfo .playlistName { + font-weight: 600; +} + +.searchList.floating { + position: fixed; + z-index: 199; + width: 156px; + margin-top: -65px !important; +} + +.audiosSearchBox input[type='search'] { + height: 25px; + width: 77%; + padding-left: 21px; + padding-top: 4px; + background: rgb(255, 255, 255) url("/assets/packages/static/openvk/img/search_icon.png") 5px 6px no-repeat; +} + +.audiosSearchBox { + padding-bottom: 10px; + padding-top: 7px; + display: flex; +} + +.audiosSearchBox select { + width: 30%; + padding-left: 7px; + margin-left: -2px; +} + +.audioStatus { + color: #2B587A; + margin-top: -3px; +} + +.audioStatus::before { + background-image: url('/assets/packages/static/openvk/img/audios_controls.png'); + background-repeat: no-repeat; + width: 11px; + height: 11px; + background-position: -66px -51px; + margin-top: 1px; + display: inline-block; + vertical-align: bottom; + content: ""; + padding-right: 2px; +} + +.friendsAudiosList { + margin-left: -7px; + margin-top: 8px; +} + +.friendsAudiosList .elem { + display: flex; + padding: 1px 1px; + width: 148px; +} + +.friendsAudiosList .elem img { + width: 30px; + border-radius: 2px; + object-fit: cover; + height: 31px; + min-width: 30px; +} + +.friendsAudiosList .elem .additionalInfo { + margin-left: 7px; + padding-top: 1px; + width: 100%; + display: flex; + flex-direction: column; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.friendsAudiosList .elem .additionalInfo .name { + color: #2B587A; +} + +.friendsAudiosList #used .elem .additionalInfo .name { + color: #F4F4F4; +} + +.friendsAudiosList .elem .additionalInfo .desc { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + color: #878A8F; + font-size: 11px; +} + +.friendsAudiosList #used .elem .additionalInfo .desc { + color: #F4F4F4; +} + +.friendsAudiosList .elem:hover { + background: #E8EBF0; + cursor: pointer; +} + +.friendsAudiosList #used .elem:hover { + background: #787878; + cursor: pointer; +} + +.editContainer { + display:table; + clear:both; + width:100%; + margin-top: 10px; +} + +.editContainer .playerContainer { + width: 78%; + float: left; + max-width: 78%; + min-width: 68%; +} + +.addToPlaylist { + width: 22%; +} diff --git a/Web/static/css/main.css b/Web/static/css/main.css index 7b63d6f6..7a087705 100644 --- a/Web/static/css/main.css +++ b/Web/static/css/main.css @@ -1243,64 +1243,6 @@ table.User { border-bottom: 1px solid #c3cad2; } -.music-app { - display: grid; -} - -.music-app--player { - display: grid; - grid-template-columns: 32px 32px 32px 1fr; - padding: 8px; - border-bottom: 1px solid #c1c1c1; - border-bottom-style: solid; - border-bottom-style: dashed; -} - -.music-app--player .play, -.music-app--player .perv, -.music-app--player .next { - -webkit-appearance: none; - -moz-appearance: none; - background-color: #507597; - color: #fff; - height: 20px; - margin: 5px; - border: none; - border-radius: 5px; - cursor: pointer; - padding: 0; - font-size: 10px; -} - -.music-app--player .info { - margin-left: 10px; - width: 550px; -} - -.music-app--player .info .song-name * { - color: black; -} - -.music-app--player .info .song-name time { - float: right; -} - -.music-app--player .info .track { - margin-top: 8px; - height: 5px; - width: 70%; - background-color: #fff; - border-top: 1px solid #507597; - float: left; -} - -.music-app--player .info .track .inner-track { - background-color: #507597; - height: inherit; - width: 15px; - opacity: .7; -} - .settings_delete { margin: -12px; padding: 12px; @@ -1477,7 +1419,7 @@ body.scrolled .toTop:hover { display: none; } -.post-has-videos { +.post-has-videos, .post-has-audios { margin-top: 11px; margin-left: 3px; color: #3c3c3c; @@ -1494,7 +1436,7 @@ body.scrolled .toTop:hover { margin-left: 2px; } -.post-has-video { +.post-has-video, .post-has-audio { padding-bottom: 4px; cursor: pointer; } @@ -1503,6 +1445,10 @@ body.scrolled .toTop:hover { text-decoration: underline; } +.post-has-audio:hover span { + text-decoration: underline; +} + .post-has-video::before { content: " "; width: 14px; @@ -1516,6 +1462,19 @@ body.scrolled .toTop:hover { margin-bottom: -1px; } +.post-has-audio::before { + content: " "; + width: 14px; + height: 15px; + display: inline-block; + vertical-align: bottom; + background-image: url("/assets/packages/static/openvk/img/audio.png"); + background-repeat: no-repeat; + margin: 3px; + margin-left: 2px; + margin-bottom: -1px; +} + .post-opts { margin-top: 10px; } @@ -2123,6 +2082,45 @@ table td[width="120"] { margin: 0 auto; } +#upload_container { + background: white; + padding: 30px 80px 20px; + margin: 10px 25px 30px; + border: 1px solid #d6d6d6; +} + +#upload_container h4 { + border-bottom: solid 1px #daE1E8; + text-align: left; + padding: 0 0 4px 0; + margin: 0; +} + +#audio_upload { + width: 350px; + margin: 20px auto; + margin-bottom: 10px; + padding: 15px 0; + border: 2px solid #ccc; + background-color: #EFEFEF; + text-align: center; +} + +ul { + list-style: url(/assets/packages/static/openvk/img/bullet.gif) outside; + margin: 10px 0; + padding-left: 30px; + color: black; +} + +li { + padding: 1px 0; +} + +#upload_container ul { + padding-left: 15px; +} + #votesBalance { margin-top: 10px; padding: 7px; @@ -2473,8 +2471,7 @@ a.poll-retract-vote { display: none; } -.searchOptions -{ +.searchOptions { overflow: hidden; width:25.5%; border-top:1px solid #E5E7E6; @@ -2485,8 +2482,7 @@ a.poll-retract-vote { margin-right: -7px; } -.searchBtn -{ +.searchBtn { border: solid 1px #575757; background-color: #696969; color:white; @@ -2498,52 +2494,47 @@ a.poll-retract-vote { margin-top: 1px; } -.searchBtn:active -{ +.searchBtn:active { border: solid 1px #666666; background-color: #696969; color:white; box-shadow: 0px -2px 0px 0px rgba(255, 255, 255, 0.18) inset; } -.searchList -{ +.searchList { list-style: none; user-select: none; padding-left:0px; } -.searchList #used -{ +.searchList #used { margin-left:0px; - color: white; + color: white !important; padding:2px; padding-top:5px; padding-bottom:5px; - border: solid 0.125rem #696969; - background:linear-gradient(#888888,#858585); + border: solid 0.125rem #4F4F4F; + background: #606060; margin-bottom:2px; padding-left:9px; width:87%; } -.searchList #used a -{ +.searchList #used a { color: white; } -.sr:focus -{ +.sr:focus { outline:none; } -.searchHide -{ +.searchHide { padding-right: 5px; } -.searchList li +.searchList li, .searchList a { + display: block; margin-left:0px; color: #2B587A !important; cursor:pointer; @@ -2554,26 +2545,27 @@ a.poll-retract-vote { padding-left:9px; } -.searchList li a -{ +.searchList li a { min-width:100%; } -.searchList li:hover -{ - margin-left:0px; - color: #2B587A !important; - background:#ebebeb; - padding:2px; - padding-top:5px; - padding-bottom:5px; - margin-bottom:2px; - padding-left:9px; - width:91%; +.searchList a { + min-width: 88%; } -.whatFind -{ +.searchList a:hover { + margin-left: 0px; + color: #2B587A !important; + background: #ebebeb; + padding: 2px; + padding-top: 5px; + padding-bottom: 5px; + margin-bottom: 2px; + padding-left: 9px; + width: 89.9%; +} + +.whatFind { color:rgb(128, 128, 128); background:none; border:none; @@ -2585,8 +2577,7 @@ a.poll-retract-vote { margin-top: 0.5px; } -.searchOptionName -{ +.searchOptionName { cursor:pointer; background-color: #EAEAEA; padding-left:5px; @@ -2598,8 +2589,7 @@ a.poll-retract-vote { border-bottom: 2px solid #E4E4E4; } -.searchOption -{ +.searchOption { user-select: none; } @@ -2876,7 +2866,7 @@ body.article .floating_sidebar, body.article .page_content { .lagged { filter: opacity(0.5); - cursor: progress; + cursor: not-allowed; user-select: none; } @@ -2890,7 +2880,7 @@ body.article .floating_sidebar, body.article .page_content { pointer-events: none; } -.lagged * { +.lagged *, .lagged { pointer-events: none; } @@ -2975,3 +2965,40 @@ body.article .floating_sidebar, body.article .page_content { background: #E9F0F1 !important; } +.searchOptions.newer { + padding-left: 6px; + border-top: unset !important; + height: unset !important; + border-left: 1px solid #d8d8d8; + width: 26% !important; +} + +hr { + background-color: #d8d8d8; + border: none; + height: 1px; +} + +.searchList hr { + width: 153px; + margin-left: 0px; + margin-top: 6px; +} + +.showMore, .showMoreAudiosPlaylist { + width: 100%; + text-align: center; + background: #d5d5d5; + height: 22px; + padding-top: 9px; + cursor: pointer; +} + +#upload_container.uploading { + background: white url('/assets/packages/static/openvk/img/progressbar.gif') !important; + background-position-x: 0% !important; + background-position-y: 0% !important; + background-repeat: repeat !important; + background-repeat: no-repeat !important; + background-position: 50% !important; +} diff --git a/Web/static/img/audio.png b/Web/static/img/audio.png new file mode 100644 index 0000000000000000000000000000000000000000..62d9dff203a8f34d908941223522ab2ed648faa9 GIT binary patch literal 560 zcmV-00?+-4P)<h;3K|Lk000e1NJLTq000gE000gM1^@s6A4o0H00001b5ch_0Itp) z=>Px#1ZP1_K>z@;j|==^1poj532;bRa{vGf6951U69E94oEQKA0mVr~K~y+Tol?DO z0s#z8)vBQ4sVFE^hr`KN5F9#`zJ#tmKo?QF6h{TQ!QH`EXzAeQAR;&w#Kj*R{6X<V z!T;uqC&jfNgzqlNm*kS8|Kh=5!SQ&c>2ykgK;Q|yTrTNwIM8G=q4W7nolXa7qf914 zCQ%p|kH@rHt-wR(;d;FmyId|qv)Mf46qvwbu%KG4QX~?g!C)Ya31BQbI2;brb^Xyu zz3-UM=V>;Z(P%UR>zmC+x7lnu7b<tV-Ll<oCm3+o5m+o1<oEk&x7+2=B^V4+DwWa@ ztJP|6lEe^UqtTF;dc962({%{`E-jr-%kvEu%U>VK!uIC#ImP2~2*k44ELAEMNn(E1 zH0^o2-4YCt5+08Sltf2t)$8@*U6P8$Vv<LH=LJ!_-4;J^>zLz?CGJ>KRkg(5C&6em zn&YjOLZOfv4u@6vZ!r<FD9d7>q9{J#bUI0pkN&~S^7Z?DYPDMAbUMWV8I{Mg<L}@* z$vLo9V87qL@`#_!W|Q1*H*vui2{5E%B9V|o_j)~Yxm?(641T`j7gLOj#<&Q4--SW} yp{tOmQ+SgKFGI0ftqQw-;71|jS?nevBKifrKlO%}3D{Tw0000<MNUMnLSTZVsQEqs literal 0 HcmV?d00001 diff --git a/Web/static/img/audios_controls.png b/Web/static/img/audios_controls.png new file mode 100644 index 0000000000000000000000000000000000000000..bf21832a08c0c8c47ee03636c97e1df49282514f GIT binary patch literal 3706 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 Y|EKD#P+H^wDF6Tf07*qoM6N<$g3W72`2YX_ literal 0 HcmV?d00001 diff --git a/Web/static/img/bullet.gif b/Web/static/img/bullet.gif new file mode 100644 index 0000000000000000000000000000000000000000..bd5c540f311367fe2786f8c90a1a9b6bdd081677 GIT binary patch literal 53 zcmZ?wbhEHbWMklA_`t|e+_U!o|Nn|VSy&ku7#VaJfB+=Jz{Jzjzw$H>KTl7h*9tBM FYXF1T4W9r2 literal 0 HcmV?d00001 diff --git a/Web/static/img/explicit.svg b/Web/static/img/explicit.svg new file mode 100644 index 00000000..df6f6c15 --- /dev/null +++ b/Web/static/img/explicit.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="11" viewBox="0 0 11 11" width="11"> + <path d="m1 2.506v5.988a1.5 1.5 0 0 0 1.491 1.506h6.019c.827 0 1.49-.674 1.49-1.506v-5.988a1.5 1.5 0 0 0 -1.491-1.506h-6.019c-.827 0-1.49.674-1.49 1.506zm4 2.494v-1h2v-1h-3v5h3v-1h-2v-1h2v-1zm-5-2.494a2.496 2.496 0 0 1 2.491-2.506h6.019a2.5 2.5 0 0 1 2.49 2.506v5.988a2.496 2.496 0 0 1 -2.491 2.506h-6.019a2.5 2.5 0 0 1 -2.49-2.506z" + fill="#828a99" fill-opacity=".7"/> +</svg> \ No newline at end of file diff --git a/Web/static/img/favicons/favicon24_paused.png b/Web/static/img/favicons/favicon24_paused.png new file mode 100644 index 0000000000000000000000000000000000000000..c8fdef269222dd0fff08c6177bd5f2d3a3a26a05 GIT binary patch literal 932 zcmV;V16%xwP)<h;3K|Lk000e1NJLTq000;O000;W1^@s6;CDUv00004XF*Lt006O` zVN5~y0000WV@Og>004&&004{<008|>004nL003F*009yY002DZ000@zy2&Ck0003Y zX+uL$Nkc;*P;zf(X>4Tx04UF6U|=$Eba8TJ5@2A+%_}Jia(7aQh>TKTKhMC%z{~&! ziOIzUjsXEaAa-7UUMd3y_;!tf5kz0s1)0S_8sJJUC@KNce}LF1CAB!2fq~Hg$j&Y= zDFBKy&H=JTlCr_<4Ip+=NRTs-eFVtnONX$pfY?b8_7f1h2<QnQ`wK{YI+)GG0X7H1 zmI1L#QY%3AGC1cKR2F5XXOt)yT3T8vIHu$$r78qh7MG;v7Att>CFd6v<QFBDq^1DX zLfi)if)ItE5Yqr^w9sUrG62N`g!MTP9KMWVXAoitkX%|+24ylb858Cs%$Os=z~H}> zfq~@@Ld>L>fnk9T0|Wn5gqWHk1A~A*1H-;K0Kmpi{cn=x1ONa432;bRa{vGi!~g&e z!~vBn4jTXf0qsddK~zXftyViu12Gh|&1PLiBv$~gKxhyR*FZF>QjwmNe7Yzchz8LB zN8k=1NEPA_GxjDJgCZGrHsiHD+4ub3^I9vX)2W!x=Z95QT?!!%Kn-q&2W9O3f(7_s zj5+UiyZ3T3nH(;c%Uev=L2d!`uY~xhI-O&sYklRMt3lBg(Fp5&t+l>Z&bxv49$wqK z9PRnwY+pI&wYL~p2no&77IXbPBY^`fTr2M&L~q~t7p$K3ykpT8wkw7cLc*2fW;Sbj zLY$4qO)p}TCW9%a$qU8C!W$#WnW99q%!oxFc9liPagrfrOi`lIrscwsDirxE?>SSH zXp91z=gR>*EK1H4B^r%0D|8eiuqISIXNnRnq=+?uBh;XYz;nBPXNnSSl`6c=+(k`D z<|k8>Xffq6nz)OaLCzE<nh*A(lkgs}Agi!arYO;3%EM97>>}2IUNfdB(bg$~UB!@C z1A5DuqC|@+#~lTi!{N}sy}kc>7X1~Hj-(X*-u5a*EHuMvhfERAp^zekv|uPaV%uUo zp<xrY71~FznQVt>!X3S*t)0EyXKUPvkZ!9<;r{^#z=tvRRE|a?@%r*|)ale0@Hr4t z>i9*1KZPGgoZ!k(xG)o?^_L6#97zaJ7t9JiTPD3;|LzkHSXjYrQUNId0000<MNUMn GLSTYUih}C^ literal 0 HcmV?d00001 diff --git a/Web/static/img/favicons/favicon24_playing.png b/Web/static/img/favicons/favicon24_playing.png new file mode 100644 index 0000000000000000000000000000000000000000..ce4478b8608a59911981069bb5e68e71bc423f32 GIT binary patch literal 1081 zcmV-91jhS`P)<h;3K|Lk000e1NJLTq000;O000;W1^@s6;CDUv00004XF*Lt006O` zVN5~y0000WV@Og>004&&004{<008|>004nL003F*009yY002DZ000@zy2&Ck0003Y zX+uL$Nkc;*P;zf(X>4Tx04UF6U|=$Eba8TJ5@2A+%_}Jia(7aQh>TKTKhMC%z{~&! ziOIzUjsXEaAa-7UUMd3y_;!tf5kz0s1)0S_8sJJUC@KNce}LF1CAB!2fq~Hg$j&Y= zDFBKy&H=JTlCr_<4Ip+=NRTs-eFVtnONX$pfY?b8_7f1h2<QnQ`wK{YI+)GG0X7H1 zmI1L#QY%3AGC1cKR2F5XXOt)yT3T8vIHu$$r78qh7MG;v7Att>CFd6v<QFBDq^1DX zLfi)if)ItE5Yqr^w9sUrG62N`g!MTP9KMWVXAoitkX%|+24ylb858Cs%$Os=z~H}> zfq~@@Ld>L>fnk9T0|Wn5gqWHk1A~A*1H-;K0Kmpi{cn=x1ONa432;bRa{vGi!~g&e z!~vBn4jTXf0)k0IK~zXftyaBm8bJ`AJ-E9H1(0}wB#k4PE>2^miJSt&8$?ko3Pg*9 zWD<l^<VT|5Igluj=+i`La)~Uc^8hr+kwfD%x4Zdf?`-k2B!zF_yW5}H`F3V@M<)h@ z0WFuy<7_tjk%*oFKJahRfQXwG34!lX6uryka^FO^+dW>bR$nmanduaS`5mI0tn0pT ze9!-+l=1*d3wV%J+Vg$?v!k>+(prNxy`N%x+K@eUl+wPI=txYY1@;64*tqX#B~54D z#rs0@8=ID}jSzv+0D~5sp1z@cA#a`A3xyCd5$rnrkYHm^Y$%t@)Hyq&mnSEbafDT8 z8<I&R+OX?LG!zfAOBS$ro=4Scl{%fbba3#*s<#Oy!N%bQlFTmc$GVhCC2BUC)MzxQ zSS(ug8SDjz>5hOKoD>^u!eQ(6dQ_=Y5Vp+jIvD4uFj4G*j7-LD*e9;*Qms~_^Y`a; zSU%ib$06bzfCxEAJkWexckw?FLO^e8J=-&pF{dbTDY9dqpnlP0GQkP!(8bS-jWuz| zV{hV9lyq#c$!s>miR#d3G@|S9dS{I`_F@uq4>%#WZf#>-7K;UOdGGi8G@s9{`iwZ_ z$we~*oCjvLfQ7>`9*?QjYEcjbR=o`{fobzaGrQ;NXUB9prFOeb!{Ly^FtqAyBMv#< zNVuri>-y^Ik14ZhiC`j}u0sC7ZHPG0>{dG95OEzcA%aUGyJD(eN{Fj7Y{IsKX8<IF z=>QM&M(aoB;p0cYWTak^P?;oz_ZJXAZlXxO7F->FUtT_UUGD=pk8t<>_=^Ne=8ql^ z@XnApGZIhTeYtR(djb=f<)aqhv!$EQ7ry=l-ND0C8%{=U00000NkvXXu0mjfv3k|u literal 0 HcmV?d00001 diff --git a/Web/static/img/play_buttons.gif b/Web/static/img/play_buttons.gif new file mode 100644 index 0000000000000000000000000000000000000000..b5a8078d03e13d2741b942f32fcb211317aa93d0 GIT binary patch literal 103 zcmZ?wbhEHb6kt$bC}3bv{K>+|z`(?y!vF*zvBrdig#Z8lgSbF(3z5E=%ii)o=5|zy z(mhxBd8g+>k4T5iWvfm`IB6ezy(X#s?SqQNi*0rVf3fRS)DYHTs@<|oTUCRB!5RSI CaUwJT literal 0 HcmV?d00001 diff --git a/Web/static/img/progressbar.gif b/Web/static/img/progressbar.gif new file mode 100644 index 0000000000000000000000000000000000000000..09d6437c6fa4670ea38a1bd904994acf7266c9d4 GIT binary patch literal 1018 zcmZ?wbhEHbjAP(u_{hjGYu2ozM~^;x_U!-v|BC;){aizWogD*Qjr0td85tND6o0a? zaxpM6=m6OaAT10`wLSeSPrv0~JZH<T?#=gnfAGsa(CB!QK5yy5s+W}vpM136O5M@l z*<X7i>u83{Y=afQ7EUlwJO9#Hpqo=xWQKLx<m*qjNzS;oD(dz3%D!bA(>|U#5nFxs z%cg6(&CRb1|NZM*-u}!y<2w5bwH0nVGJc4+vmY?@jLfX;oVjvmB+Nxj3_30h%nafT zoIn@JxAZSM_?DwlXkFWkX}LenoGqQi)}_oE_h|a8oSl=R8KJ&lX3#MQ>QMpfF=^>v zeDJMR%N&tNhPiwbstQ$4Un{vUA}4urhWd;o<=68z+H6?+$?Ln<<F9qbL2sSUyzHCD zgfK{pS_a8d%OC-&7zB)IX1GDRE&U4*y_K3HvVzIt{;4y|&L+w{l}zGDTRPKk`L2C= z+H1wvx-QoB-h244X<aWYaDXubG65PhJ^hOgyya*XTGvD%W)QZTOaaGC12AS7K`~Q6 cTFfAY17!vw#SCQzA*B_{3_?o1WEo@)0B0YPK>z>% literal 0 HcmV?d00001 diff --git a/Web/static/img/song.jpg b/Web/static/img/song.jpg new file mode 100644 index 0000000000000000000000000000000000000000..21e5a3a39ea15a318629c5d31996cc08d1791164 GIT binary patch literal 2481 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 IB_mJ&0k$w~`v3p{ literal 0 HcmV?d00001 diff --git a/Web/static/img/tour/audios.png b/Web/static/img/tour/audios.png new file mode 100644 index 0000000000000000000000000000000000000000..a15affe0511d972531cf450b2bb9ecac41e1a664 GIT binary patch literal 6234 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 ZH_lEBoxS7mG5hMetD`6T^7T6=egWw+lqvuK literal 0 HcmV?d00001 diff --git a/Web/static/img/tour/audios_playlists.png b/Web/static/img/tour/audios_playlists.png new file mode 100644 index 0000000000000000000000000000000000000000..a15affe0511d972531cf450b2bb9ecac41e1a664 GIT binary patch literal 6234 zcmeHLYi!$86uzO|)NSp?20|-H$YcnHhGRR4o!F$=N>XT{=}H^4ZbPtZ`#LpE>|ncX zIw4pKh#~f4ynY~FW!fa*2jVF<fmnh4KmuvfU=q^)p@BB33?!t1ka%3%vFoJWD3mvi z+NM7DobTN8yvO&U-kuv<mS4IY0MOFane79h;Z6WTr<xitW!0ML5AauGp*`0Qz^l)~ z?)LBSGa+^M<p6ka1ps?i0r2By0LC`~u=O+ut-;T!F9B%%=;LQvAHuvR)J&$gE0ZC5 z4P8--2!QoRr5!u&o&t^Co1T7ZOW(yO&+NZ&b?bqtd&b9CH@!zNhYn69J|piBUH$z? z%f7R(MH)`;di|aKlZ~DC0`ZZ3dx^s2Qv<t-?^;dU8;|r%ymITN>F{L3c=4jWKUhsC z8f=oZY5L?uc=E|TONej#`yPI5e0nKyUFc+D_nXf(Oliu+Z$Ez9w*1oJ73&^j8XB%h zzrTn7yt(<On_Jfo{6rk--nn+|C8ucmo6VWsFNTJfonq+e?OPt*cFV-{*zOZkyU4>} z?GMeLHa0hdm3OnHZ3nYwu$fDHyYn6R8-ETI4jsi`7g&8g{Xc^*-lA{9Ps_@kTTK8~ z9<hHzPi$Cu833#ItN8(IAlEHOdMN^BT||*escd6_)W%8~N<+vZL{w0<w(!?)9}W|$ z+!o%#=IC5Gg9g>kod)XP*^`%c4oSQm-k5G#o~j5Kp@b|*R7yq76e?|D2Ux)W+hi(C zI4sssTe#iUNDSn9iHvR_f{k#b#KfXRf{!pPA5F%uC89LLQFNSQc#??;_$N*i)lV3! zBZ!nCD?(rPhN?RJ)D|AJtg=8+qobpd(O5(`3Rs-yDLP6;qa@}a%`we_6;d<Txgb@T zEHWiSEnBLt5jHFo^<k?mjCbssQY!n{npyQ1_8e7#Wr~T=wrLKdEctxpVWa5mK$a*} zL?xtICgx>)-twSs>E@t*4rqV5MgewlF6Zl*b1$WmufnuCMz9%GuguA9=Eus2>O-bJ zY)GhM1aEVlXN_s~p&6&nLmadF!j`I>L)KoYF$z`GbFkVgE~^uAJN_x`0wH4{Xz4~? z*NbU4mwG*K61d=T?ywP-SieQpWPQ}UCPmc?*$GsOhO*E?=_nnIku*cnY@SI9Y(j|9 z8|auo(*X#Vvj!oj%c?Rq3xUUi0>cCm0-Uu7xI$!T!Fgc>d3_XF5)|DiK`X76U;$BO zt&pN-h(4d6SVBfG>IU8rO2<;vTx4IyNjsrSGcBk|s4JVsgovuj0wY5x!l+ELQAH*b z@i;?@VmwMFrI->$GEK)47<-m@9dK5chV25uz;4TsbqRydY>DQ$q>_LNnUt9*$uLO9 zYB)rqq?lki7BVyxJv9NaIyIXO6*n|k3~<<X$XFGJH!H`)7>O93CS@@$lf1%6Bovbh zuOME8yyPjE1Ex+<r)uKt9P@Lw+GYCDT>(;+SX7;kQ-+3#>{@IKd&TH=C^u6vyHm~r zl<YQ@mh2Kha*C><dK;-`x4Wj!j@$g~oh3de{OBOk&Q~}tio-Rhys0bJC^S%e0jI$H z%Grgi6%1mhD7%Xn)j!bPx;BE0c{&^i6XGKe8D?5B^b+A0Bw^_UESHO_1nnSFBbr=2 zP5fN<0vf>hb1gIB$nQ?0Bo~V@B1)18jzJ`k;z<(5C6-Jk6C9G+gs5=1E`MhlIo*@y zyzcooq>*29(je3dNKR9pF&^Qc8=AOC!$eYvk-W@_I3PGQJYpe9iwT<JnV5vQq-RZ_ zK?Du*&7W1_bqCx3Ff`Ia`oXWX0J}^0EyIh^U)B#~WT^A(2!TEM{ve!=_m8%4Asugi z1J6P_-psuCZ!9bK<PA<b-q=(>6xoA(E+=H&bi2VmC+$NU=hXQIAv^@(w+tcBvAj`q zz74NVe5T=5q4DhL4WqwpI&^my5T6CDFLVHPRm=h7sq&m1@TxxprR*0k0Z*@Z%yPz$ zG?Suc(R?IlWxyUri^I;u$#XRRP>lVzDi~a@72JA$!QgVO;Edd@nrfw^RZVqdU&ekz zbYEop@vQ|)b1WNUQ&c^<mcLi6pyEJQ#qS)ZJxeDO@v2Du${ccAXXN5Ze1tlx9J#=X zE9VwN;Bvu<g-gXZ7x)Rs%eywQpZu#Aq~ZG(17ECM^}w->04!<k%C_e#w}15s69S<5 zgUREEPapbl$>j$_q2t8!mtL8_@BTaY_O`5fsr`Dk^M$3$7Kw`vSxkn-Lin$DhNj}G ZH_lEBoxS7mG5hMetD`6T^7T6=egWw+lqvuK literal 0 HcmV?d00001 diff --git a/Web/static/img/tour/audios_search.png b/Web/static/img/tour/audios_search.png new file mode 100644 index 0000000000000000000000000000000000000000..a15affe0511d972531cf450b2bb9ecac41e1a664 GIT binary patch literal 6234 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 ZH_lEBoxS7mG5hMetD`6T^7T6=egWw+lqvuK literal 0 HcmV?d00001 diff --git a/Web/static/img/tour/audios_upload.png b/Web/static/img/tour/audios_upload.png new file mode 100644 index 0000000000000000000000000000000000000000..a15affe0511d972531cf450b2bb9ecac41e1a664 GIT binary patch literal 6234 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 ZH_lEBoxS7mG5hMetD`6T^7T6=egWw+lqvuK literal 0 HcmV?d00001 diff --git a/Web/static/js/al_music.js b/Web/static/js/al_music.js new file mode 100644 index 00000000..fbbe7c82 --- /dev/null +++ b/Web/static/js/al_music.js @@ -0,0 +1,1433 @@ +function fmtTime(time) { + const mins = String(Math.floor(time / 60)).padStart(2, '0'); + const secs = String(Math.floor(time % 60)).padStart(2, '0'); + return `${ mins}:${ secs}`; +} + +function fastError(message) { + MessageBox(tr("error"), message, [tr("ok")], [Function.noop]) +} + +// elapsed это вроде прошедшие, а оставшееся это remaining но ладно уже +function getElapsedTime(fullTime, time) { + let timer = fullTime - time + + if(timer < 0) return "-00:00" + + return "-" + fmtTime(timer) +} + +window.savedAudiosPages = {} + +class playersSearcher { + constructor(context_type, context_id) { + this.context_type = context_type + this.context_id = context_id + this.searchType = "by_name" + this.query = "" + this.page = 1 + this.successCallback = () => {} + this.errorCallback = () => {} + this.beforesendCallback = () => {} + this.clearContainer = () => {} + } + + execute() { + $.ajax({ + type: "POST", + url: "/audios/context", + data: { + context: this.context_type, + hash: u("meta[name=csrf]").attr("value"), + page: this.page, + query: this.query, + context_entity: this.context_id, + type: this.searchType, + returnPlayers: 1, + }, + beforeSend: () => { + this.beforesendCallback() + }, + error: () => { + this.errorCallback() + }, + success: (response) => { + this.successCallback(response, this) + } + }) + } + + movePage(page) { + this.page = page + this.execute() + } +} + +class bigPlayer { + tracks = { + currentTrack: null, + nextTrack: null, + previousTrack: null, + tracks: [] + } + + context = { + context_type: null, + context_id: 0, + pagesCount: 0, + playedPages: [], + object: [], + } + + nodes = { + dashPlayer: null, + audioPlayer: null, + thisPlayer: null, + playButtons: null, + } + + timeType = 0 + + findTrack(id) { + return this.tracks["tracks"].find(item => item.id == id) + } + + constructor(context, context_id, page = 1) { + this.context["context_type"] = context + this.context["context_id"] = context_id + this.context["playedPages"].push(String(page)) + + this.nodes["thisPlayer"] = document.querySelector(".bigPlayer") + this.nodes["thisPlayer"].classList.add("lagged") + this.nodes["audioPlayer"] = document.createElement("audio") + + this.player = () => { return this.nodes["audioPlayer"] } + this.nodes["playButtons"] = this.nodes["thisPlayer"].querySelector(".playButtons") + this.nodes["dashPlayer"] = dashjs.MediaPlayer().create() + + let formdata = new FormData() + formdata.append("context", context) + formdata.append("context_entity", context_id) + formdata.append("query", context_id) + formdata.append("hash", u("meta[name=csrf]").attr("value")) + formdata.append("page", page) + + ky.post("/audios/context", { + hooks: { + afterResponse: [ + async (_request, _options, response) => { + if(response.status !== 200) { + fastError(tr("unable_to_load_queue")) + return + } + + let contextObject = await response.json() + + if(!contextObject.success) { + fastError(tr("unable_to_load_queue")) + return + } + + this.nodes["thisPlayer"].classList.remove("lagged") + this.tracks["tracks"] = contextObject["items"] + this.context["pagesCount"] = contextObject["pagesCount"] + + if(localStorage.lastPlayedTrack != null && this.tracks["tracks"].find(item => item.id == localStorage.lastPlayedTrack) != null) { + this.setTrack(localStorage.lastPlayedTrack) + this.pause() + } + + console.info("Context is successfully loaded") + } + ] + }, + body: formdata + }) + + u(this.nodes["playButtons"].querySelector(".playButton")).on("click", (e) => { + if(this.player().paused) + this.play() + else + this.pause() + }) + + u(this.player()).on("timeupdate", (e) => { + const time = this.player().currentTime; + const ps = ((time * 100) / this.tracks["currentTrack"].length).toFixed(3) + this.nodes["thisPlayer"].querySelector(".time").innerHTML = fmtTime(time) + this.timeType == 0 ? this.nodes["thisPlayer"].querySelector(".elapsedTime").innerHTML = getElapsedTime(this.tracks["currentTrack"].length, time) + : null + + if (ps <= 100) + this.nodes["thisPlayer"].querySelector(".selectableTrack .slider").style.left = `${ ps}%`; + + }) + + u(this.player()).on("volumechange", (e) => { + const volume = this.player().volume; + const ps = Math.ceil((volume * 100) / 1); + + if (ps <= 100) + this.nodes["thisPlayer"].querySelector(".volumePanel .selectableTrack .slider").style.left = `${ ps}%`; + + localStorage.volume = volume + }) + + u(".bigPlayer .track > div").on("click mouseup", (e) => { + if(this.tracks["currentTrack"] == null) + return + + let rect = this.nodes["thisPlayer"].querySelector(".selectableTrack").getBoundingClientRect(); + + const width = e.clientX - rect.left; + const time = Math.ceil((width * this.tracks["currentTrack"].length) / (rect.right - rect.left)); + + this.player().currentTime = time; + }) + + u(".bigPlayer .trackPanel .selectableTrack").on("mousemove", (e) => { + if(this.tracks["currentTrack"] == null) + return + + let rect = this.nodes["thisPlayer"].querySelector(".selectableTrack").getBoundingClientRect(); + + const width = e.clientX - rect.left; + const time = Math.ceil((width * this.tracks["currentTrack"].length) / (rect.right - rect.left)); + + document.querySelector(".bigPlayer .track .bigPlayerTip").style.display = "block" + document.querySelector(".bigPlayer .track .bigPlayerTip").innerHTML = fmtTime(time) + document.querySelector(".bigPlayer .track .bigPlayerTip").style.left = `min(${width - 15}px, 315.5px)` + }) + + u(".bigPlayer .nextButton").on("mouseover mouseleave", (e) => { + if(this.tracks["currentTrack"] == null) + return + + if(e.type == "mouseleave") { + $(".nextTrackTip").remove() + return + } + + e.currentTarget.parentNode.insertAdjacentHTML("afterbegin", ` + <div class="bigPlayerTip nextTrackTip" style="left: 5%;"> + ${ovk_proc_strtr(escapeHtml(this.findTrack(this.tracks["previousTrack"]).name), 20) ?? ""} + </div> + `) + + document.querySelector(".nextTrackTip").style.display = "block" + }) + + u(".bigPlayer .backButton").on("mouseover mouseleave", (e) => { + if(this.tracks["currentTrack"] == null) + return + + if(e.type == "mouseleave") { + $(".previousTrackTip").remove() + return + } + + e.currentTarget.parentNode.insertAdjacentHTML("afterbegin", ` + <div class="bigPlayerTip previousTrackTip" style="left: 8%;"> + ${ovk_proc_strtr(escapeHtml(this.findTrack(this.tracks["nextTrack"]).name), 20) ?? ""} + </div> + `) + + document.querySelector(".previousTrackTip").style.display = "block" + }) + + u(".bigPlayer .trackPanel .selectableTrack").on("mouseleave", (e) => { + if(this.tracks["currentTrack"] == null) + return + + document.querySelector(".bigPlayer .track .bigPlayerTip").style.display = "none" + }) + + u(".bigPlayer .volumePanel > div").on("click mouseup mousemove", (e) => { + if(this.tracks["currentTrack"] == null) + return + + if(e.type == "mousemove") { + let buttonsPresseed = _bsdnUnwrapBitMask(e.buttons) + if(!buttonsPresseed[0]) + return; + } + + let rect = this.nodes["thisPlayer"].querySelector(".volumePanel .selectableTrack").getBoundingClientRect(); + + const width = e.clientX - rect.left; + const volume = Math.max(0, (width * 1) / (rect.right - rect.left)); + + this.player().volume = volume; + }) + + u(".bigPlayer .elapsedTime").on("click", (e) => { + if(this.tracks["currentTrack"] == null) + return + + this.timeType == 0 ? (this.timeType = 1) : (this.timeType = 0) + + localStorage.playerTimeType = this.timeType + + this.nodes["thisPlayer"].querySelector(".elapsedTime").innerHTML = this.timeType == 1 ? + fmtTime(this.tracks["currentTrack"].length) + : getElapsedTime(this.tracks["currentTrack"].length, this.player().currentTime) + }) + + u(".bigPlayer .additionalButtons .repeatButton").on("click", (e) => { + if(this.tracks["currentTrack"] == null) + return + + e.currentTarget.classList.toggle("pressed") + + if(e.currentTarget.classList.contains("pressed")) + this.player().loop = true + else + this.player().loop = false + }) + + u(".bigPlayer .additionalButtons .shuffleButton").on("click", (e) => { + if(this.tracks["currentTrack"] == null) + return + + this.tracks["tracks"].sort(() => Math.random() - 0.59) + this.setTrack(this.tracks["tracks"].at(0).id) + }) + + // хз что она делала в самом вк, но тут сделаем вид что это просто мут музыки + u(".bigPlayer .additionalButtons .deviceButton").on("click", (e) => { + if(this.tracks["currentTrack"] == null) + return + + e.currentTarget.classList.toggle("pressed") + + this.player().muted = e.currentTarget.classList.contains("pressed") + }) + + u(".bigPlayer .arrowsButtons .nextButton").on("click", (e) => { + this.showPreviousTrack() + }) + + u(".bigPlayer .arrowsButtons .backButton").on("click", (e) => { + this.showNextTrack() + }) + + u(".bigPlayer .trackInfo b").on("click", (e) => { + window.location.assign(`/search?query=${e.currentTarget.innerHTML}&type=audios&only_performers=on`) + }) + + u(document).on("keydown", (e) => { + if(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", " "].includes(e.key)) { + if(document.querySelector(".ovk-diag-cont") != null) + return + + e.preventDefault() + } + + switch(e.key) { + case "ArrowUp": + this.player().volume = Math.min(0.99, this.player().volume + 0.1) + break + case "ArrowDown": + this.player().volume = Math.max(0, this.player().volume - 0.1) + break + case "ArrowLeft": + this.player().currentTime = this.player().currentTime - 3 + break + case "ArrowRight": + this.player().currentTime = this.player().currentTime + 3 + break + // буквально + case " ": + if(this.player().paused) + this.play() + else + this.pause() + + break; + } + }) + + u(document).on("keyup", (e) => { + if([87, 65, 83, 68, 82, 77].includes(e.keyCode)) { + if(document.querySelector(".ovk-diag-cont") != null) + return + + e.preventDefault() + } + + switch(e.keyCode) { + case 87: + case 65: + this.showPreviousTrack() + break + case 83: + case 68: + this.showNextTrack() + break + case 82: + document.querySelector(".bigPlayer .additionalButtons .repeatButton").click() + break + case 77: + document.querySelector(".bigPlayer .additionalButtons .deviceButton").click() + break + } + }) + + u(this.player()).on("ended", (e) => { + e.preventDefault() + + // в начало очереди + if(!this.tracks.nextTrack) { + if(!this.context["playedPages"].includes("1")) { + $.ajax({ + type: "POST", + url: "/audios/context", + data: { + context: this["context"].context_type, + context_entity: this["context"].context_id, + hash: u("meta[name=csrf]").attr("value"), + page: 1 + }, + success: (response_2) => { + this.tracks["tracks"] = response_2["items"].concat(this.tracks["tracks"]) + this.context["playedPages"].push(String(1)) + + this.setTrack(this.tracks["tracks"][0].id) + } + }) + } else { + this.setTrack(this.tracks.tracks[0].id) + } + + return + } + + this.showNextTrack() + }) + + u(this.player()).on("loadstart", (e) => { + let playlist = this.context.context_type == "playlist_context" ? this.context.context_id : null + + let tempThisId = this.tracks.currentTrack.id + setTimeout(() => { + if(tempThisId != this.tracks.currentTrack.id) return + + $.ajax({ + type: "POST", + url: `/audio${this.tracks["currentTrack"].id}/listen`, + data: { + hash: u("meta[name=csrf]").attr("value"), + playlist: playlist + }, + success: (response) => { + if(response.success) { + console.info("Listen is counted.") + + if(response.new_playlists_listens) + document.querySelector("#listensCount").innerHTML = tr("listens_count", response.new_playlists_listens) + } else + console.info("Listen is not counted.") + } + }) + }, 2000) + }) + + if(localStorage.volume != null && localStorage.volume < 1 && localStorage.volume > 0) + this.player().volume = localStorage.volume + else + this.player().volume = 0.75 + + if(localStorage.playerTimeType == 'null' || localStorage.playerTimeType == null) + this.timeType = 0 + else + this.timeType = localStorage.playerTimeType + + navigator.mediaSession.setActionHandler('play', () => { this.play() }); + navigator.mediaSession.setActionHandler('pause', () => { this.pause() }); + navigator.mediaSession.setActionHandler('previoustrack', () => { this.showPreviousTrack() }); + navigator.mediaSession.setActionHandler('nexttrack', () => { this.showNextTrack() }); + navigator.mediaSession.setActionHandler("seekto", (details) => { + this.player().currentTime = details.seekTime; + }); + } + + play() { + if(this.tracks["currentTrack"] == null) + return + + document.querySelectorAll('audio').forEach(el => el.pause()); + document.querySelector(`.audioEmbed[data-realid='${this.tracks["currentTrack"].id}'] .audioEntry .playerButton .playIcon`) != null ? document.querySelector(`.audioEmbed[data-realid='${this.tracks["currentTrack"].id}'] .audioEntry .playerButton .playIcon`).classList.add("paused") : void(0) + this.player().play() + this.nodes["playButtons"].querySelector(".playButton").classList.add("pause") + document.querySelector('link[rel="icon"], link[rel="shortcut icon"]').setAttribute("href", "/assets/packages/static/openvk/img/favicons/favicon24_paused.png") + + navigator.mediaSession.playbackState = "playing" + } + + pause() { + if(this.tracks["currentTrack"] == null) + return + + document.querySelector(`.audioEmbed[data-realid='${this.tracks["currentTrack"].id}'] .audioEntry .playerButton .playIcon`) != null ? document.querySelector(`.audioEmbed[data-realid='${this.tracks["currentTrack"].id}'] .audioEntry .playerButton .playIcon`).classList.remove("paused") : void(0) + this.player().pause() + this.nodes["playButtons"].querySelector(".playButton").classList.remove("pause") + document.querySelector('link[rel="icon"], link[rel="shortcut icon"]').setAttribute("href", "/assets/packages/static/openvk/img/favicons/favicon24_playing.png") + + navigator.mediaSession.playbackState = "paused" + } + + showPreviousTrack() { + if(this.tracks["currentTrack"] == null || this.tracks["previousTrack"] == null) + return + + this.setTrack(this.tracks["previousTrack"]) + } + + showNextTrack() { + if(this.tracks["currentTrack"] == null || this.tracks["nextTrack"] == null) + return + + this.setTrack(this.tracks["nextTrack"]) + } + + updateButtons() { + // перепутал некст и бек. + let prevButton = this.nodes["thisPlayer"].querySelector(".nextButton") + let nextButton = this.nodes["thisPlayer"].querySelector(".backButton") + + if(this.tracks["previousTrack"] == null) + prevButton.classList.add("lagged") + else + prevButton.classList.remove("lagged") + + if(this.tracks["nextTrack"] == null) + nextButton.classList.add("lagged") + else + nextButton.classList.remove("lagged") + + if(document.querySelector(".nextTrackTip") != null) { + let track = this.findTrack(this.tracks["previousTrack"]) + document.querySelector(".nextTrackTip").innerHTML = ` + ${track != null ? ovk_proc_strtr(escapeHtml(track.name), 20) : ""} + ` + } + + if(document.querySelector(".previousTrackTip") != null) { + let track = this.findTrack(this.tracks["nextTrack"]) + document.querySelector(".previousTrackTip").innerHTML = ` + ${track != null ? ovk_proc_strtr(escapeHtml(track.name ?? ""), 20) : ""} + ` + } + } + + setTrack(id) { + if(this.tracks["tracks"] == null) { + console.info("Context is not loaded yet. Wait please") + return 0; + } + + document.querySelectorAll(".audioEntry.nowPlaying").forEach(el => el.classList.remove("nowPlaying")) + let obj = this.tracks["tracks"].find(item => item.id == id) + + if(obj == null) { + fastError("No audio in context") + return + } + + this.nodes["thisPlayer"].querySelector(".trackInfo span").innerHTML = escapeHtml(obj.name) + this.nodes["thisPlayer"].querySelector(".trackInfo b").innerHTML = escapeHtml(obj.performer) + this.nodes["thisPlayer"].querySelector(".trackInfo .time").innerHTML = fmtTime(obj.length) + this.tracks["currentTrack"] = obj + + let indexOfCurrentTrack = this.tracks["tracks"].indexOf(obj) ?? 0 + this.tracks["nextTrack"] = this.tracks["tracks"].at(indexOfCurrentTrack + 1) != null ? this.tracks["tracks"].at(indexOfCurrentTrack + 1).id : null + + if(indexOfCurrentTrack - 1 >= 0) + this.tracks["previousTrack"] = this.tracks["tracks"].at(indexOfCurrentTrack - 1).id + else + this.tracks["previousTrack"] = null + + if(this.tracks["nextTrack"] == null && Math.max(...this.context["playedPages"]) < this.context["pagesCount"] + || this.tracks["previousTrack"] == null && (Math.min(...this.context["playedPages"]) > 1)) { + + // idk how it works + let lesser = this.tracks["previousTrack"] == null ? (Math.min(...this.context["playedPages"]) > 1) + : Math.max(...this.context["playedPages"]) > this.context["pagesCount"] + + let formdata = new FormData() + formdata.append("context", this.context["context_type"]) + formdata.append("context_entity", this.context["context_id"]) + formdata.append("hash", u("meta[name=csrf]").attr("value")) + + if(lesser) + formdata.append("page", Math.min(...this.context["playedPages"]) - 1) + else + formdata.append("page", Number(Math.max(...this.context["playedPages"])) + 1) + + ky.post("/audios/context", { + hooks: { + afterResponse: [ + async (_request, _options, response) => { + let newArr = await response.json() + + if(lesser) + this.tracks["tracks"] = newArr["items"].concat(this.tracks["tracks"]) + else + this.tracks["tracks"] = this.tracks["tracks"].concat(newArr["items"]) + + this.context["playedPages"].push(String(newArr["page"])) + + if(lesser) + this.tracks["previousTrack"] = this.tracks["tracks"].at(this.tracks["tracks"].indexOf(obj) - 1).id + else + this.tracks["nextTrack"] = this.tracks["tracks"].at(indexOfCurrentTrack + 1) != null ? this.tracks["tracks"].at(indexOfCurrentTrack + 1).id : null + + this.updateButtons() + console.info("Context is successfully loaded") + } + ] + }, + body: formdata + }) + } + + if(this.tracks["currentTrack"].available == false || this.tracks["currentTrack"].withdrawn) + this.showNextTrack() + + this.updateButtons() + + const protData = { + "org.w3.clearkey": { + "clearkeys": obj.keys + } + }; + + this.nodes["dashPlayer"].initialize(this.player(), obj.url, false); + this.nodes["dashPlayer"].setProtectionData(protData); + + this.play() + + let playerAtPage = document.querySelector(`.audioEmbed[data-realid='${this.tracks["currentTrack"].id}'] .audioEntry`) + if(playerAtPage != null) + playerAtPage.classList.add("nowPlaying") + + document.querySelectorAll(`.audioEntry .playerButton .playIcon.paused`).forEach(el => el.classList.remove("paused")) + + localStorage.lastPlayedTrack = this.tracks["currentTrack"].id + + if(this.timeType == 1) + this.nodes["thisPlayer"].querySelector(".elapsedTime").innerHTML = fmtTime(this.tracks["currentTrack"].length) + + let album = document.querySelector(".playlistBlock") + + navigator.mediaSession.metadata = new MediaMetadata({ + title: obj.name, + artist: obj.performer, + album: album == null ? "OpenVK Audios" : album.querySelector(".playlistInfo h4").innerHTML, + artwork: [{ src: album == null ? "/assets/packages/static/openvk/img/song.jpg" : album.querySelector(".playlistCover img").src }], + }); + + navigator.mediaSession.setPositionState({ + duration: this.tracks["currentTrack"].length + }) + } +} + +document.addEventListener("DOMContentLoaded", function() { + if(document.querySelector(".bigPlayer") != null) { + let context = document.querySelector("input[name='bigplayer_context']") + + if(!context) + return + + let type = context.dataset.type + let entity = context.dataset.entity + window.player = new bigPlayer(type, entity, context.dataset.page) + + let bigplayer = document.querySelector('.bigPlayerDetector') + + let bigPlayerObserver = new IntersectionObserver(entries => { + entries.forEach(x => { + if(x.isIntersecting) { + document.querySelector('.bigPlayer').classList.remove("floating") + document.querySelector('.searchOptions .searchList').classList.remove("floating") + document.querySelector('.bigPlayerDetector').style.marginTop = "0px" + } else { + document.querySelector('.searchOptions .searchList').classList.add("floating") + document.querySelector('.bigPlayer').classList.add("floating") + document.querySelector('.bigPlayerDetector').style.marginTop = "46px" + } + }); + }, { + root: null, + rootMargin: "0px", + threshold: 0 + }); + + if(bigplayer != null) + bigPlayerObserver.observe(bigplayer); + } + + $(`.audioEntry .mediaInfo`).on("mouseover mouseleave", (e) => { + const info = e.currentTarget.closest(".mediaInfo") + const overfl = info.querySelector(".info") + + if(e.originalEvent.type == "mouseleave" || e.originalEvent.type == "mouseout") { + info.classList.add("noOverflow") + info.classList.remove("overflowedName") + } else { + info.classList.remove("noOverflow") + info.classList.add("overflowedName") + } + }) +}) + +$(document).on("click", ".audioEmbed > *", (e) => { + const player = e.currentTarget.closest(".audioEmbed") + + if(player.classList.contains("inited")) return + + initPlayer(player.id.replace("audioEmbed-", ""), + JSON.parse(player.dataset.keys), + player.dataset.url, + player.dataset.length) + + if(e.target.classList.contains("playIcon")) + e.target.click() +}) + +function initPlayer(id, keys, url, length) { + document.querySelector(`#audioEmbed-${ id}`).classList.add("inited") + const audio = document.querySelector(`#audioEmbed-${ id} .audio`); + const playButton = u(`#audioEmbed-${ id} .playerButton > .playIcon`); + const trackDiv = u(`#audioEmbed-${ id} .track > div > div`); + const volumeSpan = u(`#audioEmbed-${ id} .volume span`); + const rect = document.querySelector(`#audioEmbed-${ id} .selectableTrack`).getBoundingClientRect(); + + const playerObject = document.querySelector(`#audioEmbed-${ id}`) + + if(document.querySelector(".bigPlayer") != null) { + playButton.on("click", () => { + if(window.player.tracks["tracks"] == null) + return + + if(window.player.tracks["currentTrack"] == null || window.player.tracks["currentTrack"].id != playerObject.dataset.realid) + window.player.setTrack(playerObject.dataset.realid) + else + document.querySelector(".bigPlayer .playButton").click() + }) + + return + } + + const protData = { + "org.w3.clearkey": { + "clearkeys": keys + } + }; + + const player = dashjs.MediaPlayer().create(); + player.initialize(audio, url, false); + player.setProtectionData(protData); + + playButton.on("click", () => { + if (audio.paused) { + document.querySelectorAll('audio').forEach(el => el.pause()); + audio.play(); + } else { + audio.pause(); + } + }); + + u(audio).on("timeupdate", () => { + const time = audio.currentTime; + const ps = ((time * 100) / length).toFixed(3); + volumeSpan.html(fmtTime(Math.floor(time))); + + if (ps <= 100) + playerObject.querySelector(".lengthTrack .slider").style.left = `${ ps}%`; + }); + + u(audio).on("volumechange", (e) => { + const volume = audio.volume; + const ps = Math.ceil((volume * 100) / 1); + + if (ps <= 100) + playerObject.querySelector(".volumeTrack .slider").style.left = `${ ps}%`; + }) + + const playButtonImageUpdate = () => { + if (!audio.paused) { + playButton.addClass("paused") + document.querySelector('link[rel="icon"], link[rel="shortcut icon"]').setAttribute("href", "/assets/packages/static/openvk/img/favicons/favicon24_paused.png") + } else { + playButton.removeClass("paused") + document.querySelector('link[rel="icon"], link[rel="shortcut icon"]').setAttribute("href", "/assets/packages/static/openvk/img/favicons/favicon24_playing.png") + } + + if(!$(`#audioEmbed-${ id}`).hasClass("havePlayed")) { + $(`#audioEmbed-${ id}`).addClass("havePlayed") + + $(`#audioEmbed-${ id} .track`).toggle() + + $.post(`/audio${playerObject.dataset.realid}/listen`, { + hash: u("meta[name=csrf]").attr("value") + }); + } + }; + + const hideTracks = () => { + $(`#audioEmbed-${ id} .track`).toggle() + $(`#audioEmbed-${ id}`).removeClass("havePlayed") + } + + u(audio).on("play", playButtonImageUpdate); + u(audio).on(["pause", "suspended"], playButtonImageUpdate); + u(audio).on("ended", (e) => { + let thisPlayer = e.target.closest(".audioEmbed") + let nextPlayer = null + if(thisPlayer.closest(".attachment") != null) { + try { + nextPlayer = thisPlayer.closest(".attachment").nextElementSibling.querySelector(".audioEmbed") + } catch(e) {return} + } else if(thisPlayer.closest(".audio") != null) { + try { + nextPlayer = thisPlayer.closest(".audio").nextElementSibling.querySelector(".audioEmbed") + } catch(e) {return} + } else { + nextPlayer = thisPlayer.nextElementSibling + } + + playButtonImageUpdate() + + if(!nextPlayer) return + + initPlayer(nextPlayer.id.replace("audioEmbed-", ""), + JSON.parse(nextPlayer.dataset.keys), + nextPlayer.dataset.url, + nextPlayer.dataset.length) + + nextPlayer.querySelector(".playIcon").click() + hideTracks() + }) + + u(`#audioEmbed-${ id} .lengthTrack > div`).on("click", (e) => { + let rect = document.querySelector("#audioEmbed-" + id + " .selectableTrack").getBoundingClientRect(); + const width = e.clientX - rect.left; + const time = Math.ceil((width * length) / (rect.right - rect.left)); + + audio.currentTime = time; + }); + + u(`#audioEmbed-${ id} .volumeTrack > div`).on("click mouseup mousemove", (e) => { + if(e.type == "mousemove") { + let buttonsPresseed = _bsdnUnwrapBitMask(e.buttons) + if(!buttonsPresseed[0]) + return; + } + + let rect = document.querySelector("#audioEmbed-" + id + " .volumeTrack").getBoundingClientRect(); + + const width = e.clientX - rect.left; + const volume = (width * 1) / (rect.right - rect.left); + + audio.volume = volume; + }); + + audio.volume = localStorage.volume ?? 0.75 + u(audio).trigger("volumechange") +} + +$(document).on("click", ".musicIcon.edit-icon", (e) => { + let player = e.currentTarget.closest(".audioEmbed") + let id = Number(player.dataset.realid) + let performer = e.currentTarget.dataset.performer + let name = e.currentTarget.dataset.title + let genre = player.dataset.genre + let lyrics = e.currentTarget.dataset.lyrics + + MessageBox(tr("edit_audio"), ` + <div> + ${tr("performer")} + <input name="performer" maxlength="256" type="text" value="${performer}"> + </div> + + <div style="margin-top: 11px"> + ${tr("audio_name")} + <input name="name" maxlength="256" type="text" value="${name}"> + </div> + + <div style="margin-top: 11px"> + ${tr("genre")} + <select name="genre"></select> + </div> + + <div style="margin-top: 11px"> + ${tr("lyrics")} + <textarea name="lyrics" maxlength="5000" style="max-height: 200px;">${lyrics ?? ""}</textarea> + </div> + + <div style="margin-top: 11px"> + <label><input type="checkbox" name="explicit" ${e.currentTarget.dataset.explicit == 1 ? "checked" : ""}>${tr("audios_explicit")}</label><br> + <label><input type="checkbox" name="searchable" ${e.currentTarget.dataset.searchable == 1 ? "checked" : ""}>${tr("searchable")}</label> + <hr> + <a id="_fullyDeleteAudio">${tr("fully_delete_audio")}</a> + </div> + `, [tr("save"), tr("cancel")], [ + function() { + let t_name = $(".ovk-diag-body input[name=name]").val(); + let t_perf = $(".ovk-diag-body input[name=performer]").val(); + let t_genre = $(".ovk-diag-body select[name=genre]").val(); + let t_lyrics = $(".ovk-diag-body textarea[name=lyrics]").val(); + let t_explicit = document.querySelector(".ovk-diag-body input[name=explicit]").checked; + let t_unlisted = document.querySelector(".ovk-diag-body input[name=searchable]").checked; + + $.ajax({ + type: "POST", + url: `/audio${id}/action?act=edit`, + data: { + name: t_name, + performer: t_perf, + genre: t_genre, + lyrics: t_lyrics, + unlisted: Number(t_unlisted), + explicit: Number(t_explicit), + hash: u("meta[name=csrf]").attr("value") + }, + success: (response) => { + if(response.success) { + let perf = player.querySelector(".performer a") + perf.innerHTML = escapeHtml(response.new_info.performer) + perf.setAttribute("href", "/search?query=&type=audios&sort=id&only_performers=on&query="+response.new_info.performer) + + e.target.setAttribute("data-performer", escapeHtml(response.new_info.performer)) + + let name = player.querySelector(".title") + name.innerHTML = escapeHtml(response.new_info.name) + + e.target.setAttribute("data-title", escapeHtml(response.new_info.name)) + + if(response.new_info.lyrics_unformatted != "") { + if(player.querySelector(".lyrics") != null) { + player.querySelector(".lyrics").innerHTML = response.new_info.lyrics + player.querySelector(".title").classList.add("withLyrics") + } else { + player.insertAdjacentHTML("beforeend", ` + <div class="lyrics"> + ${response.new_info.lyrics} + </div> + `) + + player.querySelector(".title").classList.add("withLyrics") + } + } else { + $(player.querySelector(".lyrics")).remove() + player.querySelector(".title").classList.remove("withLyrics") + } + + e.target.setAttribute("data-lyrics", response.new_info.lyrics_unformatted) + e.target.setAttribute("data-explicit", Number(response.new_info.explicit)) + + if(Number(response.new_info.explicit) == 1) { + if(!player.querySelector(".mediaInfo .explicitMark")) + player.querySelector(".mediaInfo").insertAdjacentHTML("beforeend", ` + <div class="explicitMark"></div> + `) + } else { + $(player.querySelector(".mediaInfo .explicitMark")).remove() + } + + e.target.setAttribute("data-searchable", Number(!response.new_info.unlisted)) + player.setAttribute("data-genre", response.new_info.genre) + + let url = new URL(location.href) + let page = "1" + + if(url.searchParams.p != null) + page = String(url.searchParams.p) + + window.savedAudiosPages[page] = null + } else + fastError(response.flash.message) + } + }); + }, + + Function.noop + ]); + + window.openvk.audio_genres.forEach(elGenre => { + document.querySelector(".ovk-diag-body select[name=genre]").insertAdjacentHTML("beforeend", ` + <option value="${elGenre}" ${elGenre == genre ? "selected" : ""}>${elGenre}</option> + `) + }) + + u(".ovk-diag-body #_fullyDeleteAudio").on("click", (e) => { + u("body").removeClass("dimmed"); + document.querySelector("html").style.overflowY = "scroll" + + u(".ovk-diag-cont").remove(); + + $.ajax({ + type: "POST", + url: `/audio${id}/action?act=delete`, + data: { + hash: u("meta[name=csrf]").attr("value") + }, + success: (response) => { + if(response.success) + u(player).remove() + else + fastError(response.flash.message) + } + }); + }) +}) + +$(document).on("click", ".title.withLyrics", (e) => { + let parent = e.currentTarget.closest(".audioEmbed") + + parent.querySelector(".lyrics").classList.toggle("showed") +}) + +$(document).on("click", ".musicIcon.remove-icon", (e) => { + e.stopImmediatePropagation() + + let id = e.currentTarget.dataset.id + + let formdata = new FormData() + formdata.append("hash", u("meta[name=csrf]").attr("value")) + + ky.post(`/audio${id}/action?act=remove`, { + hooks: { + beforeRequest: [ + (_request) => { + e.target.classList.add("lagged") + } + ], + afterResponse: [ + async (_request, _options, response) => { + let json = await response.json() + + if(json.success) { + e.target.classList.remove("remove-icon") + e.target.classList.add("add-icon") + e.target.classList.remove("lagged") + + let withd = e.target.closest(".audioEmbed.withdrawn") + + if(withd != null) + u(withd).remove() + } else + fastError(json.flash.message) + } + ] + }, body: formdata + }) +}) + +$(document).on("click", ".musicIcon.remove-icon-group", (e) => { + e.stopImmediatePropagation() + + let id = e.currentTarget.dataset.id + + let formdata = new FormData() + formdata.append("hash", u("meta[name=csrf]").attr("value")) + formdata.append("club", e.currentTarget.dataset.club) + + ky.post(`/audio${id}/action?act=remove_club`, { + hooks: { + beforeRequest: [ + (_request) => { + e.currentTarget.classList.add("lagged") + } + ], + afterResponse: [ + async (_request, _options, response) => { + let json = await response.json() + + if(json.success) + $(e.currentTarget.closest(".audioEmbed")).remove() + else + fastError(json.flash.message) + } + ] + }, body: formdata + }) +}) + +$(document).on("click", ".musicIcon.add-icon-group", async (ev) => { + let body = ` + ${tr("what_club_add")} + <div style="margin-top: 4px;"> + <select id="addIconsWindow" style="width: 59%;"></select> + <input name="addButton" type="button" class="button" value="${tr("add")}"> + </div> + <span class="errorPlace"></span> + ` + MessageBox(tr("add_audio_to_club"), body, [tr("close")], [Function.noop]) + + document.querySelector(".ovk-diag-body").style.padding = "11px" + + if(window.openvk.writeableClubs == null) { + try { + window.openvk.writeableClubs = await API.Groups.getWriteableClubs() + } catch (e) { + document.querySelector(".errorPlace").innerHTML = tr("no_access_clubs") + document.querySelector(".ovk-diag-body input[name='addButton']").classList.add("lagged") + + return + } + } + + window.openvk.writeableClubs.forEach(el => { + document.querySelector("#addIconsWindow").insertAdjacentHTML("beforeend", ` + <option value="${el.id}">${ovk_proc_strtr(el.name, 20)}</option> + `) + }) + + $(".ovk-diag-body").on("click", "input[name='addButton']", (e) => { + $.ajax({ + type: "POST", + url: `/audio${ev.target.dataset.id}/action?act=add_to_club`, + data: { + hash: u("meta[name=csrf]").attr("value"), + club: document.querySelector("#addIconsWindow").value + }, + beforeSend: () => { + e.target.classList.add("lagged") + document.querySelector(".errorPlace").innerHTML = "" + }, + success: (response) => { + if(!response.success) + document.querySelector(".errorPlace").innerHTML = response.flash.message + + e.currentTarget.classList.remove("lagged") + } + }) + }) +}) + +$(document).on("click", ".musicIcon.add-icon", (e) => { + let id = e.currentTarget.dataset.id + + let formdata = new FormData() + formdata.append("hash", u("meta[name=csrf]").attr("value")) + + ky.post(`/audio${id}/action?act=add`, { + hooks: { + beforeRequest: [ + (_request) => { + e.target.classList.add("lagged") + } + ], + afterResponse: [ + async (_request, _options, response) => { + let json = await response.json() + + if(json.success) { + e.target.classList.remove("add-icon") + e.target.classList.add("remove-icon") + e.target.classList.remove("lagged") + } else + fastError(json.flash.message) + } + ] + }, body: formdata + }) +}) + +$(document).on("click", "#_deletePlaylist", (e) => { + let id = e.currentTarget.dataset.id + + MessageBox(tr("warning"), tr("sure_delete_playlist"), [tr("yes"), tr("no")], [() => { + $.ajax({ + type: "POST", + url: `/playlist${id}/action?act=delete`, + data: { + hash: u("meta[name=csrf]").attr("value"), + }, + beforeSend: () => { + e.currentTarget.classList.add("lagged") + }, + success: (response) => { + if(response.success) { + window.location.assign("/playlists" + response.id) + } else { + fastError(response.flash.message) + } + } + }) + }, Function.noop]) +}) + +$(document).on("click", "#_audioAttachment", (e) => { + let form = e.currentTarget.closest("form") + let body = ` + <div class="searchBox"> + <input name="query" type="text" maxlength="50" placeholder="${tr("header_search")}"> + <select name="perf"> + <option value="by_name">${tr("by_name")}</option> + <option value="by_performer">${tr("by_performer")}</option> + </select> + </div> + + <div class="audiosInsert"></div> + ` + MessageBox(tr("select_audio"), body, [tr("ok")], [Function.noop]) + + document.querySelector(".ovk-diag-body").style.padding = "0" + document.querySelector(".ovk-diag-cont").style.width = "580px" + document.querySelector(".ovk-diag-body").style.height = "335px" + + let searcher = new playersSearcher("entity_audios", 0) + searcher.successCallback = (response, thisc) => { + let domparser = new DOMParser() + let result = domparser.parseFromString(response, "text/html") + + let pagesCount = result.querySelector("input[name='pagesCount']").value + let count = Number(result.querySelector("input[name='count']").value) + + if(count < 1) { + document.querySelector(".audiosInsert").innerHTML = thisc.context_type == "entity_audios" ? tr("no_audios_thisuser") : tr("no_results") + return + } + + result.querySelectorAll(".audioEmbed").forEach(el => { + let id = el.dataset.prettyid + let name = el.dataset.name + let isAttached = (form.querySelector("input[name='audios']").value.includes(`${id},`)) + document.querySelector(".audiosInsert").insertAdjacentHTML("beforeend", ` + <div style="display: table;width: 100%;clear: both;"> + <div style="width: 72%;float: left;">${el.outerHTML}</div> + <div class="attachAudio" data-attachmentdata="${id}" data-name="${name}"> + <span>${isAttached ? tr("detach_audio") : tr("attach_audio")}</span> + </div> + </div> + `) + }) + + u("#loader").remove() + + if(thisc.page < pagesCount) { + document.querySelector(".audiosInsert").insertAdjacentHTML("beforeend", ` + <div id="showMoreAudios" data-pagesCount="${pagesCount}" data-page="${thisc.page + 1}" class="showMore"> + <span>${tr("show_more_audios")}</span> + </div>`) + } + } + + searcher.errorCallback = () => { + fastError("Error when loading players.") + } + + searcher.beforesendCallback = () => { + document.querySelector(".audiosInsert").insertAdjacentHTML("beforeend", `<img id="loader" src="/assets/packages/static/openvk/img/loading_mini.gif">`) + } + + searcher.clearContainer = () => { + document.querySelector(".audiosInsert").innerHTML = "" + } + + searcher.movePage(1) + + $(".audiosInsert").on("click", "#showMoreAudios", (e) => { + u(e.currentTarget).remove() + searcher.movePage(Number(e.currentTarget.dataset.page)) + }) + + $(".searchBox input").on("change", async (e) => { + await new Promise(r => setTimeout(r, 500)); + + if(e.currentTarget.value === document.querySelector(".searchBox input").value) { + searcher.clearContainer() + + if(e.currentTarget.value == "") { + searcher.context_type = "entity_audios" + searcher.context_id = 0 + searcher.query = "" + + searcher.movePage(1) + + return + } + + searcher.context_type = "search_context" + searcher.context_id = 0 + searcher.query = e.currentTarget.value + + searcher.movePage(1) + return; + } + }) + + $(".searchBox select").on("change", async (e) => { + searcher.clearContainer() + searcher.searchType = e.currentTarget.value + + $(".searchBox input").trigger("change") + return; + }) + + function insertAttachment(id) { + let audios = form.querySelector("input[name='audios']") + + if(!audios.value.includes(id + ",")) { + if(audios.value.split(",").length > 10) { + NewNotification(tr("error"), tr("max_attached_audios")) + return false + } + + form.querySelector("input[name='audios']").value += (id + ",") + + return true + } else { + form.querySelector("input[name='audios']").value = form.querySelector("input[name='audios']").value.replace(id + ",", "") + + return false + } + } + + $(".audiosInsert").on("click", ".attachAudio", (ev) => { + if(!insertAttachment(ev.currentTarget.dataset.attachmentdata)) { + u(`.post-has-audios .post-has-audio[data-id='${ev.currentTarget.dataset.attachmentdata}']`).remove() + ev.currentTarget.querySelector("span").innerHTML = tr("attach_audio") + } else { + ev.currentTarget.querySelector("span").innerHTML = tr("detach_audio") + + form.querySelector(".post-has-audios").insertAdjacentHTML("beforeend", ` + <div class="post-has-audio" id="unattachAudio" data-id="${ev.currentTarget.dataset.attachmentdata}"> + <span>${ovk_proc_strtr(escapeHtml(ev.currentTarget.dataset.name), 40)}</span> + </div> + `) + + u(`#unattachAudio[data-id='${ev.currentTarget.dataset.attachmentdata}']`).on("click", (e) => { + let id = ev.currentTarget.dataset.attachmentdata + form.querySelector("input[name='audios']").value = form.querySelector("input[name='audios']").value.replace(id + ",", "") + + u(e.currentTarget).remove() + }) + } + }) +}) + +$(document).on("click", ".audioEmbed.processed", (e) => { + MessageBox(tr("error"), tr("audio_embed_processing"), [tr("ok")], [Function.noop]) +}) + +$(document).on("click", ".audioEmbed.withdrawn", (e) => { + MessageBox(tr("error"), tr("audio_embed_withdrawn"), [tr("ok")], [Function.noop]) +}) + +$(document).on("click", ".musicIcon.report-icon", (e) => { + MessageBox(tr("report_question"), ` + ${tr("going_to_report_audio")} + <br/>${tr("report_question_text")} + <br/><br/><b> ${tr("report_reason")}</b>: <input type='text' id='uReportMsgInput' placeholder='${tr("reason")}' />`, [tr("confirm_m"), tr("cancel")], [(function() { + + res = document.querySelector("#uReportMsgInput").value; + xhr = new XMLHttpRequest(); + xhr.open("GET", "/report/" + e.target.dataset.id + "?reason=" + res + "&type=audio", true); + xhr.onload = (function() { + if(xhr.responseText.indexOf("reason") === -1) + MessageBox(tr("error"), tr("error_sending_report"), ["OK"], [Function.noop]); + else + MessageBox(tr("action_successfully"), tr("will_be_watched"), ["OK"], [Function.noop]); + }); + xhr.send(null) + }), + + Function.noop]) +}) + +$(document).on("click", ".audiosContainer .paginator a", (e) => { + e.preventDefault() + let url = new URL(e.currentTarget.href) + let page = url.searchParams.get("p") + + function searchNode(id) { + let node = document.querySelector(`.audioEmbed[data-realid='${id}'] .audioEntry`) + + if(node != null) { + node.classList.add("nowPlaying") + } + } + + if(window.savedAudiosPages[page] != null) { + history.pushState({}, "", e.currentTarget.href) + document.querySelector(".audiosContainer").innerHTML = window.savedAudiosPages[page].innerHTML + searchNode(window.player["tracks"].currentTrack != null ? window.player["tracks"].currentTrack.id : 0) + + return + } + + e.currentTarget.parentNode.classList.add("lagged") + $.ajax({ + type: "GET", + url: e.currentTarget.href, + success: (response) => { + let domparser = new DOMParser() + let result = domparser.parseFromString(response, "text/html") + + document.querySelector(".audiosContainer").innerHTML = result.querySelector(".audiosContainer").innerHTML + history.pushState({}, "", e.currentTarget.href) + window.savedAudiosPages[page] = result.querySelector(".audiosContainer") + searchNode(window.player["tracks"].currentTrack != null ? window.player["tracks"].currentTrack.id : 0) + + if(!window.player.context["playedPages"].includes(page)) { + $.ajax({ + type: "POST", + url: "/audios/context", + data: { + context: window.player["context"].context_type, + context_entity: window.player["context"].context_id, + hash: u("meta[name=csrf]").attr("value"), + page: page + }, + success: (response_2) => { + window.player.tracks["tracks"] = window.player.tracks["tracks"].concat(response_2["items"]) + window.player.context["playedPages"].push(String(page)) + console.info("Page is switched") + } + }) + } + } + }) +}) + +$(document).on("click", ".addToPlaylist", (e) => { + let audios = document.querySelector("input[name='audios']") + let id = e.currentTarget.dataset.id + + if(!audios.value.includes(id + ",")) { + document.querySelector("input[name='audios']").value += (id + ",") + e.currentTarget.querySelector("span").innerHTML = tr("remove_from_playlist") + } else { + document.querySelector("input[name='audios']").value = document.querySelector("input[name='audios']").value.replace(id + ",", "") + e.currentTarget.querySelector("span").innerHTML = tr("add_to_playlist") + } +}) + +$(document).on("click", "#bookmarkPlaylist, #unbookmarkPlaylist", (e) => { + let target = e.currentTarget + let id = target.id + + $.ajax({ + type: "POST", + url: `/playlist${e.currentTarget.dataset.id}/action?act=${id == "unbookmarkPlaylist" ? "unbookmark" : "bookmark"}`, + data: { + hash: u("meta[name=csrf]").attr("value"), + }, + beforeSend: () => { + e.currentTarget.classList.add("lagged") + }, + success: (response) => { + if(response.success) { + e.currentTarget.setAttribute("id", id == "unbookmarkPlaylist" ? "bookmarkPlaylist" : "unbookmarkPlaylist") + e.currentTarget.innerHTML = id == "unbookmarkPlaylist" ? tr("bookmark") : tr("unbookmark") + e.currentTarget.classList.remove("lagged") + } else + fastError(response.flash.message) + } + }) +}) diff --git a/Web/static/js/al_playlists.js b/Web/static/js/al_playlists.js new file mode 100644 index 00000000..cf61c45d --- /dev/null +++ b/Web/static/js/al_playlists.js @@ -0,0 +1,113 @@ +let context_type = "entity_audios" +let context_id = 0 + +if(document.querySelector("#editPlaylistForm")) { + context_type = "playlist_context" + context_id = document.querySelector("#editPlaylistForm").dataset.id +} + +if(document.querySelector(".showMoreAudiosPlaylist") && document.querySelector(".showMoreAudiosPlaylist").dataset.club != null) { + context_type = "entity_audios" + context_id = Number(document.querySelector(".showMoreAudiosPlaylist").dataset.club) * -1 +} + +let searcher = new playersSearcher(context_type, context_id) + +searcher.successCallback = (response, thisc) => { + let domparser = new DOMParser() + let result = domparser.parseFromString(response, "text/html") + let pagesCount = Number(result.querySelector("input[name='pagesCount']").value) + let count = Number(result.querySelector("input[name='count']").value) + + result.querySelectorAll(".audioEmbed").forEach(el => { + let id = Number(el.dataset.realid) + let isAttached = (document.querySelector("input[name='audios']").value.includes(`${id},`)) + + document.querySelector(".playlistAudiosContainer").insertAdjacentHTML("beforeend", ` + <div id="newPlaylistAudios"> + <div class="playerContainer"> + ${el.outerHTML} + </div> + <div class="attachAudio addToPlaylist" data-id="${id}"> + <span>${isAttached ? tr("remove_from_playlist") : tr("add_to_playlist")}</span> + </div> + </div> + `) + }) + + if(count < 1) + document.querySelector(".playlistAudiosContainer").insertAdjacentHTML("beforeend", ` + ${tr("no_results")} + `) + + if(Number(thisc.page) >= pagesCount) + u(".showMoreAudiosPlaylist").remove() + else { + if(document.querySelector(".showMoreAudiosPlaylist") != null) { + document.querySelector(".showMoreAudiosPlaylist").setAttribute("data-page", thisc.page + 1) + + if(thisc.query != "") { + document.querySelector(".showMoreAudiosPlaylist").setAttribute("data-query", thisc.query) + } + + document.querySelector(".showMoreAudiosPlaylist").style.display = "block" + } else { + document.querySelector(".playlistAudiosContainer").parentNode.insertAdjacentHTML("beforeend", ` + <div class="showMoreAudiosPlaylist" data-page="2" + ${thisc.query != "" ? `"data-query="${thisc.query}"` : ""} + ${thisc.context_type == "entity_audios" ? `"data-playlist="${thisc.context_id}"` : ""} + ${thisc.context_id < 0 ? `"data-club="${thisc.context_id}"` : ""}> + ${tr("show_more_audios")} + </div> + `) + } + } + + u("#loader").remove() +} + +searcher.beforesendCallback = () => { + document.querySelector(".playlistAudiosContainer").parentNode.insertAdjacentHTML("beforeend", `<img id="loader" src="/assets/packages/static/openvk/img/loading_mini.gif">`) + + if(document.querySelector(".showMoreAudiosPlaylist") != null) + document.querySelector(".showMoreAudiosPlaylist").style.display = "none" +} + +searcher.errorCallback = () => { + fastError("Error when loading players") +} + +searcher.clearContainer = () => { + document.querySelector(".playlistAudiosContainer").innerHTML = "" +} + +$(document).on("click", ".showMoreAudiosPlaylist", (e) => { + searcher.movePage(Number(e.currentTarget.dataset.page)) +}) + +$(document).on("change", "input#playlist_query", async (e) => { + e.preventDefault() + + await new Promise(r => setTimeout(r, 500)); + + if(e.currentTarget.value === document.querySelector("input#playlist_query").value) { + searcher.clearContainer() + + if(e.currentTarget.value == "") { + searcher.context_type = "entity_audios" + searcher.context_id = 0 + searcher.query = "" + + searcher.movePage(1) + + return + } + + searcher.context_type = "search_context" + searcher.context_id = 0 + searcher.query = e.currentTarget.value + + searcher.movePage(1) + return; + } +}) diff --git a/Web/static/js/al_wall.js b/Web/static/js/al_wall.js index 39ee2199..5acba57f 100644 --- a/Web/static/js/al_wall.js +++ b/Web/static/js/al_wall.js @@ -155,18 +155,6 @@ function setupWallPostInputHandlers(id) { return; } }); - - u("#wall-post-input" + id).on("input", function(e) { - var boost = 5; - var textArea = e.target; - textArea.style.height = "5px"; - var newHeight = textArea.scrollHeight; - textArea.style.height = newHeight + boost + "px"; - return; - - // revert to original size if it is larger (possibly changed by user) - // textArea.style.height = (newHeight > originalHeight ? (newHeight + boost) : originalHeight) + "px"; - }); u("#wall-post-input" + id).on("dragover", function(e) { e.preventDefault() @@ -182,6 +170,18 @@ function setupWallPostInputHandlers(id) { }); } +u(document).on("input", "textarea", function(e) { + var boost = 5; + var textArea = e.target; + textArea.style.height = "5px"; + var newHeight = textArea.scrollHeight; + textArea.style.height = newHeight + boost + "px"; + return; + + // revert to original size if it is larger (possibly changed by user) + // textArea.style.height = (newHeight > originalHeight ? (newHeight + boost) : originalHeight) + "px"; +}); + function OpenMiniature(e, photo, post, photo_id, type = "post") { /* костыли но смешные однако diff --git a/Web/static/js/package.json b/Web/static/js/package.json index f9459ce4..f5c37008 100644 --- a/Web/static/js/package.json +++ b/Web/static/js/package.json @@ -2,6 +2,8 @@ "dependencies": { "@atlassian/aui": "^9.6.0", "create-react-class": "^15.7.0", + "dashjs": "^4.3.0", + "id3js": "^2.1.1", "handlebars": "^4.7.7", "jquery": "^3.0.0", "knockout": "^3.5.1", diff --git a/Web/static/js/yarn.lock b/Web/static/js/yarn.lock index fa61ed9b..289fc81e 100644 --- a/Web/static/js/yarn.lock +++ b/Web/static/js/yarn.lock @@ -41,6 +41,11 @@ backbone@1.4.1: dependencies: underscore ">=1.8.3" +codem-isoboxer@0.3.6: + version "0.3.6" + resolved "https://registry.yarnpkg.com/codem-isoboxer/-/codem-isoboxer-0.3.6.tgz#867f670459b881d44f39168d5ff2a8f14c16151d" + integrity sha512-LuO8/7LW6XuR5ERn1yavXAfodGRhuY2yP60JTZIw5yNYMCE5lUVbk3NFUCJxjnphQH+Xemp5hOGb1LgUXm00Xw== + core-js@^1.0.0: version "1.2.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" @@ -64,6 +69,18 @@ dompurify@2.4.5: resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.5.tgz#0e89a27601f0bad978f9a924e7a05d5d2cccdd87" integrity sha512-jggCCd+8Iqp4Tsz0nIvpcb22InKEBrGz5dw3EQJMs8HPJDsKbFIO3STYtAvCfDx26Muevn1MHVI0XxjgFfmiSA== +dashjs@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/dashjs/-/dashjs-4.3.0.tgz#cccda5a490cabf6c3b48aa887ec8c8ac0df1a233" + integrity sha512-cqpnJaPQpEY4DsEdF9prwD00+5dp5EGHCFc7yo9n2uuAH9k4zPkZJwXQ8dXmVRhPf3M89JfKSoAYIP3dbXmqcg== + dependencies: + codem-isoboxer "0.3.6" + es6-promise "^4.2.8" + fast-deep-equal "2.0.1" + html-entities "^1.2.1" + imsc "^1.0.2" + localforage "^1.7.1" + encoding@^0.1.11: version "0.1.13" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" @@ -71,6 +88,11 @@ encoding@^0.1.11: dependencies: iconv-lite "^0.6.2" +es6-promise@^4.2.8: + version "4.2.8" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" + integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== + event-lite@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/event-lite/-/event-lite-0.1.2.tgz#838a3e0fdddef8cc90f128006c8e55a4e4e4c11b" @@ -81,6 +103,11 @@ fancy-file-input@2.0.4: resolved "https://registry.yarnpkg.com/fancy-file-input/-/fancy-file-input-2.0.4.tgz#698c216482e07649a827681c4db3054fddc9a32b" integrity sha512-l+J0WwDl4nM/zMJ/C8qleYnXMUJKsLng7c5uWH/miAiHoTvPDtEoLW1tmVO6Cy2O8i/1VfA+2YOwg/Q3+kgO6w== +fast-deep-equal@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" + integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= + fbjs@^0.8.0: version "0.8.17" resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" @@ -94,6 +121,10 @@ fbjs@^0.8.0: setimmediate "^1.0.5" ua-parser-js "^0.7.18" +html-entities@^1.2.1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.4.0.tgz#cfbd1b01d2afaf9adca1b10ae7dffab98c71d2dc" + integrity sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA== handlebars@^4.7.7: version "4.7.7" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" @@ -113,11 +144,28 @@ iconv-lite@^0.6.2: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +id3js@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/id3js/-/id3js-2.1.1.tgz#0c307d0d2f194bc5fa7a809bbed0b1a93577f16d" + integrity sha512-9Gi+sG0RHSa5qn8hkwi2KCl+2jV8YrtiZidXbOO3uLfRAxc2jilRg0fiQ3CbeoAmR7G7ap3RVs1kqUVhIyZaog== + ieee754@^1.1.8: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= + +imsc@^1.0.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/imsc/-/imsc-1.1.3.tgz#e96a60a50d4000dd7b44097272768b9fd6a4891d" + integrity sha512-IY0hMkVTNoqoYwKEp5UvNNKp/A5jeJUOrIO7judgOyhHT+xC6PA4VBOMAOhdtAYbMRHx9DTgI8p6Z6jhYQPFDA== + dependencies: + sax "1.2.1" + int64-buffer@^0.1.9: version "0.1.10" resolved "https://registry.yarnpkg.com/int64-buffer/-/int64-buffer-0.1.10.tgz#277b228a87d95ad777d07c13832022406a473423" @@ -173,6 +221,13 @@ ky@^0.19.0: resolved "https://registry.yarnpkg.com/ky/-/ky-0.19.0.tgz#d6ad117e89efe2d85a1c2e91462d48ca1cda1f7a" integrity sha512-RkDgbg5ahMv1MjHfJI2WJA2+Qbxq0iNSLWhreYiCHeHry9Q12sedCnP5KYGPt7sydDvsyH+8UcG6Kanq5mpsyw== +lie@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e" + integrity sha1-mkNrLMd0bKWd56QfpGmz77dr2H4= + dependencies: + immediate "~3.0.5" + literallycanvas@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/literallycanvas/-/literallycanvas-0.5.2.tgz#7d4800a8d9c4b38a593e91695d52466689586abd" @@ -180,6 +235,13 @@ literallycanvas@^0.5.2: dependencies: react-addons-pure-render-mixin "^15.1" +localforage@^1.7.1: + version "1.10.0" + resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4" + integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg== + dependencies: + lie "3.1.1" + loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -273,6 +335,11 @@ requirejs@^2.3.6: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +sax@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" + integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o= + setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" diff --git a/bootstrap.php b/bootstrap.php index faa798f6..4b67a0b7 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -64,6 +64,33 @@ function ovk_proc_strtr(string $string, int $length = 0): string return $newString . ($string !== $newString ? "…" : ""); #if cut hasn't happened, don't append "..." } +function knuth_shuffle(iterable $arr, int $seed): array +{ + $data = is_array($arr) ? $arr : iterator_to_array($arr); + $retVal = []; + $ind = []; + $count = sizeof($data); + + srand($seed, MT_RAND_PHP); + + for($i = 0; $i < $count; ++$i) + $ind[$i] = 0; + + for($i = 0; $i < $count; ++$i) { + do { + $index = rand() % $count; + } while($ind[$index] != 0); + + $ind[$index] = 1; + $retVal[$i] = $data[$index]; + } + + # Reseed + srand(hexdec(bin2hex(openssl_random_pseudo_bytes(4)))); + + return $retVal; +} + function bmask(int $input, array $options = []): Bitmask { return new Bitmask($input, $options["length"] ?? 1, $options["mappings"] ?? []); diff --git a/composer.json b/composer.json index c68dd4d1..a248197a 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "vearutop/php-obscene-censor-rus": "dev-master", "erusev/parsedown": "dev-master", "bhaktaraz/php-rss-generator": "dev-master", + "ext-openssl": "*", "ext-simplexml": "*", "symfony/console": "5.4.x-dev", "wapmorgan/morphos": "dev-master", diff --git a/install/init-static-db.sql b/install/init-static-db.sql index 78b5e64a..7678587e 100644 --- a/install/init-static-db.sql +++ b/install/init-static-db.sql @@ -54,27 +54,6 @@ CREATE TABLE `attachments` ( `index` bigint(20) UNSIGNED NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; -CREATE TABLE `audios` ( - `id` bigint(20) UNSIGNED NOT NULL, - `owner` bigint(20) UNSIGNED NOT NULL, - `virtual_id` bigint(20) UNSIGNED NOT NULL, - `created` bigint(20) UNSIGNED NOT NULL, - `edited` bigint(20) UNSIGNED DEFAULT NULL, - `hash` char(128) COLLATE utf8mb4_unicode_520_ci NOT NULL, - `deleted` tinyint(4) DEFAULT 0, - `name` varchar(190) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '(no name)', - `performer` varchar(190) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT 'Unknown', - `genre` varchar(190) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT 'K-POP', - `lyrics` longtext COLLATE utf8mb4_unicode_520_ci DEFAULT NULL, - `explicit` tinyint(4) NOT NULL DEFAULT 0 -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; - -CREATE TABLE `audio_relations` ( - `user` bigint(20) UNSIGNED NOT NULL, - `audio` bigint(20) UNSIGNED NOT NULL, - `index` bigint(20) UNSIGNED NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; - CREATE TABLE `comments` ( `id` bigint(20) UNSIGNED NOT NULL, `owner` bigint(20) NOT NULL, diff --git a/install/sqls/00041-music.sql b/install/sqls/00041-music.sql new file mode 100644 index 00000000..49edbaf0 --- /dev/null +++ b/install/sqls/00041-music.sql @@ -0,0 +1,100 @@ +-- Apply these two commands if you installed OpenVK before 12th November 2023 OR if it's just doesn't work out of box, then apply this file again +-- DROP TABLE `audios`; +-- DROP TABLE `audio_relations`; + +CREATE TABLE IF NOT EXISTS `audios` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `owner` bigint unsigned NOT NULL, + `virtual_id` bigint unsigned NOT NULL, + `created` bigint unsigned NOT NULL, + `edited` bigint unsigned DEFAULT NULL, + `hash` char(128) NOT NULL, + `length` smallint unsigned NOT NULL, + `segment_size` decimal(20,6) NOT NULL DEFAULT '6.000000' COMMENT 'Size in seconds of each segment', + `kid` binary(16) NOT NULL, + `key` binary(16) NOT NULL, + `token` binary(28) NOT NULL COMMENT 'Key to access original file', + `listens` bigint unsigned NOT NULL DEFAULT '0', + `performer` varchar(256) NOT NULL, + `name` varchar(256) NOT NULL, + `lyrics` text, + `genre` enum('Blues','Big Band','Classic Rock','Chorus','Country','Easy Listening','Dance','Acoustic','Disco','Humour','Funk','Speech','Grunge','Chanson','Hip-Hop','Opera','Jazz','Chamber Music','Metal','Sonata','New Age','Symphony','Oldies','Booty Bass','Other','Primus','Pop','Porn Groove','R&B','Satire','Rap','Slow Jam','Reggae','Club','Rock','Tango','Techno','Samba','Industrial','Folklore','Alternative','Ballad','Ska','Power Ballad','Death Metal','Rhythmic Soul','Pranks','Freestyle','Soundtrack','Duet','Euro-Techno','Punk Rock','Ambient','Drum Solo','Trip-Hop','A Cappella','Vocal','Euro-House','Jazz+Funk','Dance Hall','Fusion','Goa','Trance','Drum & Bass','Classical','Club-House','Instrumental','Hardcore','Acid','Terror','House','Indie','Game','BritPop','Sound Clip','Negerpunk','Gospel','Polsk Punk','Noise','Beat','AlternRock','Christian Gangsta Rap','Bass','Heavy Metal','Soul','Black Metal','Punk','Crossover','Space','Contemporary Christian','Meditative','Christian Rock','Instrumental Pop','Merengue','Instrumental Rock','Salsa','Ethnic','Thrash Metal','Gothic','Anime','Darkwave','JPop','Techno-Industrial','Synthpop','Electronic','Abstract','Pop-Folk','Art Rock','Eurodance','Baroque','Dream','Bhangra','Southern Rock','Big Beat','Comedy','Breakbeat','Cult','Chillout','Gangsta Rap','Downtempo','Top 40','Dub','Christian Rap','EBM','Pop / Funk','Eclectic','Jungle','Electro','Native American','Electroclash','Cabaret','Emo','New Wave','Experimental','Psychedelic','Garage','Rave','Global','Showtunes','IDM','Trailer','Illbient','Lo-Fi','Industro-Goth','Tribal','Jam Band','Acid Punk','Krautrock','Acid Jazz','Leftfield','Polka','Lounge','Retro','Math Rock','Musical','New Romantic','Rock & Roll','Nu-Breakz','Hard Rock','Post-Punk','Folk','Post-Rock','Folk-Rock','Psytrance','National Folk','Shoegaze','Swing','Space Rock','Fast Fusion','Trop Rock','Bebob','World Music','Latin','Neoclassical','Revival','Audiobook','Celtic','Audio Theatre','Bluegrass','Neue Deutsche Welle','Avantgarde','Podcast','Gothic Rock','Indie Rock','Progressive Rock','G-Funk','Psychedelic Rock','Dubstep','Symphonic Rock','Garage Rock','Slow Rock','Psybient','Psychobilly','Touhou') DEFAULT NULL, + `explicit` tinyint(1) NOT NULL DEFAULT '0', + `withdrawn` tinyint(1) NOT NULL DEFAULT '0', + `processed` tinyint unsigned NOT NULL DEFAULT '0', + `checked` bigint NOT NULL DEFAULT '0' COMMENT 'Last time the audio availability was checked', + `unlisted` tinyint(1) NOT NULL DEFAULT '0', + `deleted` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `owner_virtual_id` (`owner`,`virtual_id`), + KEY `genre` (`genre`), + KEY `unlisted` (`unlisted`), + KEY `listens` (`listens`), + KEY `deleted` (`deleted`), + KEY `length` (`length`), + KEY `listens_genre` (`listens`,`genre`), + FULLTEXT KEY `performer_name` (`performer`,`name`), + FULLTEXT KEY `lyrics` (`lyrics`), + FULLTEXT KEY `performer` (`performer`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + +CREATE TABLE IF NOT EXISTS `audio_listens` ( + `entity` bigint NOT NULL, + `audio` bigint unsigned NOT NULL, + `time` bigint unsigned NOT NULL, + `index` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'Workaround for Nette DBE bug', + `playlist` bigint(20) UNSIGNED DEFAULT NULL, + PRIMARY KEY (`index`), + KEY `audio` (`audio`), + KEY `user` (`entity`) USING BTREE, + KEY `user_time` (`entity`,`time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + +CREATE TABLE IF NOT EXISTS `audio_relations` ( + `entity` bigint NOT NULL, + `audio` bigint unsigned NOT NULL, + `index` bigint unsigned NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`index`), + KEY `user` (`entity`) USING BTREE, + KEY `entity_audio` (`entity`,`audio`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + +CREATE TABLE IF NOT EXISTS `playlists` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `owner` bigint NOT NULL, + `name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL, + `description` varchar(2048) DEFAULT NULL, + `cover_photo_id` bigint unsigned DEFAULT NULL, + `length` int unsigned NOT NULL DEFAULT '0', + `special_type` tinyint unsigned NOT NULL DEFAULT '0', + `created` bigint unsigned DEFAULT NULL, + `listens` bigint(20) unsigned NOT NULL DEFAULT 0, + `edited` bigint unsigned DEFAULT NULL, + `deleted` tinyint unsigned DEFAULT '0', + PRIMARY KEY (`id`), + KEY `owner_deleted` (`owner`,`deleted`), + FULLTEXT KEY `title_description` (`name`,`description`), + FULLTEXT KEY `title` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + +CREATE TABLE IF NOT EXISTS `playlist_imports` ( + `entity` bigint NOT NULL, + `playlist` bigint unsigned NOT NULL, + `index` bigint unsigned NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`index`) USING BTREE, + KEY `user` (`entity`) USING BTREE, + KEY `entity_audio` (`entity`,`playlist`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + +CREATE TABLE IF NOT EXISTS `playlist_relations` ( + `collection` bigint unsigned NOT NULL, + `media` bigint unsigned NOT NULL, + `index` bigint unsigned NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`index`) USING BTREE, + KEY `playlist` (`collection`) USING BTREE, + KEY `audio` (`media`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + +ALTER TABLE `groups` ADD `everyone_can_upload_audios` TINYINT(1) NOT NULL DEFAULT '0' AFTER `backdrop_2`; +ALTER TABLE `profiles` ADD `last_played_track` BIGINT(20) UNSIGNED NULL DEFAULT NULL AFTER `client_name`, ADD `audio_broadcast_enabled` TINYINT(1) UNSIGNED NOT NULL DEFAULT '0' AFTER `last_played_track`; +ALTER TABLE `groups` ADD `last_played_track` BIGINT(20) UNSIGNED NULL DEFAULT NULL AFTER `everyone_can_upload_audios`, ADD `audio_broadcast_enabled` TINYINT(1) UNSIGNED NOT NULL DEFAULT '0' AFTER `last_played_track`; diff --git a/locales/en.strings b/locales/en.strings index b1d211d1..7fbe255e 100644 --- a/locales/en.strings +++ b/locales/en.strings @@ -479,6 +479,7 @@ "my_videos" = "My Videos"; "my_messages" = "My Messages"; "my_notes" = "My Notes"; +"my_audios" = "My Audios"; "my_groups" = "My Groups"; "my_feed" = "My Feed"; "my_feedback" = "My Feedback"; @@ -568,6 +569,7 @@ "privacy_setting_add_to_friends" = "Who can add me to friends"; "privacy_setting_write_wall" = "Who can publish post on my wall"; "privacy_setting_write_messages" = "Who can write messages to me"; +"privacy_setting_view_audio" = "Who can see my audios"; "privacy_value_anybody" = "Anybody"; "privacy_value_anybody_dative" = "Anybody"; "privacy_value_users" = "OpenVK users"; @@ -713,12 +715,133 @@ "upload_new_video" = "Upload new video"; "max_attached_videos" = "Max is 10 videos"; "max_attached_photos" = "Max is 10 photos"; +"max_attached_audios" = "Max is 10 audios"; "no_videos" = "You don't have uploaded videos."; "no_videos_results" = "No results."; "change_video" = "Change video"; "unknown_video" = "This video is not supported in your version of OpenVK."; + +/* Audios */ + +"audios" = "Audios"; +"audio" = "Audio"; +"playlist" = "Playlist"; +"upload_audio" = "Upload audio"; +"upload_audio_to_group" = "Upload audio to group"; + +"performer" = "Performer"; +"audio_name" = "Name"; +"genre" = "Genre"; +"lyrics" = "Lyrics"; + +"select_another_file" = "Select another file"; + +"limits" = "Limits"; +"select_audio" = "Select audio from your computer"; +"audio_requirements" = "Audio must be between $1s to $2 minutes, weights to $3MB and contain audio stream."; +"audio_requirements_2" = "Audio must not infringe copyright and related rights"; +"you_can_also_add_audio_using" = "You can also add audio from among the files you have already downloaded using"; +"search_audio_inst" = "audios search"; + +"audio_embed_not_found" = "Audio not found"; +"audio_embed_deleted" = "Audio was deleted"; +"audio_embed_withdrawn" = "The audio was withdrawn at the request of the copyright holder"; +"audio_embed_forbidden" = "The user's privacy settings do not allow this embed this audio"; +"audio_embed_processing" = "Audio is still being processed, or has not been processed correctly."; + +"audios_count_zero" = "No audios"; +"audios_count_one" = "One audio"; +"audios_count_few" = "$1 audios"; +"audios_count_many" = "$1 audios"; +"audios_count_other" = "$1 audios"; + +"track_unknown" = "Unknown"; +"track_noname" = "Without name"; + +"my_music" = "My music"; +"music_user" = "User's music"; +"music_club" = "Club's music"; +"audio_new" = "New"; +"audio_popular" = "Popular"; +"audio_search" = "Search"; + +"my_audios_small" = "My audios"; +"my_playlists" = "My playlists"; +"playlists" = "Playlists"; +"audios_explicit" = "Contains obscene language"; +"withdrawn" = "Withdrawn"; +"deleted" = "Deleted"; +"owner" = "Owner"; +"searchable" = "Searchable"; + +"select_audio" = "Select audios"; +"no_playlists_thisuser" = "You haven't added any playlists yet."; +"no_playlists_user" = "This user has not added any playlists yet."; +"no_playlists_club" = "This group hasn't added playlists yet."; + +"no_audios_thisuser" = "You haven't added any audios yet."; +"no_audios_user" = "This user has not added any audios yet."; +"no_audios_club" = "This group has not added any audios yet."; + +"new_playlist" = "New playlist"; +"created_playlist" = "created"; +"updated_playlist" = "updated"; +"bookmark" = "Add to collection"; +"unbookmark" = "Remove from collection"; +"empty_playlist" = "There are no audios in this playlist."; +"edit_playlist" = "Edit playlist"; +"unable_to_load_queue" = "Error when loading queue."; + +"fully_delete_audio" = "Fully delete audio"; +"attach_audio" = "Attach audio"; +"detach_audio" = "Detach audio"; + +"show_more_audios" = "Show more audios"; +"add_to_playlist" = "Add to playlist"; +"remove_from_playlist" = "Remove from playlist"; +"delete_playlist" = "Delete playlist"; +"playlist_cover" = "Playlist cover"; + +"playlists_user" = "Users playlists"; +"playlists_club" = "Groups playlists"; +"change_cover" = "Change cover"; +"playlist_cover" = "Playlist's cover"; + +"minutes_count_zero" = "lasts no minutes"; +"minutes_count_one" = "lasts one minute"; +"minutes_count_few" = "lasts $1 minutes"; +"minutes_count_many" = "lasts $1 minutes"; +"minutes_count_other" = "lasts $1 minutes"; + +"listens_count_zero" = "no listens"; +"listens_count_one" = "one listen"; +"listens_count_few" = "$1 listens"; +"listens_count_many" = "$1 listens"; +"listens_count_other" = "$1 listens"; + +"add_audio_to_club" = "Add audio to group"; +"what_club_add" = "Which group do you want to add the song to?"; +"group_has_audio" = "This group already has this song."; +"group_hasnt_audio" = "This group doesn't have this song."; + +"by_name" = "by name"; +"by_performer" = "by performer"; +"no_access_clubs" = "There are no groups where you are an administrator."; +"audio_successfully_uploaded" = "Audio has been successfully uploaded and is currently being processed."; + +"broadcast_audio" = "Broadcast audio to status"; +"sure_delete_playlist" = "Do you sure want to delete this playlist?"; +"edit_audio" = "Edit audio"; +"audios_group" = "Audios from group"; +"playlists_group" = "Playlists from group"; + +"play_tip" = "Play/pause"; +"repeat_tip" = "Repeat"; +"shuffle_tip" = "Shuffle"; +"mute_tip" = "Mute"; + /* Notifications */ "feedback" = "Feedback"; @@ -983,6 +1106,7 @@ "going_to_report_photo" = "You are about to report this photo."; "going_to_report_user" = "You are about to report this user."; "going_to_report_video" = "You are about to report this video."; +"going_to_report_audio" = "You are about to report this audio."; "going_to_report_post" = "You are about to report this post."; "going_to_report_comment" = "You are about to report this comment."; @@ -1111,6 +1235,7 @@ "created" = "Created"; "everyone_can_create_topics" = "Everyone can create topics"; +"everyone_can_upload_audios" = "Everyone can upload audios"; "display_list_of_topics_above_wall" = "Display a list of topics above the wall"; "topic_changes_saved_comment" = "The updated title and settings will appear on the topic page."; @@ -1287,6 +1412,22 @@ "description_too_long" = "Description is too long."; +"invalid_audio" = "Invalid audio."; +"do_not_have_audio" = "You don't have this audio."; +"do_have_audio" = "You already have this audio."; + +"set_playlist_name" = "Enter the playlist name."; +"playlist_already_bookmarked" = "This playlist is already in your collection."; +"playlist_not_bookmarked" = "This playlist is not in your collection."; +"invalid_cover_photo" = "Error when loading cover photo."; +"not_a_photo" = "Uploaded file doesn't look like a photo."; +"file_too_big" = "File is too big."; +"file_loaded_partially" = "The file has been uploaded partially."; +"file_not_uploaded" = "Failed to upload the file."; +"error_code" = "Error code: $1."; +"ffmpeg_timeout" = "Timed out waiting ffmpeg. Try to upload file again."; +"ffmpeg_not_installed" = "Failed to proccess the file. It looks like ffmpeg is not installed on this server."; + /* Admin actions */ "login_as" = "Login as $1"; @@ -1393,6 +1534,10 @@ "admin_gift_moved_successfully" = "Gift moved successfully"; "admin_gift_moved_to_recycle" = "This gift will now be in <b>Recycle Bin</b>."; +"admin_original_file" = "Original file"; +"admin_audio_length" = "Length"; +"admin_cover_id" = "Cover (photo ID)"; +"admin_music" = "Music"; "logs" = "Logs"; "logs_anything" = "Anything"; @@ -1620,8 +1765,16 @@ "tour_section_5_text_3" = "In addition to uploading videos directly, the site also supports embedding videos from YouTube"; -"tour_section_6_title_1" = "Audios section, which doesn't exist yet xdddd"; -"tour_section_6_text_1" = "I would love to do a tutorial on this section, but sunshine Vriska didn't make the music :c"; +"tour_section_6_title_1" = "Listen to music"; +"tour_section_6_text_1" = "You can listen to music in \"My Audios\""; +"tour_section_6_text_2" = "This section is also controlled by the privacy settings."; +"tour_section_6_text_3" = "The most listened songs are in \"Popular\", and recently uploaded songs are in \"New\""; +"tour_section_6_text_4" = "To add a song to your collection, hover over it and click on the \"plus\". You can search for the song you want."; +"tour_section_6_text_5" = "If you can't find the song you want, you can upload it yourself"; +"tour_section_6_bottom_text_1" = "<b>Important:</b> the song must not infringe copyright"; +"tour_section_6_title_2" = "Create playlists"; +"tour_section_6_text_6" = "You can create playlists in the \"My Playlists\" tab"; +"tour_section_6_text_7" = "You can also add another's playlists to your collection"; "tour_section_7_title_1" = "Follow what your friends write"; @@ -1732,6 +1885,8 @@ "s_order_by_name" = "By name"; "s_order_by_random" = "By random"; "s_order_by_rating" = "By rating"; +"s_order_by_length" = "By length"; +"s_order_by_listens" = "By listens count"; "s_order_invert" = "Invert"; "s_by_date" = "By date"; @@ -1753,6 +1908,8 @@ "deleted_target_comment" = "This comment belongs to deleted post"; "no_results" = "No results"; +"s_only_performers" = "Performers only"; +"s_with_lyrics" = "With lyrics"; /* BadBrowser */ @@ -1792,6 +1949,7 @@ /* Mobile */ "mobile_friends" = "Friends"; "mobile_photos" = "Photos"; +"mobile_audios" = "Audios"; "mobile_videos" = "Videos"; "mobile_messages" = "Messages"; "mobile_notes" = "Notes"; @@ -1805,6 +1963,9 @@ "mobile_user_info_hide" = "Hide"; "mobile_user_info_show_details" = "Show details"; +"my" = "My"; +"enter_a_name_or_artist" = "Enter a name or artist..."; + /* Moderation */ "section" = "Section"; diff --git a/locales/ru.strings b/locales/ru.strings index 49871399..63f40462 100644 --- a/locales/ru.strings +++ b/locales/ru.strings @@ -461,6 +461,7 @@ "my_videos" = "Мои Видеозаписи"; "my_messages" = "Мои Сообщения"; "my_notes" = "Мои Заметки"; +"my_audios" = "Мои Аудиозаписи"; "my_groups" = "Мои Группы"; "my_feed" = "Мои Новости"; "my_feedback" = "Мои Ответы"; @@ -540,6 +541,7 @@ "privacy_setting_add_to_friends" = "Кто может называть меня другом"; "privacy_setting_write_wall" = "Кто может писать у меня на стене"; "privacy_setting_write_messages" = "Кто может писать мне сообщения"; +"privacy_setting_view_audio" = "Кому видно мои аудиозаписи"; "privacy_value_anybody" = "Все желающие"; "privacy_value_anybody_dative" = "Всем желающим"; "privacy_value_users" = "Пользователям OpenVK"; @@ -672,9 +674,128 @@ "upload_new_video" = "Загрузить новое видео"; "max_attached_videos" = "Максимум 10 видеозаписей"; "max_attached_photos" = "Максимум 10 фотографий"; +"max_attached_audios" = "Максимум 10 аудиозаписей"; "no_videos" = "У вас нет видео."; "no_videos_results" = "Нет результатов."; +/* Audios */ + +"audios" = "Аудиозаписи"; +"audio" = "Аудиозапись"; +"playlist" = "Плейлист"; +"upload_audio" = "Загрузить аудио"; +"upload_audio_to_group" = "Загрузить аудио в группу"; + +"performer" = "Исполнитель"; +"audio_name" = "Название"; +"genre" = "Жанр"; +"lyrics" = "Текст"; + +"select_another_file" = "Выбрать другой файл"; + +"limits" = "Ограничения"; +"select_audio" = "Выберите аудиозапись на Вашем компьютере"; +"audio_requirements" = "Аудиозапись должна быть длинной от $1c до $2 минут, весить до $3мб и содержать аудиопоток."; +"audio_requirements_2" = "Аудиозапись не должна нарушать авторские и смежные права."; +"you_can_also_add_audio_using" = "Вы также можете добавить аудиозапись из числа уже загруженных файлов, воспользовавшись"; +"search_audio_inst" = "поиском по аудио"; + +"audio_embed_not_found" = "Аудиозапись не найдена"; +"audio_embed_deleted" = "Аудиозапись была удалена"; +"audio_embed_withdrawn" = "Аудиозапись была изъята по обращению правообладателя."; +"audio_embed_forbidden" = "Настройки приватности пользователя не позволяют встраивать эту композицию"; +"audio_embed_processing" = "Аудио ещё обрабатывается, либо обработалось неправильно."; + +"audios_count_zero" = "Нет аудиозаписей"; +"audios_count_one" = "Одна аудиозапись"; /* сингл */ +"audios_count_few" = "$1 аудиозаписи"; +"audios_count_many" = "$1 аудиозаписей"; +"audios_count_other" = "$1 аудиозаписей"; + +"track_unknown" = "Неизвестен"; +"track_noname" = "Без названия"; + +"my_music" = "Моя музыка"; +"music_user" = "Музыка пользователя"; +"music_club" = "Музыка группы"; +"audio_new" = "Новое"; +"audio_popular" = "Популярное"; +"audio_search" = "Поиск"; + +"my_audios_small" = "Мои аудиозаписи"; +"my_playlists" = "Мои плейлисты"; +"playlists" = "Плейлисты"; +"audios_explicit" = "Содержит нецензурную лексику"; +"withdrawn" = "Изъято"; +"deleted" = "Удалено"; +"owner" = "Владелец"; +"searchable" = "Доступно в поиске"; + +"select_audio" = "Выбрать аудиозаписи"; +"no_playlists_thisuser" = "Вы ещё не добавляли плейлистов."; +"no_playlists_user" = "Этот пользователь ещё не добавлял плейлистов."; +"no_playlists_club" = "Эта группа ещё не добавляла плейлистов."; + +"no_audios_thisuser" = "Вы ещё не добавляли аудиозаписей."; +"no_audios_user" = "Этот пользователь ещё не добавлял аудиозаписей."; +"no_audios_club" = "Эта группа ещё не добавляла аудиозаписей."; + +"new_playlist" = "Новый плейлист"; +"created_playlist" = "создан"; +"updated_playlist" = "обновлён"; +"bookmark" = "Добавить в коллекцию"; +"unbookmark" = "Убрать из коллекции"; +"empty_playlist" = "В этом плейлисте нет аудиозаписей."; +"edit_playlist" = "Редактировать плейлист"; +"unable_to_load_queue" = "Не удалось загрузить очередь."; + +"fully_delete_audio" = "Полностью удалить аудиозапись"; +"attach_audio" = "Прикрепить аудиозапись"; +"detach_audio" = "Открепить аудиозапись"; + +"show_more_audios" = "Показать больше аудиозаписей"; +"add_to_playlist" = "Добавить в плейлист"; +"remove_from_playlist" = "Удалить из плейлиста"; +"delete_playlist" = "Удалить плейлист"; +"playlist_cover" = "Обложка плейлиста"; +"playlists_user" = "Плейлисты польз."; +"playlists_club" = "Плейлисты группы"; +"change_cover" = "Сменить обложку"; +"playlist_cover" = "Обложка плейлиста"; + +"minutes_count_zero" = "длится ноль минут"; +"minutes_count_one" = "длится одну минуту"; +"minutes_count_few" = "длится $1 минуты"; +"minutes_count_many" = "длится $1 минут"; +"minutes_count_other" = "длится $1 минут"; + +"listens_count_zero" = "нет прослушиваний"; +"listens_count_one" = "одно прослушивание"; +"listens_count_few" = "$1 прослушивания"; +"listens_count_many" = "$1 прослушиваний"; +"listens_count_other" = "$1 прослушиваний"; + +"add_audio_to_club" = "Добавить аудио в группу"; +"what_club_add" = "В какую группу вы хотите добавить песню?"; +"group_has_audio" = "У группы уже есть эта песня."; +"group_hasnt_audio" = "У группы нет этой песни."; + +"by_name" = "по композициям"; +"by_performer" = "по исполнителю"; +"no_access_clubs" = "Нет групп, где вы являетесь администратором."; +"audio_successfully_uploaded" = "Аудио успешно загружено и на данный момент обрабатывается."; + +"broadcast_audio" = "Транслировать аудио в статус"; +"sure_delete_playlist" = "Вы действительно хотите удалить этот плейлист?"; +"edit_audio" = "Редактировать аудиозапись"; +"audios_group" = "Аудиозаписи группы"; +"playlists_group" = "Плейлисты группы"; + +"play_tip" = "Проигрывание/пауза"; +"repeat_tip" = "Повторение"; +"shuffle_tip" = "Перемешать"; +"mute_tip" = "Заглушить"; + /* Notifications */ "feedback" = "Ответы"; @@ -915,6 +1036,7 @@ "going_to_report_photo" = "Вы собираетесь пожаловаться на данную фотографию."; "going_to_report_user" = "Вы собираетесь пожаловаться на данного пользователя."; "going_to_report_video" = "Вы собираетесь пожаловаться на данную видеозапись."; +"going_to_report_audio" = "Вы собираетесь пожаловаться на данную аудиозапись."; "going_to_report_post" = "Вы собираетесь пожаловаться на данную запись."; "going_to_report_comment" = "Вы собираетесь пожаловаться на данный комментарий."; @@ -1030,6 +1152,7 @@ "topics_other" = "$1 тем"; "created" = "Создано"; "everyone_can_create_topics" = "Все могут создавать темы"; +"everyone_can_upload_audios" = "Все могут загружать аудиозаписи"; "display_list_of_topics_above_wall" = "Отображать список тем над стеной"; "topic_changes_saved_comment" = "Обновлённый заголовок и настройки появятся на странице с темой."; "failed_to_create_topic" = "Не удалось создать тему"; @@ -1047,6 +1170,7 @@ "no_data" = "Нет данных"; "no_data_description" = "Тут ничего нет... Пока..."; "error" = "Ошибка"; +"error_generic" = "Произошла ошибка общего характера: "; "error_shorturl" = "Данный короткий адрес уже занят."; "error_segmentation" = "Ошибка сегментации"; "error_upload_failed" = "Не удалось загрузить фото"; @@ -1055,6 +1179,8 @@ "error_weak_password" = "Ненадёжный пароль. Пароль должен содержать не менее 8 символов, цифры, прописные и строчные буквы"; "error_shorturl_incorrect" = "Короткий адрес имеет некорректный формат."; "error_repost_fail" = "Не удалось поделиться записью"; + +"error_insufficient_info" = "Вы не указали необходимую информацию."; "error_data_too_big" = "Аттрибут '$1' не может быть длиннее $2 $3"; "forbidden" = "Ошибка доступа"; "unknown_error" = "Неизвестная ошибка"; @@ -1181,6 +1307,21 @@ "group_owner_is_banned" = "Создатель сообщества успешно забанен."; "group_is_banned" = "Сообщество успешно забанено"; "description_too_long" = "Описание слишком длинное."; +"invalid_audio" = "Такой аудиозаписи не существует."; +"do_not_have_audio" = "У вас нет этой аудиозаписи."; +"do_have_audio" = "У вас уже есть эта аудиозапись."; + +"set_playlist_name" = "Укажите название плейлиста."; +"playlist_already_bookmarked" = "Плейлист уже есть в вашей коллекции."; +"playlist_not_bookmarked" = "Плейлиста нет в вашей коллекции."; +"invalid_cover_photo" = "Не удалось сохранить обложку плейлиста."; +"not_a_photo" = "Загруженный файл не похож на фотографию."; +"file_too_big" = "Файл слишком большой."; +"file_loaded_partially" = "Файл загрузился частично."; +"file_not_uploaded" = "Не удалось загрузить файл."; +"error_code" = "Код ошибки: $1."; +"ffmpeg_timeout" = "Превышено время ожидания обработки ffmpeg. Попробуйте загрузить файл снова."; +"ffmpeg_not_installed" = "Не удалось обработать файл. Похоже, на сервере не установлен ffmpeg."; /* Admin actions */ @@ -1277,6 +1418,10 @@ "admin_gift_moved_successfully" = "Подарок успешно перемещён"; "admin_gift_moved_to_recycle" = "Теперь подарок находится в <b>корзине</b>."; +"admin_original_file" = "Оригинальный файл"; +"admin_audio_length" = "Длина"; +"admin_cover_id" = "Обложка (ID фото)"; +"admin_music" = "Музыка"; "logs" = "Логи"; "logs_anything" = "Любое"; @@ -1508,8 +1653,16 @@ "tour_section_5_text_3" = "Кроме загрузки видео напрямую, сайт поддерживает и встраивание видео из YouTube"; -"tour_section_6_title_1" = "Аудиозаписи, которых пока что нет XD"; -"tour_section_6_text_1" = "Я был бы очень рад сделать туториал по этому разделу, но солнышко Вриска не сделала музыку"; +"tour_section_6_title_1" = "Слушайте аудиозаписи"; +"tour_section_6_text_1" = "Вы можете слушать аудиозаписи в разделе \"Мои Аудиозаписи\""; +"tour_section_6_text_2" = "Этот раздел также регулируется настройками приватности"; +"tour_section_6_text_3" = "Самые прослушиваемые песни находятся во вкладке \"Популярное\", а недавно загруженные — во вкладке \"Новое\""; +"tour_section_6_text_4" = "Чтобы добавить песню в свою коллекцию, наведите на неё и нажмите на плюс. Найти нужную песню можно в поиске"; +"tour_section_6_text_5" = "Если вы не можете найти нужную песню, вы можете загрузить её самостоятельно"; +"tour_section_6_bottom_text_1" = "<b>Важно:</b> песня не должна нарушать авторские права"; +"tour_section_6_title_2" = "Создавайте плейлисты"; +"tour_section_6_text_6" = "Вы можете создавать сборники треков во вкладке \"Мои плейлисты\""; +"tour_section_6_text_7" = "Можно также добавлять чужие плейлисты в свою коллекцию"; "tour_section_7_title_1" = "Следите за тем, что пишут ваши друзья"; @@ -1620,6 +1773,8 @@ "s_order_by_name" = "По имени"; "s_order_by_random" = "По случайности"; "s_order_by_rating" = "По рейтингу"; +"s_order_by_length" = "По длине"; +"s_order_by_listens" = "По числу прослушиваний"; "s_order_invert" = "Инвертировать"; "s_by_date" = "По дате"; @@ -1641,6 +1796,8 @@ "deleted_target_comment" = "Этот комментарий принадлежит к удалённой записи"; "no_results" = "Результатов нет"; +"s_only_performers" = "Только исполнители"; +"s_with_lyrics" = "С текстом"; /* BadBrowser */ @@ -1680,6 +1837,7 @@ /* Mobile */ "mobile_friends" = "Друзья"; "mobile_photos" = "Фотографии"; +"mobile_audios" = "Аудиозаписи"; "mobile_videos" = "Видеозаписи"; "mobile_messages" = "Сообщения"; "mobile_notes" = "Заметки"; @@ -1693,6 +1851,9 @@ "mobile_user_info_hide" = "Скрыть"; "mobile_user_info_show_details" = "Показать подробнее"; +"my" = "Мои"; +"enter_a_name_or_artist" = "Введите название или автора..."; + /* Moderation */ "section" = "Раздел"; diff --git a/openvk-example.yml b/openvk-example.yml index 4b45e7ba..308610e2 100644 --- a/openvk-example.yml +++ b/openvk-example.yml @@ -50,6 +50,8 @@ openvk: - "Good luck filling! If you are a regular support agent, inform the administrator that he forgot to fill the config" messages: strict: false + music: + exposeOriginalURLs: true wall: christian: false anonymousPosting: diff --git a/themepacks/midnight/stylesheet.css b/themepacks/midnight/stylesheet.css index 634917c6..73030b40 100644 --- a/themepacks/midnight/stylesheet.css +++ b/themepacks/midnight/stylesheet.css @@ -235,10 +235,133 @@ input[type="radio"] { } .searchList #used { - background: linear-gradient(#453e5e,#473f61); + background: #463f60 !important; } #backdropEditor { background-image: url("/themepack/midnight/0.0.2.8/resource/backdrop-editor.gif") !important; border-color: #473e66 !important; -} \ No newline at end of file +} + +.bigPlayer { + background-color: rgb(30, 26, 43) !important; +} + +.bigPlayer .selectableTrack, .audioEmbed .track > .selectableTrack { + border-top: #b9b9b9 1px solid !important; +} + +.bigPlayer .paddingLayer .slider, .audioEmbed .track .slider { + background: #b9b9b9 !important; +} + +.musicIcon { + filter: invert(81%) !important; +} + +.audioEntry.nowPlaying { + background: #463f60 !important; + border: 1px solid #645a86 !important; +} + +.preformer { + color: #b7b7b7 !important; +} + +.bigPlayer .paddingLayer .trackPanel .track .timeTip { + background: #b9b9b9 !important; + color: black !important; +} + +.audioEntry.nowPlaying:hover { + background: #50486f !important; +} + +.audioEntry:hover { + background: #19142D !important; +} + +.audioEntry .performer a { + color: #a2a1a1 !important; +} + +.musicIcon.lagged { + opacity: 49%; +} + +.bigPlayer .paddingLayer .bigPlayerTip { + color: black !important; +} + +.searchList a { + color: #bbb !important; +} + +.searchList a:hover { + color: #eeeeee !important; + background: #332d46 !important; +} + +.friendsAudiosList .elem:hover { + background: #332d46 !important; +} + +.audioEntry .playerButton .playIcon { + filter: invert(81%); +} + +img[src$='/assets/packages/static/openvk/img/camera_200.png'], img[src$='/assets/packages/static/openvk/img/song.jpg'] { + filter: invert(100%); +} + +.audioStatus { + color: #8E8E8E !important; +} + +.audioEntry .withLyrics { + color: #6f6497 !important; +} + +#listensCount { + color: unset !important; +} + +#upload_container, .whiteBox { + background: #1d1928 !important; + border: 1px solid #383052 !important; +} + +ul { + color: #8b9ab5 !important; +} + +#audio_upload { + border: 2px solid #383052 !important; + background-color: #262133 !important; +} + +/* вот бы css в овк был бы написан на var()'ах( */ +#upload_container.uploading { + background: #121017 url('/assets/packages/static/openvk/img/progressbar.gif') !important; +} + +.musicIcon.pressed { + opacity: 41% !important; +} + +.ovk-diag-body .searchBox { + background: #1e1a2b !important; +} + +.audioEntry.nowPlaying .title { + color: #fff !important; +} + +.attachAudio:hover { + background: #19142D !important; + cursor: pointer; +} + +.showMore, .showMoreAudiosPlaylist { + background: #181826 !important; +} diff --git a/themepacks/openvk_modern/stylesheet.css b/themepacks/openvk_modern/stylesheet.css index 178e8094..841e92ec 100644 --- a/themepacks/openvk_modern/stylesheet.css +++ b/themepacks/openvk_modern/stylesheet.css @@ -279,18 +279,9 @@ input[type=checkbox] { box-shadow: none; } -.searchList #used -{ - margin-left:0px; - color: white; - padding: 2px; - padding-top: 5px; - padding-bottom: 5px; - border: none; - background: #a4a4a4; - margin-bottom: 2px; - padding-left: 5px; - width: 90%; +.searchList #used { + background: #3c3c3c !important; + border: unset !important; } .searchList #used a @@ -337,3 +328,35 @@ input[type=checkbox] { { border-top: 1px solid #2f2f2f; } + +.musicIcon { + filter: contrast(202%) !important; +} + +.audioEntry .playerButton .playIcon { + filter: contrast(7) !important; +} + +.audioEmbed .track > .selectableTrack, .bigPlayer .selectableTrack { + border-top: #404040 1px solid !important; +} + +.bigPlayer .paddingLayer .slider, .audioEmbed .track .slider { + background: #3c3c3c !important; +} + +.audioEntry.nowPlaying { + background: #4b4b4b !important; +} + +.audioEntry.nowPlaying:hover { + background: #373737 !important; +} + +.musicIcon.pressed { + filter: brightness(150%) !important; +} + +.musicIcon.lagged { + opacity: 50%; +} From eb64376c3a02356c771280353c486e789521afe7 Mon Sep 17 00:00:00 2001 From: DeathPleiad <43928323+Parad1seF0x@users.noreply.github.com> Date: Sun, 12 Nov 2023 03:14:01 +0300 Subject: [PATCH 10/21] Add -vn flag to FFMPEG scripts (#1020) --- Web/Models/shell/processAudio.ps1 | 2 +- Web/Models/shell/processAudio.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Web/Models/shell/processAudio.ps1 b/Web/Models/shell/processAudio.ps1 index 206e11e1..f60a9aed 100644 --- a/Web/Models/shell/processAudio.ps1 +++ b/Web/Models/shell/processAudio.ps1 @@ -23,7 +23,7 @@ Set-Location -Path $temp Move-Item $filename $audioFile ffmpeg -i $audioFile -f dash -encryption_scheme cenc-aes-ctr -encryption_key $key ` - -encryption_kid $keyID -map 0:a -c:a aac -ar 44100 -seg_duration $seg ` + -encryption_kid $keyID -map 0:a -vn -c:a aac -ar 44100 -seg_duration $seg ` -use_timeline 1 -use_template 1 -init_seg_name ($fileHash + '_fragments/0_0.$ext$') ` -media_seg_name ($fileHash + '_fragments/chunk$Number%06d$_$RepresentationID$.$ext$') -adaptation_sets 'id=0,streams=a' ` "$fileHash.mpd" diff --git a/Web/Models/shell/processAudio.sh b/Web/Models/shell/processAudio.sh index ab5a5c55..fa8346e0 100644 --- a/Web/Models/shell/processAudio.sh +++ b/Web/Models/shell/processAudio.sh @@ -19,7 +19,7 @@ cd "$temp" mv "$filename" "$audioFile" ffmpeg -i "$audioFile" -f dash -encryption_scheme cenc-aes-ctr -encryption_key "$key" \ - -encryption_kid "$keyID" -map 0 -c:a aac -ar 44100 -seg_duration "$seg" \ + -encryption_kid "$keyID" -map 0 -vn -c:a aac -ar 44100 -seg_duration "$seg" \ -use_timeline 1 -use_template 1 -init_seg_name "$fileHash"_fragments/0_0."\$ext\$" \ -media_seg_name "$fileHash"_fragments/chunk"\$Number"%06d\$_"\$RepresentationID\$"."\$ext\$" -adaptation_sets 'id=0,streams=a' \ "$fileHash.mpd" From f65d790654bb716af1d74b38031781701327d5e1 Mon Sep 17 00:00:00 2001 From: lalka2018 <99399973+lalka2016@users.noreply.github.com> Date: Sun, 12 Nov 2023 12:58:04 +0300 Subject: [PATCH 11/21] Set context timeout to 20s and maybe fix broadcas t list --- Web/Models/Entities/User.php | 2 +- Web/static/js/al_music.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php index 291dfb6a..4ff3236b 100644 --- a/Web/Models/Entities/User.php +++ b/Web/Models/Entities/User.php @@ -1279,7 +1279,7 @@ class User extends RowModel foreach($entityIds as $id) { $entit = $id > 0 ? (new Users)->get($id) : (new Clubs)->get(abs($id)); - if($id > 0 && $entit->isDeleted()) return; + if($id > 0 && $entit->isDeleted()) continue; $returnArr[] = $entit; } diff --git a/Web/static/js/al_music.js b/Web/static/js/al_music.js index fbbe7c82..85952a00 100644 --- a/Web/static/js/al_music.js +++ b/Web/static/js/al_music.js @@ -141,7 +141,8 @@ class bigPlayer { } ] }, - body: formdata + body: formdata, + timeout: 20000, }) u(this.nodes["playButtons"].querySelector(".playButton")).on("click", (e) => { From 5bb6e097fb0c03117817657d53b96edc739aafae Mon Sep 17 00:00:00 2001 From: Evgeniy Khramov <65224669+thejenja@users.noreply.github.com> Date: Sun, 12 Nov 2023 17:04:13 +0500 Subject: [PATCH 12/21] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=20=D0=BA=D1=80?= =?UTF-8?q?=D1=83=D0=B3=D0=BB=D1=8B=D1=85=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=20=D0=B2=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B5=20=D0=BC=D1=83=D0=B7=D1=8B=D0=BA=D0=B8=20=D0=B4=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=B5=D0=B9=20(#1024)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Web/static/css/avatar.2.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Web/static/css/avatar.2.css b/Web/static/css/avatar.2.css index 2990eb3d..42daa14d 100644 --- a/Web/static/css/avatar.2.css +++ b/Web/static/css/avatar.2.css @@ -81,3 +81,10 @@ div.ovk-video > div > img object-fit: cover; border-radius: 100px; } + +.friendsAudiosList .elem img { + width: 30px; + border-radius: 100px; + height: 31px; + min-width: 30px; +} From 1632d54d52065681d1f2e822ce4ad9132d1bc737 Mon Sep 17 00:00:00 2001 From: lalka2018 <99399973+lalka2016@users.noreply.github.com> Date: Sun, 12 Nov 2023 15:46:00 +0300 Subject: [PATCH 13/21] ya eblan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Исправлен server error из-за списка друзей. Он подгружает абсолютно все группы и всех друзей, на которые подписан человек и из-за этого серверу очень плохо. Забыл, бывает. Теперь он подгружает только 10 такого --- Web/Models/Entities/User.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php index 4ff3236b..66b93713 100644 --- a/Web/Models/Entities/User.php +++ b/Web/Models/Entities/User.php @@ -1267,6 +1267,8 @@ class User extends RowModel $entityIds[] = $_rel->model == "openvk\\Web\\Models\\Entities\\Club" ? $_rel->target * -1 : $_rel->target; } + $entityIds = array_slice($entityIds, 0, 10); + if($shuffle) { $shuffleSeed = openssl_random_pseudo_bytes(6); $shuffleSeed = hexdec(bin2hex($shuffleSeed)); From 08499cd3b4dd3ff575e6819fd8931d20f6647f7a Mon Sep 17 00:00:00 2001 From: lalka2018 <99399973+lalka2016@users.noreply.github.com> Date: Sun, 12 Nov 2023 17:02:33 +0300 Subject: [PATCH 14/21] Some broadcast list fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - При скроллинге вниз на странице с аудио вкладки не идут за вами (баговано) - Теперь перемешка списка друзей на странице аудио должна работать нормально --- Web/Models/Entities/User.php | 4 ++-- Web/static/js/al_music.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php index 66b93713..40ef7aef 100644 --- a/Web/Models/Entities/User.php +++ b/Web/Models/Entities/User.php @@ -1267,14 +1267,14 @@ class User extends RowModel $entityIds[] = $_rel->model == "openvk\\Web\\Models\\Entities\\Club" ? $_rel->target * -1 : $_rel->target; } - $entityIds = array_slice($entityIds, 0, 10); - if($shuffle) { $shuffleSeed = openssl_random_pseudo_bytes(6); $shuffleSeed = hexdec(bin2hex($shuffleSeed)); $entityIds = knuth_shuffle($entityIds, $shuffleSeed); } + + $entityIds = array_slice($entityIds, 0, 10); $returnArr = []; diff --git a/Web/static/js/al_music.js b/Web/static/js/al_music.js index 85952a00..691b6edb 100644 --- a/Web/static/js/al_music.js +++ b/Web/static/js/al_music.js @@ -651,10 +651,10 @@ document.addEventListener("DOMContentLoaded", function() { entries.forEach(x => { if(x.isIntersecting) { document.querySelector('.bigPlayer').classList.remove("floating") - document.querySelector('.searchOptions .searchList').classList.remove("floating") + //document.querySelector('.searchOptions .searchList').classList.remove("floating") document.querySelector('.bigPlayerDetector').style.marginTop = "0px" } else { - document.querySelector('.searchOptions .searchList').classList.add("floating") + //document.querySelector('.searchOptions .searchList').classList.add("floating") document.querySelector('.bigPlayer').classList.add("floating") document.querySelector('.bigPlayerDetector').style.marginTop = "46px" } From b7160d78a04c53f0dca5d231a89f12b7c10fa34f Mon Sep 17 00:00:00 2001 From: veselcraft <veselcraft@icloud.com> Date: Sun, 12 Nov 2023 23:00:19 +0300 Subject: [PATCH 15/21] Global: Gender -> Sex --- locales/en.strings | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/locales/en.strings b/locales/en.strings index 7fbe255e..53385ff9 100644 --- a/locales/en.strings +++ b/locales/en.strings @@ -72,7 +72,7 @@ "change_status" = "change status"; "name" = "Name"; "surname" = "Surname"; -"gender" = "Gender"; +"gender" = "Sex"; "male" = "male"; "female" = "female"; "description" = "Description"; @@ -1457,7 +1457,7 @@ "admin_verification" = "Verification"; "admin_banreason" = "Ban reason"; "admin_banned" = "banned"; -"admin_gender" = "Gender"; +"admin_gender" = "Sex"; "admin_registrationdate" = "Registration date"; "admin_actions" = "Actions"; "admin_image" = "Image"; From 784b19aaf76708e7bb39b4ec8a83e29892ba2477 Mon Sep 17 00:00:00 2001 From: veselcraft <veselcraft@icloud.com> Date: Sun, 12 Nov 2023 23:07:02 +0300 Subject: [PATCH 16/21] VKAPI: add smth idk --- VKAPI/Handlers/Users.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/VKAPI/Handlers/Users.php b/VKAPI/Handlers/Users.php index 68bf828f..6eccab1b 100644 --- a/VKAPI/Handlers/Users.php +++ b/VKAPI/Handlers/Users.php @@ -165,6 +165,18 @@ final class Users extends VKAPIRequestHandler case "interests": $response[$i]->interests = $usr->getInterests(); break; + case "quotes": + $response[$i]->interests = $usr->getFavoriteQuote(); + break; + case "email": + $response[$i]->interests = $usr->getEmail(); + break; + case "telegram": + $response[$i]->interests = $usr->getTelegram(); + break; + case "about": + $response[$i]->interests = $usr->getDescription(); + break; case "rating": $response[$i]->rating = $usr->getRating(); break; From 626b5b49bb9169e024f9e9584c6c49692c61aab2 Mon Sep 17 00:00:00 2001 From: veselcraft <veselcraft@icloud.com> Date: Mon, 13 Nov 2023 12:36:46 +0300 Subject: [PATCH 17/21] VKAPI: fix php 8 issue when DELETED appears in empty friend list --- VKAPI/Handlers/Users.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/VKAPI/Handlers/Users.php b/VKAPI/Handlers/Users.php index 6eccab1b..65b7c191 100644 --- a/VKAPI/Handlers/Users.php +++ b/VKAPI/Handlers/Users.php @@ -12,6 +12,9 @@ final class Users extends VKAPIRequestHandler $users = new UsersRepo; if($user_ids == "0") $user_ids = (string) $authuser->getId(); + + if($user_ids == "") + return array(); $usrs = explode(',', $user_ids); $response = array(); From d183b1a8a3df8bb14f490b5473353443b4348ee3 Mon Sep 17 00:00:00 2001 From: veselcraft <veselcraft@icloud.com> Date: Mon, 13 Nov 2023 12:37:15 +0300 Subject: [PATCH 18/21] compatibility fixxxxxx --- VKAPI/Handlers/Friends.php | 6 +++++- VKAPI/Handlers/Wall.php | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/VKAPI/Handlers/Friends.php b/VKAPI/Handlers/Friends.php index 56de3294..59cf5679 100644 --- a/VKAPI/Handlers/Friends.php +++ b/VKAPI/Handlers/Friends.php @@ -4,7 +4,7 @@ use openvk\Web\Models\Repositories\Users as UsersRepo; final class Friends extends VKAPIRequestHandler { - function get(int $user_id, string $fields = "", int $offset = 0, int $count = 100): object + function get(int $user_id = 0, string $fields = "", int $offset = 0, int $count = 100): object { $i = 0; $offset++; @@ -14,6 +14,10 @@ final class Friends extends VKAPIRequestHandler $this->requireUser(); + if ($user_id == 0) { + $user_id = $this->getUser()->getId(); + } + if (is_null($users->get($user_id))) { $this->fail(100, "One of the parameters specified was missing or invalid"); } diff --git a/VKAPI/Handlers/Wall.php b/VKAPI/Handlers/Wall.php index 693ca3cb..add2159b 100644 --- a/VKAPI/Handlers/Wall.php +++ b/VKAPI/Handlers/Wall.php @@ -802,7 +802,7 @@ final class Wall extends VKAPIRequestHandler return [ "type" => "photo", "photo" => [ - "album_id" => $attachment->getAlbum() ? $attachment->getAlbum()->getId() : NULL, + "album_id" => $attachment->getAlbum() ? $attachment->getAlbum()->getId() : 0, "date" => $attachment->getPublicationTime()->timestamp(), "id" => $attachment->getVirtualId(), "owner_id" => $attachment->getOwner()->getId(), From 63702d44d1587d85049cb334bb8564c81e1b9b70 Mon Sep 17 00:00:00 2001 From: lalka2018 <99399973+lalka2016@users.noreply.github.com> Date: Mon, 13 Nov 2023 15:08:18 +0300 Subject: [PATCH 19/21] Something related with audios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Теперь аудиозаписи в wall.get,getById,getComments,getComment выглядит нормально - Теперь при создании плейлиста можно выбрать до тысячи песен - По идее, название трека теперь нормально обрезается и раскрывается при наведении - Добавлена проверка на существование коммента в wall.getComment - Плейлисты теперь не вылетают, если пользователь не залогинен. --- VKAPI/Handlers/Wall.php | 23 +++++++++++++++++---- Web/Presenters/AudioPresenter.php | 8 +++---- Web/Presenters/templates/Audio/Playlist.xml | 2 +- Web/Presenters/templates/Audio/player.xml | 8 +++---- Web/static/js/al_music.js | 2 +- 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/VKAPI/Handlers/Wall.php b/VKAPI/Handlers/Wall.php index add2159b..179d5e0e 100644 --- a/VKAPI/Handlers/Wall.php +++ b/VKAPI/Handlers/Wall.php @@ -60,7 +60,10 @@ final class Wall extends VKAPIRequestHandler } else if ($attachment instanceof \openvk\Web\Models\Entities\Note) { $attachments[] = $attachment->toVkApiStruct(); } else if ($attachment instanceof \openvk\Web\Models\Entities\Audio) { - $attachments[] = $attachment->toVkApiStruct($this->getUser()); + $attachments[] = [ + "type" => "audio", + "audio" => $attachment->toVkApiStruct($this->getUser()), + ]; } else if ($attachment instanceof \openvk\Web\Models\Entities\Post) { $repostAttachments = []; @@ -237,7 +240,10 @@ final class Wall extends VKAPIRequestHandler } else if ($attachment instanceof \openvk\Web\Models\Entities\Note) { $attachments[] = $attachment->toVkApiStruct(); } else if ($attachment instanceof \openvk\Web\Models\Entities\Audio) { - $attachments[] = $attachment->toVkApiStruct($this->getUser()); + $attachments[] = [ + "type" => "audio", + "audio" => $attachment->toVkApiStruct($this->getUser()) + ]; } else if ($attachment instanceof \openvk\Web\Models\Entities\Post) { $repostAttachments = []; @@ -576,7 +582,10 @@ final class Wall extends VKAPIRequestHandler } elseif($attachment instanceof \openvk\Web\Models\Entities\Note) { $attachments[] = $attachment->toVkApiStruct(); } elseif($attachment instanceof \openvk\Web\Models\Entities\Audio) { - $attachments[] = $attachment->toVkApiStruct($this->getUser()); + $attachments[] = [ + "type" => "audio", + "audio" => $attachment->toVkApiStruct($this->getUser()), + ]; } } @@ -636,6 +645,9 @@ final class Wall extends VKAPIRequestHandler $comment = (new CommentsRepo)->get($comment_id); # один хуй айди всех комментов общий + if(!$comment || $comment->isDeleted()) + $this->fail(100, "Invalid comment"); + $profiles = []; $attachments = []; @@ -644,7 +656,10 @@ final class Wall extends VKAPIRequestHandler if($attachment instanceof \openvk\Web\Models\Entities\Photo) { $attachments[] = $this->getApiPhoto($attachment); } elseif($attachment instanceof \openvk\Web\Models\Entities\Audio) { - $attachments[] = $attachment->toVkApiStruct($this->getUser()); + $attachments[] = [ + "type" => "audio", + "audio" => $attachment->toVkApiStruct($this->getUser()), + ]; } } diff --git a/Web/Presenters/AudioPresenter.php b/Web/Presenters/AudioPresenter.php index 3bae710c..8ec17012 100644 --- a/Web/Presenters/AudioPresenter.php +++ b/Web/Presenters/AudioPresenter.php @@ -304,7 +304,7 @@ final class AudioPresenter extends OpenVKPresenter if ($_SERVER["REQUEST_METHOD"] === "POST") { $title = $this->postParam("title"); $description = $this->postParam("description"); - $audios = !empty($this->postParam("audios")) ? array_slice(explode(",", $this->postParam("audios")), 0, 100) : []; + $audios = !empty($this->postParam("audios")) ? array_slice(explode(",", $this->postParam("audios")), 0, 1000) : []; if(empty($title) || iconv_strlen($title) < 1) $this->flashFail("err", tr("error"), tr("set_playlist_name")); @@ -478,9 +478,9 @@ final class AudioPresenter extends OpenVKPresenter $this->template->audios = iterator_to_array($playlist->fetch($page, 10)); $this->template->ownerId = $owner_id; $this->template->owner = $playlist->getOwner(); - $this->template->isBookmarked = $playlist->isBookmarkedBy($this->user->identity); - $this->template->isMy = $playlist->getOwner()->getId() === $this->user->id; - $this->template->canEdit = $playlist->canBeModifiedBy($this->user->identity); + $this->template->isBookmarked = $this->user->identity && $playlist->isBookmarkedBy($this->user->identity); + $this->template->isMy = $this->user->identity && $playlist->getOwner()->getId() === $this->user->id; + $this->template->canEdit = $this->user->identity && $playlist->canBeModifiedBy($this->user->identity); } function renderAction(int $audio_id): void diff --git a/Web/Presenters/templates/Audio/Playlist.xml b/Web/Presenters/templates/Audio/Playlist.xml index f14b6ac1..175dfc3a 100644 --- a/Web/Presenters/templates/Audio/Playlist.xml +++ b/Web/Presenters/templates/Audio/Playlist.xml @@ -39,7 +39,7 @@ <img src="{$playlist->getCoverURL('normal')}" alt="{_playlist_cover}"> </a> - <div class="profile_links" style="width: 139px;"> + <div class="profile_links" style="width: 139px;" n:if="isset($thisUser)"> <a class="profile_link" style="width: 98%;" href="/playlist{$playlist->getPrettyId()}/edit" n:if="$playlist->canBeModifiedBy($thisUser)">{_edit_playlist}</a> <a class="profile_link" style="width: 98%;" id="bookmarkPlaylist" data-id="{$playlist->getId()}" n:if="!$isBookmarked">{_bookmark}</a> <a class="profile_link" style="width: 98%;" id="unbookmarkPlaylist" data-id="{$playlist->getId()}" n:if="$isBookmarked">{_unbookmark}</a> diff --git a/Web/Presenters/templates/Audio/player.xml b/Web/Presenters/templates/Audio/player.xml index fb5862b4..a3bf5ad5 100644 --- a/Web/Presenters/templates/Audio/player.xml +++ b/Web/Presenters/templates/Audio/player.xml @@ -11,13 +11,13 @@ </div> <div class="status" style="margin-top: 12px;"> - <div class="mediaInfo" style="margin-bottom: -8px; cursor: pointer;display:flex;width: 85%;"> - <div class="info noOverflow"> + <div class="mediaInfo noOverflow" style="margin-bottom: -8px; cursor: pointer;display:flex;width: 85%;"> + <div class="info"> <strong class="performer"> - <a href="/search?query=&type=audios&sort=id&only_performers=on&query={$audio->getPerformer()}">{ovk_proc_strtr($audio->getPerformer(), 30)}</a> + <a href="/search?query=&type=audios&sort=id&only_performers=on&query={$audio->getPerformer()}">{$audio->getPerformer()}</a> </strong> — - <span class="title {if !empty($audio->getLyrics())}withLyrics{/if}">{ovk_proc_strtr($audio->getTitle(), 30)}</span> + <span class="title {if !empty($audio->getLyrics())}withLyrics{/if}">{$audio->getTitle()}</span> </div> <div class="explicitMark" n:if="$audio->isExplicit()"></div> diff --git a/Web/static/js/al_music.js b/Web/static/js/al_music.js index 691b6edb..ac449c0d 100644 --- a/Web/static/js/al_music.js +++ b/Web/static/js/al_music.js @@ -669,7 +669,7 @@ document.addEventListener("DOMContentLoaded", function() { bigPlayerObserver.observe(bigplayer); } - $(`.audioEntry .mediaInfo`).on("mouseover mouseleave", (e) => { + $(document).on("mouseover mouseleave", `.audioEntry .mediaInfo`, (e) => { const info = e.currentTarget.closest(".mediaInfo") const overfl = info.querySelector(".info") From 55f7069193c6830b3a789af33b6af092a8b7ade0 Mon Sep 17 00:00:00 2001 From: zavsc <90477403+zavsc@users.noreply.github.com> Date: Mon, 13 Nov 2023 16:25:17 +0300 Subject: [PATCH 20/21] Update dependencies (#1027) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a248197a..373d667d 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ "wapmorgan/binary-stream": "dev-master", "al/emoji-detector": "dev-master", "ezyang/htmlpurifier": "dev-master", - "scssphp/scssphp": "dev-master", + "scssphp/scssphp": "dev-main", "lfkeitel/phptotp": "dev-master", "chillerlan/php-qrcode": "dev-main", "vearutop/php-obscene-censor-rus": "dev-master", From c43e68ab708d7b8e0fe5ad80ed30d0ee16981474 Mon Sep 17 00:00:00 2001 From: IsamiRi <53663257+isamirivers@users.noreply.github.com> Date: Mon, 13 Nov 2023 16:36:33 +0300 Subject: [PATCH 21/21] Russian Latin: Music update (#1025) --- locales/ru_lat.strings | 123 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/locales/ru_lat.strings b/locales/ru_lat.strings index 74f79d57..7f1308b7 100644 --- a/locales/ru_lat.strings +++ b/locales/ru_lat.strings @@ -151,7 +151,7 @@ "post_deact_silent_m" = "molča udalil svoju stranicu."; "post_deact_silent_f" = "molča udalila svoju stranicu."; "post_on_your_wall" = "na vašej stene"; -"post_on_group_wall" = "v $1"; +"post_on_group_wall" = "в $1"; "post_on_user_wall" = "na stene $1"; "wall" = "Stena"; "post" = "Zapisí"; @@ -464,6 +464,7 @@ "my_videos" = "Moi Videozapisi"; "my_messages" = "Moi Soobsčenija"; "my_notes" = "Moi Zametki"; +"my_audios" = "Moi Audiozapisi"; "my_groups" = "Moi Gruppy"; "my_feed" = "Moi Novosti"; "my_feedback" = "Moi Otvety"; @@ -543,6 +544,7 @@ "privacy_setting_add_to_friends" = "Kto možet nazyvatí menja drugom"; "privacy_setting_write_wall" = "Kto možet pisatí u menja na stene"; "privacy_setting_write_messages" = "Kto možet pisatí mne soobsčenija"; +"privacy_setting_view_audio" = "Komu vidno moi audiozapisi"; "privacy_value_anybody" = "Vse želajusčie"; "privacy_value_anybody_dative" = "Vsem želajusčim"; "privacy_value_users" = "Polízovateljam OpenVK"; @@ -675,9 +677,128 @@ "upload_new_video" = "Zagruzití novoe video"; "max_attached_videos" = "Maksimum 10 videozapisej"; "max_attached_photos" = "Maksimum 10 fotografij"; +"max_attached_audios" = "Maksimum 10 audiozapisej"; "no_videos" = "U vas net video."; "no_videos_results" = "Net rezulítatov."; +/* Audios */ + +"audios" = "Audiozapisi"; +"audio" = "Audiozapisí"; +"playlist" = "Plejlist"; +"upload_audio" = "Zagruzití audio"; +"upload_audio_to_group" = "Zagruzití audio v gruppu"; + +"performer" = "Ispolnitelí"; +"audio_name" = "Nazvanie"; +"genre" = "Žanr"; +"lyrics" = "Tekst"; + +"select_another_file" = "Vybratí drugoj fajl"; + +"limits" = "Ograničenija"; +"select_audio" = "Vyberite audiozapisí na Vašem kompíjutere"; +"audio_requirements" = "Audiozapisí dolžna bytí dlinnoj ot $1c do $2 minut, vesití do $3mb i soderžatí audiopotok."; +"audio_requirements_2" = "Audiozapisí ne dolžna narušatí avtorskie i smežnye prava."; +"you_can_also_add_audio_using" = "Vy takže možete dobavití audiozapisí iz čisla uže zagružennyh fajlov, vospolízovavšisí"; +"search_audio_inst" = "poiskom po audio"; + +"audio_embed_not_found" = "Audiozapisí ne najdena"; +"audio_embed_deleted" = "Audiozapisí byla udalena"; +"audio_embed_withdrawn" = "Audiozapisí byla izjjata po obrasčeniju pravoobladatelja."; +"audio_embed_forbidden" = "Nastrojki privatnosti polízovatelja ne pozvoljajut vstraivatí etu kompoziciju"; +"audio_embed_processing" = "Audio esčjo obrabatyvaetsja, libo obrabotalosí nepravilíno."; + +"audios_count_zero" = "Net audiozapisej"; +"audios_count_one" = "Odna audiozapisí"; +"audios_count_few" = "$1 audiozapisi"; +"audios_count_many" = "$1 audiozapisej"; +"audios_count_other" = "$1 audiozapisej"; + +"track_unknown" = "Neizvesten"; +"track_noname" = "Bez nazvanija"; + +"my_music" = "Moja muzyka"; +"music_user" = "Muzyka polízovatelja"; +"music_club" = "Muzyka gruppy"; +"audio_new" = "Novoe"; +"audio_popular" = "Populjarnoe"; +"audio_search" = "Poisk"; + +"my_audios_small" = "Moi audiozapisi"; +"my_playlists" = "Moi plejlisty"; +"playlists" = "Plejlisty"; +"audios_explicit" = "Soderžit necenzurnuju leksiku"; +"withdrawn" = "Izjjato"; +"deleted" = "Udaleno"; +"owner" = "Vladelec"; +"searchable" = "Dostupno v poiske"; + +"select_audio" = "Vybratí audiozapisi"; +"no_playlists_thisuser" = "Vy esčjo ne dobavljali plejlistov."; +"no_playlists_user" = "Etot polízovatelí esčjo ne dobavljal plejlistov."; +"no_playlists_club" = "Eta gruppa esčjo ne dobavljala plejlistov."; + +"no_audios_thisuser" = "Vy esčjo ne dobavljali audiozapisej."; +"no_audios_user" = "Etot polízovatelí esčjo ne dobavljal audiozapisej."; +"no_audios_club" = "Eta gruppa esčjo ne dobavljala audiozapisej."; + +"new_playlist" = "Novyj plejlist"; +"created_playlist" = "sozdan"; +"updated_playlist" = "obnovljon"; +"bookmark" = "Dobavití v kollekciju"; +"unbookmark" = "Ubratí iz kollekcii"; +"empty_playlist" = "V etom plejliste net audiozapisej."; +"edit_playlist" = "Redaktirovatí plejlist"; +"unable_to_load_queue" = "Ne udalosí zagruzití očeredí."; + +"fully_delete_audio" = "Polnostíju udalití audiozapisí"; +"attach_audio" = "Prikrepití audiozapisí"; +"detach_audio" = "Otkrepití audiozapisí"; + +"show_more_audios" = "Pokazatí bolíše audiozapisej"; +"add_to_playlist" = "Dobavití v plejlist"; +"remove_from_playlist" = "Udalití iz plejlista"; +"delete_playlist" = "Udalití plejlist"; +"playlist_cover" = "Obložka plejlista"; +"playlists_user" = "Plejlisty políz."; +"playlists_club" = "Plejlisty gruppy"; +"change_cover" = "Smenití obložku"; +"playlist_cover" = "Obložka plejlista"; + +"minutes_count_zero" = "dlitsja nolí minut"; +"minutes_count_one" = "dlitsja odnu minutu"; +"minutes_count_few" = "dlitsja $1 minuty"; +"minutes_count_many" = "dlitsja $1 minut"; +"minutes_count_other" = "dlitsja $1 minut"; + +"listens_count_zero" = "net proslušivanij"; +"listens_count_one" = "odno proslušivanie"; +"listens_count_few" = "$1 proslušivanija"; +"listens_count_many" = "$1 proslušivanij"; +"listens_count_other" = "$1 proslušivanij"; + +"add_audio_to_club" = "Dobavití audio v gruppu"; +"what_club_add" = "V kakuju gruppu vy hotite dobavití pesnju?"; +"group_has_audio" = "U gruppy uže estí eta pesnja."; +"group_hasnt_audio" = "U gruppy net etoj pesni."; + +"by_name" = "po kompozicijam"; +"by_performer" = "po ispolnitelju"; +"no_access_clubs" = "Net grupp, gde vy javljaetesí administratorom."; +"audio_successfully_uploaded" = "Audio uspešno zagruženo i na dannyj moment obrabatyvaetsja."; + +"broadcast_audio" = "Translirovatí audio v status"; +"sure_delete_playlist" = "Vy dejstvitelíno hotite udalití etot plejlist?"; +"edit_audio" = "Redaktirovatí audiozapisí"; +"audios_group" = "Audiozapisi gruppy"; +"playlists_group" = "Plejlisty gruppy"; + +"play_tip" = "Proigryvanie/pauza"; +"repeat_tip" = "Povtorenie"; +"shuffle_tip" = "Peremešatí"; +"mute_tip" = "Zaglušití"; + /* Notifications */ "feedback" = "Otvety";