some change

This commit is contained in:
Jonas Heinrich 2020-11-23 22:19:07 +01:00
commit ce5cdfbc51
31 changed files with 2271 additions and 1762 deletions

View File

@ -1,3 +1,13 @@
## 1.0.0 - 2020-11
### Added
- Complete new rewrite in VueJS
Many bugs fixed and some features added.
- Dashboard integration
- Unified search support
- Show recent played stations
- Show sidebar with further infos
- Improved performance and stability
## 0.6.6 - 2020-02 ## 0.6.6 - 2020-02
### Added ### Added
- French translation - French translation

View File

@ -1,7 +0,0 @@
Copyright 2018 Jonas Heinrich
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Jonas Heinrich
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -50,3 +50,5 @@ clean:
clean-dev: clean-dev:
rm -rf node_modules rm -rf node_modules
appstore:
krankerl package

View File

@ -5,7 +5,7 @@
<name>Radio</name> <name>Radio</name>
<summary>Radio listening app</summary> <summary>Radio listening app</summary>
<description>Listening to your favorite radio stations in Nextcloud</description> <description>Listening to your favorite radio stations in Nextcloud</description>
<version>1.0</version> <version>1.0.0</version>
<licence>MIT</licence> <licence>MIT</licence>
<author mail="onny@project-insanity.org" >Jonas Heinrich</author> <author mail="onny@project-insanity.org" >Jonas Heinrich</author>
<namespace>Radio</namespace> <namespace>Radio</namespace>

View File

@ -1,25 +1,4 @@
<?php <?php
/**
* @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
return [ return [
'resources' => [ 'resources' => [

View File

@ -1,7 +1,7 @@
.icon-mail { .icon-radio {
background-image: url(./../img/radio-trans.svg); background-image: url(./../img/radio-trans.svg);
} }
body.theme--dark .icon-mail { body.theme--dark .icon-radio {
background-image: url(./../img/radio.svg); background-image: url(./../img/radio.svg);
} }

View File

@ -1,26 +1,3 @@
/**
* @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
@include icon-black-white('recent', 'radio', 1); @include icon-black-white('recent', 'radio', 1);
@include icon-black-white('radio', 'radio', 1); @include icon-black-white('radio', 'radio', 1);
@include icon-black-white('radio-trans', 'radio', 1); @include icon-black-white('radio-trans', 'radio', 1);

6
krankerl.toml Normal file
View File

@ -0,0 +1,6 @@
[package]
before_cmds = [
"composer install --no-dev -o",
"npm install --deps",
"npm run build",
]

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace OCA\Radio\AppInfo; namespace OCA\Radio\AppInfo;
use OC\Security\CSP\ContentSecurityPolicy;
use OCA\Radio\Search\SearchProvider; use OCA\Radio\Search\SearchProvider;
use OCP\AppFramework\App; use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootContext;
@ -30,8 +31,24 @@ class Application extends App implements IBootstrap {
return $c->get(IRequest::class); return $c->get(IRequest::class);
}); });
$this->registerCsp();
} }
public function boot(IBootContext $context): void { public function boot(IBootContext $context): void {
} }
/**
* Allow radio-browser hosts in the csp
*
* @throws \OCP\AppFramework\QueryException
*/
public function registerCsp() {
$manager = $this->getContainer()->getServer()->getContentSecurityPolicyManager();
$policy = new ContentSecurityPolicy();
$policy->addAllowedConnectDomain('https://de1.api.radio-browser.info');
$policy->addAllowedImageDomain('*');
$policy->addAllowedMediaDomain('*');
$manager->addDefaultPolicy($policy);
}
} }

View File

@ -44,20 +44,29 @@ class FavoriteController extends Controller {
/** /**
* @NoAdminRequired * @NoAdminRequired
*/ */
public function create(string $stationuuid, string $name, string $favicon, string $urlresolved): DataResponse { public function create(string $stationuuid, string $name, string $favicon, string $urlresolved,
return new DataResponse($this->service->create($stationuuid, $name, string $bitrate, string $country, string $language, string $homepage,
$favicon, $urlresolved, $this->userId)); string $codec, string $tags): DataResponse {
} return new DataResponse($this->service->create($stationuuid, $name,
$favicon, $urlresolved, $bitrate, $country, $language, $homepage, $codec,
$tags, $this->userId));
}
/** /**
* @NoAdminRequired * @NoAdminRequired
*/ */
public function update(int $id, string $stationuuid, public function update(int $id, string $stationuuid,
string $name, string $favicon, string $urlresolved): DataResponse { string $name, string $favicon, string $urlresolved,
return $this->handleNotFound(function () use ($id, $stationuuid, $name, $favicon, $urlresolved) { string $bitrate, string $country, string $language, string $homepage,
return $this->service->update($id, $stationuuid, $name, $favicon, $urlresolved, $this->userId); string $codec, string $tags): DataResponse {
}); return $this->handleNotFound(function () use ($id, $stationuuid, $name,
} $favicon, $urlresolved, $bitrate, $country, $language, $homepage, $codec,
$tags) {
return $this->service->update($id, $stationuuid, $name, $favicon,
$urlresolved, $bitrate, $country, $language, $homepage, $codec,
$tags, $this->userId);
});
}
/** /**
* @NoAdminRequired * @NoAdminRequired

View File

@ -1,26 +1,5 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/**
* @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Radio\Controller; namespace OCA\Radio\Controller;

View File

@ -44,18 +44,26 @@ class RecentController extends Controller {
/** /**
* @NoAdminRequired * @NoAdminRequired
*/ */
public function create(string $stationuuid, string $name, string $favicon, string $urlresolved): DataResponse { public function create(string $stationuuid, string $name, string $favicon, string $urlresolved,
string $bitrate, string $country, string $language, string $homepage,
string $codec, string $tags): DataResponse {
return new DataResponse($this->service->create($stationuuid, $name, return new DataResponse($this->service->create($stationuuid, $name,
$favicon, $urlresolved, $this->userId)); $favicon, $urlresolved, $bitrate, $country, $language, $homepage, $codec,
$tags, $this->userId));
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
*/ */
public function update(int $id, string $stationuuid, public function update(int $id, string $stationuuid, string $name,
string $name, string $favicon, string $urlresolved): DataResponse { string $favicon, string $urlresolved, string $bitrate, string $country,
return $this->handleNotFound(function () use ($id, $stationuuid, $name, $favicon, $urlresolved) { string $language, string $homepage, string $codec, string $tags): DataResponse {
return $this->service->update($id, $stationuuid, $name, $favicon, $urlresolved, $this->userId); return $this->handleNotFound(function () use ($id, $stationuuid, $name,
$favicon, $urlresolved, $bitrate, $country, $language, $homepage, $codec,
$tags) {
return $this->service->update($id, $stationuuid, $name, $favicon,
$urlresolved, $bitrate, $country, $language, $homepage, $codec,
$tags, $this->userId);
}); });
} }

View File

@ -79,7 +79,7 @@ class SettingsController extends ApiController {
*/ */
public function setMenuState($menuState = ""): JSONResponse { public function setMenuState($menuState = ""): JSONResponse {
if ($menuState == 'SEARCH') { if ($menuState == 'SEARCH') {
return true; return new JSONResponse(['status' => 'success'], Http::STATUS_OK);
}; };
$legalArguments = ['TOP', 'RECENT', 'NEW', 'FAVORITES', 'CATEGORIES']; $legalArguments = ['TOP', 'RECENT', 'NEW', 'FAVORITES', 'CATEGORIES'];
if (!in_array($menuState, $legalArguments)) { if (!in_array($menuState, $legalArguments)) {

View File

@ -4,6 +4,8 @@ namespace OCA\Radio\Dashboard;
use OCP\Dashboard\IWidget; use OCP\Dashboard\IWidget;
use OCP\IL10N; use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Util;
use OCA\Radio\AppInfo\Application; use OCA\Radio\AppInfo\Application;
@ -12,24 +14,29 @@ class RadioWidget implements IWidget {
/** @var IL10N */ /** @var IL10N */
private $l10n; private $l10n;
/** @var IURLGenerator */
private $urlGenerator;
public function __construct( public function __construct(
IL10N $l10n IL10N $l10n,
IURLGenerator $urlGenerator
) { ) {
$this->l10n = $l10n; $this->l10n = $l10n;
$this->urlGenerator = $urlGenerator;
} }
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function getId(): string { public function getId(): string {
return 'radio'; return Application::APP_ID;
} }
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function getTitle(): string { public function getTitle(): string {
return $this->l10n->t('Favorite Radio stations'); return $this->l10n->t('Radio stations');
} }
/** /**
@ -50,14 +57,14 @@ class RadioWidget implements IWidget {
* @inheritDoc * @inheritDoc
*/ */
public function getUrl(): ?string { public function getUrl(): ?string {
return \OC::$server->getURLGenerator()->linkToRoute('settings.PersonalSettings.index', ['section' => 'connected-accounts']); return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute('radio.page.index'));
} }
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function load(): void { public function load(): void {
\OC_Util::addScript(Application::APP_ID, Application::APP_ID . '-dashboard'); Util::addScript(Application::APP_ID, 'radio-dashboard');
\OC_Util::addStyle(Application::APP_ID, 'dashboard'); Util::addStyle(Application::APP_ID, 'dashboard');
} }
} }

View File

@ -27,6 +27,15 @@ class RecentMapper extends QBMapper {
->addSelect('name') ->addSelect('name')
->addSelect('favicon') ->addSelect('favicon')
->addSelect('urlresolved') ->addSelect('urlresolved')
<<<<<<< HEAD
=======
->addSelect('bitrate')
->addSelect('country')
->addSelect('language')
->addSelect('homepage')
->addSelect('codec')
->addSelect('tags')
>>>>>>> nc20
->from('recent') ->from('recent')
->orderBy('id', 'DESC') ->orderBy('id', 'DESC')
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)))
@ -45,6 +54,15 @@ class RecentMapper extends QBMapper {
->addSelect('name') ->addSelect('name')
->addSelect('favicon') ->addSelect('favicon')
->addSelect('urlresolved') ->addSelect('urlresolved')
<<<<<<< HEAD
=======
->addSelect('bitrate')
->addSelect('country')
->addSelect('language')
->addSelect('homepage')
->addSelect('codec')
->addSelect('tags')
>>>>>>> nc20
->from('recent') ->from('recent')
->orderBy('id', 'DESC') ->orderBy('id', 'DESC')
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))); ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));

View File

@ -11,6 +11,12 @@ class Station extends Entity implements JsonSerializable {
protected $name; protected $name;
protected $favicon; protected $favicon;
protected $urlresolved; protected $urlresolved;
protected $bitrate;
protected $country;
protected $language;
protected $homepage;
protected $codec;
protected $tags;
protected $userId; protected $userId;
public function jsonSerialize(): array { public function jsonSerialize(): array {
@ -19,7 +25,13 @@ class Station extends Entity implements JsonSerializable {
'stationuuid' => $this->stationuuid, 'stationuuid' => $this->stationuuid,
'name' => $this->name, 'name' => $this->name,
'favicon' => $this->favicon, 'favicon' => $this->favicon,
'urlresolved' => $this->urlresolved 'urlresolved' => $this->urlresolved,
'bitrate' => $this->bitrate,
'country' => $this->country,
'language' => $this->language,
'homepage' => $this->homepage,
'codec' => $this->codec,
'tags' => $this->tags
]; ];
} }
} }

View File

@ -38,6 +38,12 @@ class Version000000Date20181013124731 extends SimpleMigrationStep {
]); ]);
$table->addColumn('favicon', 'text'); $table->addColumn('favicon', 'text');
$table->addColumn('urlresolved', 'text'); $table->addColumn('urlresolved', 'text');
$table->addColumn('bitrate', 'text');
$table->addColumn('country', 'text');
$table->addColumn('language', 'text');
$table->addColumn('homepage', 'text');
$table->addColumn('codec', 'text');
$table->addColumn('tags', 'text');
$table->setPrimaryKey(['id']); $table->setPrimaryKey(['id']);
$table->addIndex(['user_id'], 'favorites_user_id_index'); $table->addIndex(['user_id'], 'favorites_user_id_index');
@ -60,6 +66,12 @@ class Version000000Date20181013124731 extends SimpleMigrationStep {
]); ]);
$table->addColumn('favicon', 'text'); $table->addColumn('favicon', 'text');
$table->addColumn('urlresolved', 'text'); $table->addColumn('urlresolved', 'text');
$table->addColumn('bitrate', 'text');
$table->addColumn('country', 'text');
$table->addColumn('language', 'text');
$table->addColumn('homepage', 'text');
$table->addColumn('codec', 'text');
$table->addColumn('tags', 'text');
$table->setPrimaryKey(['id']); $table->setPrimaryKey(['id']);
$table->addIndex(['user_id'], 'recent_user_id_index'); $table->addIndex(['user_id'], 'recent_user_id_index');

View File

@ -45,23 +45,37 @@ class FavoriteService {
} }
} }
public function create($stationuuid, $name, $favicon, $urlresolved, $userId) { public function create($stationuuid, $name, $favicon, $urlresolved,
$bitrate, $country, $language, $homepage, $codec, $tags, $userId) {
$station = new Station(); $station = new Station();
$station->setStationuuid($stationuuid); $station->setStationuuid($stationuuid);
$station->setName($name); $station->setName($name);
$station->setFavicon($favicon); $station->setFavicon($favicon);
$station->setUrlresolved($urlresolved); $station->setUrlresolved($urlresolved);
$station->setBitrate($bitrate);
$station->setCountry($country);
$station->setLanguage($language);
$station->setHomepage($homepage);
$station->setCodec($codec);
$station->setTags($tags);
$station->setUserId($userId); $station->setUserId($userId);
return $this->mapper->insert($station); return $this->mapper->insert($station);
} }
public function update($id, $stationuuid, $name, $favicon, $urlresolved, $userId) { public function update($id, $stationuuid, $name, $favicon, $urlresolved,
$bitrate, $country, $language, $homepage, $codec, $tags, $userId) {
try { try {
$station = $this->mapper->find($id, $userId); $station = $this->mapper->find($id, $userId);
$station->setStationuuid($stationuuid); $station->setStationuuid($stationuuid);
$station->setName($name); $station->setName($name);
$station->setFavicon($favicon); $station->setFavicon($favicon);
$station->setUrlresolved($urlresolved); $station->setUrlresolved($urlresolved);
$station->setBitrate($bitrate);
$station->setCountry($country);
$station->setLanguage($language);
$station->setHomepage($homepage);
$station->setCodec($codec);
$station->setTags($tags);
return $this->mapper->update($station); return $this->mapper->update($station);
} catch (Exception $e) { } catch (Exception $e) {
$this->handleException($e); $this->handleException($e);

View File

@ -45,23 +45,37 @@ class RecentService {
} }
} }
public function create($stationuuid, $name, $favicon, $urlresolved, $userId) { public function create($stationuuid, $name, $favicon, $urlresolved,
$bitrate, $country, $language, $homepage, $codec, $tags, $userId) {
$station = new Station(); $station = new Station();
$station->setStationuuid($stationuuid); $station->setStationuuid($stationuuid);
$station->setName($name); $station->setName($name);
$station->setFavicon($favicon); $station->setFavicon($favicon);
$station->setUrlresolved($urlresolved); $station->setUrlresolved($urlresolved);
$station->setBitrate($bitrate);
$station->setCountry($country);
$station->setLanguage($language);
$station->setHomepage($homepage);
$station->setCodec($codec);
$station->setTags($tags);
$station->setUserId($userId); $station->setUserId($userId);
return $this->mapper->insert($station); return $this->mapper->insert($station);
} }
public function update($id, $stationuuid, $name, $favicon, $urlresolved, $userId) { public function update($id, $stationuuid, $name, $favicon, $urlresolved,
$bitrate, $country, $language, $homepage, $codec, $tags, $userId) {
try { try {
$station = $this->mapper->find($id, $userId); $station = $this->mapper->find($id, $userId);
$station->setStationuuid($stationuuid); $station->setStationuuid($stationuuid);
$station->setName($name); $station->setName($name);
$station->setFavicon($favicon); $station->setFavicon($favicon);
$station->setUrlresolved($urlresolved); $station->setUrlresolved($urlresolved);
$station->setBitrate($bitrate);
$station->setCountry($country);
$station->setLanguage($language);
$station->setHomepage($homepage);
$station->setCodec($codec);
$station->setTags($tags);
return $this->mapper->update($station); return $this->mapper->update($station);
} catch (Exception $e) { } catch (Exception $e) {
$this->handleException($e); $this->handleException($e);

2902
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,16 +30,22 @@
"stylelint:fix": "stylelint src --fix" "stylelint:fix": "stylelint src --fix"
}, },
"dependencies": { "dependencies": {
"@nextcloud/axios": "^1.4.0", "@nextcloud/axios": "^1.5.0",
"@nextcloud/dialogs": "^3.0.0", "@nextcloud/dialogs": "^3.1.1",
"@nextcloud/l10n": "^1.4.1", "@nextcloud/l10n": "^1.4.1",
"@nextcloud/moment": "^1.1.1",
"@nextcloud/router": "^1.2.0", "@nextcloud/router": "^1.2.0",
"@nextcloud/vue": "^2.7.0", "@nextcloud/vue": "^2.9.0",
"@nextcloud/vue-dashboard": "^1.0.1",
"axios": "^0.21.0",
"howler": "^2.2.1", "howler": "^2.2.1",
"music-metadata": "^7.4.1", "music-metadata": "^7.5.0",
"style-loader": "^2.0.0",
"vue": "^2.6.12", "vue": "^2.6.12",
"vue-blurhash": "^0.1.2", "vue-blurhash": "^0.1.2",
"vue-router": "^3.4.7", "vue-clipboard2": "^0.3.1",
"vue-resize-observer": "^1.0.32",
"vue-router": "^3.4.9",
"vuex": "^3.5.1" "vuex": "^3.5.1"
}, },
"browserslist": [ "browserslist": [
@ -49,38 +55,38 @@
"node": ">=10.0.0" "node": ">=10.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.11.6", "@babel/core": "^7.12.3",
"@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/preset-env": "^7.11.5", "@babel/preset-env": "^7.12.1",
"@nextcloud/browserslist-config": "^1.0.0", "@nextcloud/browserslist-config": "^1.0.0",
"@nextcloud/eslint-config": "^2.2.0", "@nextcloud/eslint-config": "^2.2.0",
"@nextcloud/eslint-plugin": "^1.5.0", "@nextcloud/eslint-plugin": "^1.5.0",
"@nextcloud/webpack-vue-config": "^1.1.0", "@nextcloud/webpack-vue-config": "^1.4.1",
"@vue/test-utils": "^1.1.0", "@vue/test-utils": "^1.1.1",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-loader": "^8.1.0", "babel-loader": "^8.2.1",
"css-loader": "^3.6.0", "css-loader": "^3.6.0",
"eslint": "^6.8.0", "eslint": "^6.8.0",
"eslint-config-standard": "^14.1.1", "eslint-config-standard": "^14.1.1",
"eslint-import-resolver-webpack": "^0.13.0", "eslint-import-resolver-webpack": "^0.13.0",
"eslint-loader": "^4.0.2", "eslint-loader": "^4.0.2",
"eslint-plugin-html": "^6.1.0", "eslint-plugin-html": "^6.1.1",
"eslint-plugin-import": "^2.22.1", "eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1", "eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.1", "eslint-plugin-standard": "^4.1.0",
"eslint-plugin-vue": "^6.2.2", "eslint-plugin-vue": "^6.2.2",
"file-loader": "^6.1.1", "file-loader": "^6.2.0",
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"stylelint": "^13.7.2", "stylelint": "^13.8.0",
"stylelint-config-recommended-scss": "^4.2.0", "stylelint-config-recommended-scss": "^4.2.0",
"stylelint-scss": "^3.18.0", "stylelint-scss": "^3.18.0",
"stylelint-webpack-plugin": "^2.1.0", "stylelint-webpack-plugin": "^2.1.1",
"vue-loader": "^15.9.3", "vue-loader": "^15.9.5",
"vue-template-compiler": "^2.6.12", "vue-template-compiler": "^2.6.12",
"webpack": "^4.44.2", "webpack": "^4.44.2",
"webpack-cli": "^3.3.12", "webpack-cli": "^3.3.12",
"webpack-merge": "^5.2.0" "webpack-merge": "^5.4.0"
} }
} }

View File

@ -0,0 +1,106 @@
<template>
<DashboardWidget :items="items"
:show-more-url="showMoreUrl"
:show-more-text="title"
:loading="state === 'loading'">
<template #empty-content>
<EmptyContent
v-if="emptyContentMessage"
:icon="emptyContentIcon">
<template #desc>
{{ emptyContentMessage }}
</template>
</EmptyContent>
</template>
</DashboardWidget>
</template>
<script>
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs'
import { DashboardWidget } from '@nextcloud/vue-dashboard'
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
export default {
name: 'Dashboard',
components: {
DashboardWidget,
EmptyContent,
},
props: {
title: {
type: String,
required: false,
default: 'radio',
},
},
data() {
return {
notifications: [],
showMoreUrl: generateUrl('/apps/radio/#/favorites'),
state: 'loading',
darkThemeColor: OCA.Accessibility.theme === 'dark' ? 'ffffff' : '000000',
}
},
computed: {
items() {
return this.notifications.map((n) => {
return {
id: n.id,
targetUrl: generateUrl('/apps/radio/#/favorites'),
avatarUrl: n.favicon,
mainText: n.name,
subText: n.tags.replaceAll(',', ', '),
}
})
},
emptyContentMessage() {
if (this.state === 'error') {
return t('radio', 'Error fetching favorite stations')
} else if (this.state === 'ok') {
return t('radio', 'No favorites added yet!')
}
return ''
},
emptyContentIcon() {
if (this.state === 'error') {
return 'icon-close'
} else if (this.state === 'ok') {
return 'icon-checkmark'
}
return 'icon-checkmark'
},
},
beforeMount() {
this.fetchNotifications()
},
methods: {
fetchNotifications() {
const req = {}
axios.get(generateUrl('/apps/radio/api/favorites'), req).then((response) => {
this.processNotifications(response.data)
this.state = 'ok'
}).catch((error) => {
clearInterval(this.loop)
if (error.response && error.response.status === 401) {
showError(t('radio', 'Failed to fetch favorite radio stations'))
this.state = 'error'
} else {
// there was an error in notif processing
console.debug(error)
}
})
},
processNotifications(newNotifications) {
this.notifications = newNotifications
},
},
}
</script>

View File

@ -4,7 +4,8 @@
:station-data="tableData" /> :station-data="tableData" />
<AppContent> <AppContent>
<Table <Table
v-if="!pageLoading && tableData.length > 0" v-show="!pageLoading && tableData.length > 0"
v-resize="onResize"
:station-data="tableData" :station-data="tableData"
:favorites="favorites" :favorites="favorites"
@doPlay="doPlay" @doPlay="doPlay"
@ -13,27 +14,22 @@
<EmptyContent <EmptyContent
v-if="pageLoading" v-if="pageLoading"
icon="icon-loading" /> icon="icon-loading" />
<template <EmptyContent
v-if="tableData.length === 0 && !pageLoading"> v-if="tableData.length === 0 && !pageLoading"
<EmptyContent :icon="emptyContentIcon">
v-show="$route.name==='FAVORITES'" {{ emptyContentMessage }}
icon="icon-star"> <template #desc>
No favorites yet {{ emptyContentDesc }}
<template #desc> </template>
Stations you mark as favorite will show up here </EmptyContent>
</template>
</EmptyContent>
<EmptyContent
v-if="tableData.length === 0 && !pageLoading"
v-show="$route.name==='SEARCH'"
icon="icon-search">
No search results
<template #desc>
No stations were found matching your search term
</template>
</EmptyContent>
</template>
</AppContent> </AppContent>
<<<<<<< HEAD
=======
<Sidebar
:show-sidebar="showSidebar"
:sidebar-station="sidebarStation"
@toggleSidebar="toggleSidebar" />
>>>>>>> nc20
</Content> </Content>
</template> </template>
@ -41,15 +37,25 @@
import Content from '@nextcloud/vue/dist/Components/Content' import Content from '@nextcloud/vue/dist/Components/Content'
import AppContent from '@nextcloud/vue/dist/Components/AppContent' import AppContent from '@nextcloud/vue/dist/Components/AppContent'
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent' import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
<<<<<<< HEAD
import Navigation from './Navigation' import Navigation from './Navigation'
import Table from './Table' import Table from './Table'
import { Howl } from 'howler' import { Howl } from 'howler'
=======
>>>>>>> nc20
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs' import { showError } from '@nextcloud/dialogs'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import Navigation from './Navigation'
import Table from './Table'
import Sidebar from './Sidebar'
import { Howl, Howler } from 'howler'
let audioPlayer = null let audioPlayer = null
const requesttoken = axios.defaults.headers.requesttoken
export default { export default {
name: 'Main', name: 'Main',
@ -59,24 +65,53 @@ export default {
AppContent, AppContent,
Table, Table,
EmptyContent, EmptyContent,
<<<<<<< HEAD
=======
Sidebar,
>>>>>>> nc20
}, },
data: () => ({ data: () => ({
tableData: [], tableData: [],
pageLoading: false, pageLoading: false,
blurHashes: require('../assets/blurHashes.json'), blurHashes: require('../assets/blurHashes.json'),
favorites: null, favorites: [],
showSidebar: false, showSidebar: false,
sidebarStation: [], sidebarStation: {},
queryParams: {},
}), }),
computed: { computed: {
player() { player() {
return this.$store.state.player return this.$store.state.player
}, },
stationTags() { emptyContentMessage() {
if (this.sidebarStation.tags) { if (this.$route.name === 'FAVORITES') {
return this.sidebarStation.tags.replaceAll(',', ', ') return t('radio', 'No favorites yet')
} else if (this.$route.name === 'RECENT') {
return t('radio', 'No recent stations yet')
} else if (this.$route.name === 'SEARCH') {
return t('radio', 'No search results')
} }
return '' return 'No stations here'
},
emptyContentIcon() {
if (this.$route.name === 'FAVORITES') {
return 'icon-star'
} else if (this.$route.name === 'RECENT') {
return 'icon-recent'
} else if (this.$route.name === 'SEARCH') {
return 'icon-search'
}
return 'icon-radio'
},
emptyContentDesc() {
if (this.$route.name === 'FAVORITES') {
return t('radio', 'Stations you mark as favorite will show up here')
} else if (this.$route.name === 'RECENT') {
return t('radio', 'Stations you recently played will show up here')
} else if (this.$route.name === 'SEARCH') {
return t('radio', 'No stations were found matching your search term')
}
return t('radio', 'No stations here')
}, },
}, },
watch: { watch: {
@ -104,6 +139,19 @@ export default {
}, },
methods: { methods: {
onResize({ width, height }) {
const contentHeight = document.getElementById('app-content-vue').scrollHeight
const tableHeight = height
if (tableHeight < contentHeight) {
this.preFill()
}
},
preFill() {
const route = this.$route
this.loadStations(route.name)
},
async onRoute() { async onRoute() {
this.tableData = [] this.tableData = []
this.pageLoading = true this.pageLoading = true
@ -124,6 +172,7 @@ export default {
stationid = this.favorites[i][0] stationid = this.favorites[i][0]
} }
} }
axios.defaults.headers.requesttoken = requesttoken
await axios await axios
.delete(generateUrl(`/apps/radio/api/favorites/${stationid}`)) .delete(generateUrl(`/apps/radio/api/favorites/${stationid}`))
.then(response => { .then(response => {
@ -134,13 +183,26 @@ export default {
} }
} else { } else {
try { try {
let stationSrc = ''
if (!station.url_resolved) {
stationSrc = station.urlresolved
} else {
stationSrc = station.url_resolved
}
const stationMap = { const stationMap = {
id: -1, id: -1,
name: station.name, name: station.name.toString(),
urlresolved: station.url_resolved, urlresolved: stationSrc.toString(),
favicon: station.favicon, favicon: station.favicon.toString(),
stationuuid: station.stationuuid, stationuuid: station.stationuuid.toString(),
bitrate: station.bitrate.toString(),
country: station.country.toString(),
language: station.language.toString(),
homepage: station.homepage.toString(),
codec: station.codec.toString(),
tags: station.tags.toString(),
} }
axios.defaults.headers.requesttoken = requesttoken
await axios await axios
.post(generateUrl('/apps/radio/api/favorites'), stationMap) .post(generateUrl('/apps/radio/api/favorites'), stationMap)
.then(response => { .then(response => {
@ -157,8 +219,10 @@ export default {
* @param {Object} station Station object * @param {Object} station Station object
*/ */
async doPlay(station) { async doPlay(station) {
const vm = this const vm = this
vm.$store.dispatch('isBuffering', true)
if (audioPlayer !== null) { if (audioPlayer !== null) {
audioPlayer.fade(vm.player.volume, 0, 500) audioPlayer.fade(vm.player.volume, 0, 500)
} }
@ -170,14 +234,11 @@ export default {
} else { } else {
stationSrc = station.url_resolved stationSrc = station.url_resolved
} }
Howler.unload()
audioPlayer = new Howl({ audioPlayer = new Howl({
src: stationSrc, src: stationSrc,
html5: true,
volume: vm.player.volume, volume: vm.player.volume,
/* onfade() { // FIXME
if (this.volume() === 0) {
this.unload()
}
}, */
onplay() { onplay() {
vm.$store.dispatch('isPlaying', true) vm.$store.dispatch('isPlaying', true)
vm.$store.dispatch('isBuffering', false) vm.$store.dispatch('isBuffering', false)
@ -186,13 +247,10 @@ export default {
vm.$store.dispatch('isPlaying', false) vm.$store.dispatch('isPlaying', false)
vm.$store.dispatch('isBuffering', false) vm.$store.dispatch('isBuffering', false)
}, },
onload() { onend() {
vm.$store.dispatch('isPlaying', true) showError(t('radio', 'Lost connection to radio station, retrying ...'))
vm.$store.dispatch('isBuffering', true)
},
onstop() {
vm.$store.dispatch('isPlaying', false) vm.$store.dispatch('isPlaying', false)
vm.$store.dispatch('isBuffering', false) vm.$store.dispatch('isBuffering', true)
}, },
}) })
audioPlayer.unload() audioPlayer.unload()
@ -200,17 +258,35 @@ export default {
audioPlayer.fade(0, vm.player.volume, 500) audioPlayer.fade(0, vm.player.volume, 500)
/* Count click */ /* Count click */
axios.get(this.$apiUrl + '/json/url/' + station.stationuuid) try {
delete axios.defaults.headers.requesttoken
axios.get(this.$apiUrl + '/json/url/' + station.stationuuid)
} catch (error) {
showError(t('radio', 'Unable to count play on remote API'))
}
/* Put into recent stations */ /* Put into recent stations */
try { try {
let stationSrc = ''
if (!station.url_resolved) {
stationSrc = station.urlresolved
} else {
stationSrc = station.url_resolved
}
const stationMap = { const stationMap = {
id: -1, id: -1,
name: station.name, name: station.name.toString(),
urlresolved: station.url_resolved, urlresolved: stationSrc.toString(),
favicon: station.favicon, favicon: station.favicon.toString(),
stationuuid: station.stationuuid, stationuuid: station.stationuuid.toString(),
bitrate: station.bitrate.toString(),
country: station.country.toString(),
language: station.language.toString(),
homepage: station.homepage.toString(),
codec: station.codec.toString(),
tags: station.tags.toString(),
} }
axios.defaults.headers.requesttoken = requesttoken
await axios await axios
.post(generateUrl('/apps/radio/api/recent'), stationMap) .post(generateUrl('/apps/radio/api/recent'), stationMap)
} catch (error) { } catch (error) {
@ -219,10 +295,6 @@ export default {
}, },
/**
* Fetching radio stations using Radio-Browser.info API
* @param {String} menuState Entries to load
*/
async loadStations(menuState = 'TOP') { async loadStations(menuState = 'TOP') {
const vm = this const vm = this
@ -231,36 +303,63 @@ export default {
let sortBy = 'clickcount' let sortBy = 'clickcount'
if (vm.$route.name === 'CATEGORIES') { if (vm.$route.name === 'CATEGORIES') {
vm.tableData = [ if (vm.$route.path === '/categories') {
{ vm.tableData = [
name: 'Countries', {
type: 'folder', name: t('radio', 'Countries'),
path: '#/categories/countries', type: 'folder',
}, path: '/categories/countries',
{ },
name: 'States', {
type: 'folder', name: t('radio', 'States'),
path: '#/categories/states', type: 'folder',
}, path: '/categories/states',
{ },
name: 'Languages', {
type: 'folder', name: t('radio', 'Languages'),
path: '#/categories/languages', type: 'folder',
}, path: '/categories/languages',
{ },
name: 'Tags', {
type: 'folder', name: t('radio', 'Tags'),
path: '#/categories/tags', type: 'folder',
}, path: '/categories/tags',
] },
vm.pageLoading = false ]
return true vm.pageLoading = false
return true
} else if (vm.$route.params.category === 'tags') {
if (vm.$route.params.query) {
queryURI = this.$apiUrl + '/json/stations/search?tag=' + vm.$route.params.query + '&tagExact=true'
} else {
queryURI = this.$apiUrl + '/json/tags'
}
} else if (vm.$route.params.category === 'countries') {
if (vm.$route.params.query) {
queryURI = this.$apiUrl + '/json/stations/search?country=' + vm.$route.params.query + '&countryExact=true'
} else {
queryURI = this.$apiUrl + '/json/countries'
}
} else if (vm.$route.params.category === 'states') {
if (vm.$route.params.query) {
queryURI = this.$apiUrl + '/json/stations/search?state=' + vm.$route.params.query + '&stateExact=true'
} else {
queryURI = this.$apiUrl + '/json/states'
}
} else if (vm.$route.params.category === 'languages') {
if (vm.$route.params.query) {
queryURI = this.$apiUrl + '/json/stations/search?language=' + vm.$route.params.query + '&languageExact=true'
} else {
queryURI = this.$apiUrl + '/json/languages'
}
}
} }
// FIXME: Skip loading more stations on certain sites // Skip loading more stations on certain sites
if (vm.tableData.length > 0 if (vm.tableData.length > 0
&& (vm.$route.name === 'FAVORITES' && (vm.$route.name === 'FAVORITES'
|| vm.$route.name === 'RECENT')) { || vm.$route.name === 'RECENT'
|| vm.$route.name === 'CATEGORIES')) {
return true return true
} }
@ -277,26 +376,46 @@ export default {
queryURI = generateUrl('/apps/radio/api/recent') queryURI = generateUrl('/apps/radio/api/recent')
} }
await axios.get(queryURI, { if (menuState !== 'CATEGORIES') {
params: { vm.queryParams = {
limit: 20, limit: 20,
order: sortBy, order: sortBy,
reverse: true, reverse: true,
offset: vm.tableData.length, offset: vm.tableData.length,
}, }
}) } else {
.then(function(response) { vm.queryParams = {}
for (let i = 0; i < response.data.length; i++) { }
const obj = response.data[i]
let blurHash = vm.blurHashes[obj.stationuuid] try {
if (!blurHash) { if (menuState === 'FAVORITES' || menuState === 'RECENT') {
blurHash = 'L1TSUA?bj[?b~qfQfQj[ayfQfQfQ' axios.defaults.headers.requesttoken = requesttoken
} } else {
response.data[i].blurHash = blurHash delete axios.defaults.headers.requesttoken
} }
vm.tableData = vm.tableData.concat(response.data) await axios.get(queryURI, {
vm.pageLoading = false params: vm.queryParams,
}) })
.then(function(response) {
for (let i = 0; i < response.data.length; i++) {
const obj = response.data[i]
if (obj.stationuuid) {
let blurHash = vm.blurHashes[obj.stationuuid]
if (!blurHash) {
blurHash = 'L1TSUA?bj[?b~qfQfQj[ayfQfQfQ'
}
response.data[i].blurHash = blurHash
} else {
response.data[i].type = 'folder'
response.data[i].path = vm.$route.path + '/' + obj.name
}
}
vm.tableData = vm.tableData.concat(response.data)
vm.pageLoading = false
})
} catch (error) {
showError(t('radio', 'Could not fetch stations from remote API'))
}
}, },
/** /**
@ -310,31 +429,39 @@ export default {
} }
} }
}, },
loadSettings() { loadSettings() {
axios.defaults.headers.common = { // axios.defaults.headers.common = {
'User-Agent': 'Nextcloud Radio App/1.0', // FIXME: Reference global version number // 'User-Agent': 'Nextcloud Radio App/' + this.$version,
} // }
this.$store.dispatch('getVolumeState') this.$store.dispatch('getVolumeState')
}, },
async loadFavorites() { async loadFavorites() {
const vm = this const vm = this
await axios.get(generateUrl('/apps/radio/api/favorites')) try {
.then(function(response) { axios.defaults.headers.requesttoken = requesttoken
const favorites = [] await axios.get(generateUrl('/apps/radio/api/favorites'))
for (let i = 0, len = response.data.length; i < len; i++) { .then(function(response) {
favorites.push([response.data[i].id, response.data[i].stationuuid]) const favorites = []
} for (let i = 0, len = response.data.length; i < len; i++) {
vm.favorites = favorites favorites.push([response.data[i].id, response.data[i].stationuuid])
}) }
vm.favorites = favorites
})
} catch (error) {
showError(t('radio', 'Unable to load favorites'))
}
}, },
toggleSidebar(station) { toggleSidebar(station = null) {
this.showSidebar = true if (station) {
this.sidebarStation = station this.showSidebar = true
this.sidebarStation = station
} else {
this.showSidebar = false
}
}, },
}, },
} }
@ -342,27 +469,10 @@ export default {
<style> <style>
/* Make breadcrumbs sticky and intransparent.
Move them to the right and show navigation
toggle on smaller screens */
.breadcrumbs {
background-color: var(--color-main-background-translucent);
z-index: 60;
position: sticky;
position: -webkit-sticky;
top: 50px;
padding-bottom: 5px;
margin-left: 35px;
}
@media only screen and (min-width: 1024px) { @media only screen and (min-width: 1024px) {
.app-navigation-toggle { .app-navigation-toggle {
display: none; display: none;
} }
.breadcrumbs {
margin-left: 0px;
}
} }
</style> </style>

161
src/components/Sidebar.vue Normal file
View File

@ -0,0 +1,161 @@
<template>
<AppSidebar
v-show="showSidebar"
:title="sidebarStation.name"
:subtitle="stationTags"
:background="sidebarStation.favicon"
class="has-preview"
@close="toggleSidebar">
<div class="configBox">
<span class="icon icon-link" />
<span class="title">
{{ t('radio', 'Stream URL') }}
</span>
<div class="content">
<input type="text" :value="urlResolved" disabled="disabled">
<Actions>
<ActionButton icon="icon-clippy" @click="copyLink">
{{ t('radio', 'Copy link to clipboard') }}
</ActionButton>
</Actions>
</div>
</div>
<div class="configBox">
<span class="icon icon-external" />
<span class="title">
{{ t('radio', 'Homepage') }}
</span>
<div class="content">
<span>
<a
:href="sidebarStation.homepage"
target="new">
{{ sidebarStation.homepage }}
</a>
</span>
</div>
</div>
<div class="configBox">
<span class="icon icon-details" />
<span class="title">
{{ t('radio', 'Country & Language') }}
</span>
<div class="content">
<span>
{{ sidebarStation.country }}, {{ sidebarStation.language }}
</span>
</div>
</div>
<div class="configBox">
<span class="icon icon-details" />
<span class="title">
{{ t('radio', 'Codec & Bitrate') }}
</span>
<div class="content">
<span>
{{ sidebarStation.codec }}, {{ sidebarStation.bitrate }}
</span>
</div>
</div>
</AppSidebar>
</template>
<script>
import AppSidebar from '@nextcloud/vue/dist/Components/AppSidebar'
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import { showError, showSuccess } from '@nextcloud/dialogs'
export default {
name: 'Sidebar',
components: {
AppSidebar,
Actions,
ActionButton,
},
props: {
showSidebar: {
type: Boolean,
default() { return false },
},
sidebarStation: {
type: Object,
default() {
return {}
},
},
},
computed: {
urlResolved() {
if (this.sidebarStation.url_resolved) {
return this.sidebarStation.url_resolved
} else {
return this.sidebarStation.urlresolved
}
},
stationTags() {
if (this.sidebarStation.tags) {
return this.sidebarStation.tags.replaceAll(',', ', ')
}
return ''
},
},
methods: {
toggleSidebar(station) {
this.$emit('toggleSidebar')
},
copyLink() {
this.$copyText(this.urlResolved).then(
function() {
showSuccess(t('radio', 'Link copied to clipboard'))
},
function() {
showError(t('radio', 'Error while copying link to clipboard'))
}
)
},
},
}
</script>
<style lang="scss" scoped>
.app-sidebar {
&.has-preview::v-deep {
.app-sidebar-header__figure {
background-size: cover;
height: 200px;
}
}
}
.configBox {
padding: 0 15px;
margin-bottom: 20px;
}
.configBox .title {
font-size: 1.2em;
display: block;
margin-bottom: 15px;
}
.configBox .icon {
float: left;
margin: 4px 7px 0px 0px;
}
.configBox .content {
display: flex;
margin-left: 25px;
}
.configBox .content input {
flex-grow: 1;
}
.configBox .content button {
margin-top: -3px;
}
</style>

View File

@ -1,5 +1,5 @@
<template> <template>
<table v-if="stationData" id="table"> <table>
<thead> <thead>
<tr> <tr>
<th class="iconColumn" /> <th class="iconColumn" />
@ -10,49 +10,67 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <template v-if="!isFolder">
v-for="(station, idx) in stationData" <tr
:key="idx" v-for="(station, idx) in stationData"
:class="{ selected: idx === activeItem}"> :key="idx"
<td @click="doPlay(idx, station)"> :class="{ selected: idx === activeItem}">
<blur-hash-image <td @click="doPlay(idx, station)">
class="stationIcon" <blur-hash-image
width="32" class="stationIcon"
height="32" width="32"
:hash="station.blurHash" height="32"
:src="station.favicon" /> :hash="station.blurHash"
<span :class="{ 'icon-starred': favorites.flat().includes(station.stationuuid) }" /> :src="station.favicon" />
</td> <span :class="{ 'icon-starred': favorites.flat().includes(station.stationuuid) }" />
<td class="nameColumn" @click="doPlay(idx, station)"> </td>
<span class="innernametext"> <td class="nameColumn" @click="doPlay(idx, station)">
{{ station.name }} <span class="innernametext">
</span> {{ station.name }}
</td> </span>
<td class="actionColumn"> </td>
<Actions> <td class="actionColumn">
<ActionButton <Actions>
v-if="!favorites.flat().includes(station.stationuuid)" <ActionButton
icon="icon-star" v-if="!favorites.flat().includes(station.stationuuid)"
:close-after-click="true" icon="icon-star"
@click="doFavor(idx, station)"> :close-after-click="true"
{{ t('radio', 'Add to favorites') }} @click="doFavor(idx, station)">
</ActionButton> {{ t('radio', 'Add to favorites') }}
<ActionButton </ActionButton>
v-if="favorites.flat().includes(station.stationuuid)" <ActionButton
icon="icon-star" v-if="favorites.flat().includes(station.stationuuid)"
:close-after-click="true" icon="icon-star"
@click="doFavor(idx, station)"> :close-after-click="true"
{{ t('radio', 'Remove from favorites') }} @click="doFavor(idx, station)">
</ActionButton> {{ t('radio', 'Remove from favorites') }}
<ActionButton </ActionButton>
icon="icon-info" <ActionButton
:close-after-click="true" icon="icon-info"
@click="toggleSidebar(station)"> :close-after-click="true"
{{ t('radio', 'Details') }} @click="toggleSidebar(station)">
</ActionButton> {{ t('radio', 'Details') }}
</Actions> </ActionButton>
</td> </Actions>
</tr> </td>
</tr>
</template>
<template v-if="isFolder">
<tr
v-for="(station, idx) in stationData"
:key="idx"
@click="changeRoute(station.path)">
<td>
<span class="icon-folder" />
</td>
<td class="nameColumn">
<span class="innernametext">
{{ station.name }}
</span>
</td>
<td class="actionColumn" />
</tr>
</template>
</tbody> </tbody>
</table> </table>
</template> </template>
@ -80,6 +98,16 @@ export default {
data: () => ({ data: () => ({
activeItem: null, activeItem: null,
}), }),
computed: {
isFolder() {
if (this.stationData[0]) {
if (this.stationData[0].type === 'folder') {
return true
}
}
return false
},
},
methods: { methods: {
doPlay(idx, station) { doPlay(idx, station) {
this.activeItem = idx this.activeItem = idx
@ -91,6 +119,9 @@ export default {
toggleSidebar(station) { toggleSidebar(station) {
this.$emit('toggleSidebar', station) this.$emit('toggleSidebar', station)
}, },
changeRoute(path) {
this.$router.push({ path })
},
}, },
} }
</script> </script>
@ -151,6 +182,13 @@ table {
background-repeat: no-repeat; background-repeat: no-repeat;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
cursor: pointer; cursor: pointer;
span.icon-folder {
display: block;
background-size: cover;
width: 30px;
height: 30px;
}
} }
tr { tr {
@ -178,10 +216,15 @@ table {
padding-right: 0px; padding-right: 0px;
} }
td.nameColumn {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 0px;
}
td.nameColumn .innernametext { td.nameColumn .innernametext {
color: var(--color-main-text); color: var(--color-main-text);
text-overflow: ellipsis;
overflow: hidden;
position: relative; position: relative;
vertical-align: top; vertical-align: top;
user-select: none; user-select: none;

15
src/dashboard.js Normal file
View File

@ -0,0 +1,15 @@
import Vue from 'vue'
import router from './router'
import store from './store'
import Dashboard from './components/Dashboard.vue'
document.addEventListener('DOMContentLoaded', () => {
OCA.Dashboard.register('radio', (el) => {
global.Radio = new Vue({
el,
store,
router,
render: h => h(Dashboard),
})
})
})

View File

@ -28,15 +28,21 @@ import { translate, translatePlural } from '@nextcloud/l10n'
import App from './App' import App from './App'
import VueBlurHash from 'vue-blurhash' import VueBlurHash from 'vue-blurhash'
import VueClipboard from 'vue-clipboard2'
import VueResizeObserver from 'vue-resize-observer'
import 'vue-blurhash/dist/vue-blurhash.css' import 'vue-blurhash/dist/vue-blurhash.css'
Vue.prototype.t = translate Vue.prototype.t = translate
Vue.prototype.n = translatePlural Vue.prototype.n = translatePlural
Vue.prototype.OC = window.OC
Vue.prototype.OCA = window.OCA
Vue.prototype.$apiUrl = 'https://de1.api.radio-browser.info' Vue.prototype.$apiUrl = 'https://de1.api.radio-browser.info'
Vue.prototype.$version = '1.0'
Vue.use(VueClipboard)
Vue.use(VueBlurHash) Vue.use(VueBlurHash)
Vue.use(VueResizeObserver)
export default new Vue({ export default new Vue({
el: '#content', el: '#content',

View File

@ -7,6 +7,7 @@ import Main from './components/Main'
import store from './store.js' import store from './store.js'
Vue.use(Router) Vue.use(Router)
const requesttoken = axios.defaults.headers.requesttoken
const router = new Router({ const router = new Router({
base: generateUrl('/apps/radio/'), base: generateUrl('/apps/radio/'),
@ -33,7 +34,7 @@ const router = new Router({
name: 'FAVORITES', name: 'FAVORITES',
}, },
{ {
path: '/categories', path: '/categories/:category?/:query?',
component: Main, component: Main,
name: 'CATEGORIES', name: 'CATEGORIES',
}, },
@ -52,6 +53,7 @@ router.beforeEach((to, from, next) => {
store.dispatch('setMenuState', to.name) store.dispatch('setMenuState', to.name)
next() next()
} else { } else {
axios.defaults.headers.requesttoken = requesttoken
axios axios
.get(generateUrl('/apps/radio/settings/menuState')) .get(generateUrl('/apps/radio/settings/menuState'))
.then(async response => { .then(async response => {

View File

@ -5,6 +5,7 @@ import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
Vue.use(Vuex) Vue.use(Vuex)
const requesttoken = axios.defaults.headers.requesttoken
export default new Vuex.Store({ export default new Vuex.Store({
state: { state: {
@ -52,11 +53,13 @@ export default new Vuex.Store({
state.player.title = title state.player.title = title
}, },
setMenuState(state, menuState) { setMenuState(state, menuState) {
axios.defaults.headers.requesttoken = requesttoken
axios.post(generateUrl('/apps/radio/settings/menuState'), { axios.post(generateUrl('/apps/radio/settings/menuState'), {
menuState, menuState,
}) })
}, },
getMenuState(state) { getMenuState(state) {
axios.defaults.headers.requesttoken = requesttoken
axios axios
.get(generateUrl('/apps/radio/settings/menuState')) .get(generateUrl('/apps/radio/settings/menuState'))
.then(async response => { .then(async response => {
@ -67,11 +70,13 @@ export default new Vuex.Store({
}) })
}, },
setVolumeState(state, volumeState) { setVolumeState(state, volumeState) {
axios.defaults.headers.requesttoken = requesttoken
axios.post(generateUrl('/apps/radio/settings/volumeState'), { axios.post(generateUrl('/apps/radio/settings/volumeState'), {
volumeState, volumeState,
}) })
}, },
getVolumeState(state) { getVolumeState(state) {
axios.defaults.headers.requesttoken = requesttoken
axios axios
.get(generateUrl('/apps/radio/settings/volumeState')) .get(generateUrl('/apps/radio/settings/volumeState'))
.then(async response => { .then(async response => {

View File

@ -1,15 +1,16 @@
const { merge } = require('webpack-merge') const { merge } = require('webpack-merge')
const path = require('path')
const webpackConfig = require('@nextcloud/webpack-vue-config') const webpackConfig = require('@nextcloud/webpack-vue-config')
const config = { const config = {
entry: {
dashboard: path.join(__dirname, 'src', 'dashboard.js'),
},
module: { module: {
rules: [ rules: [
{ {
test: /\.(png|jpg|gif)$/, test: /\.vue$/,
loader: 'file-loader', loader: 'vue-loader',
options: {
name: '[name].[ext]?[hash]',
},
}, },
], ],
}, },