Compare commits
No commits in common. "main" and "1.3.0" have entirely different histories.
@ -1,14 +1,8 @@
|
|||||||
/.idea/
|
*.iml
|
||||||
/*.iml
|
.idea
|
||||||
|
|
||||||
/vendor/
|
|
||||||
/vendor-bin/*/vendor/
|
|
||||||
|
|
||||||
/.php-cs-fixer.cache
|
/.php-cs-fixer.cache
|
||||||
/tests/.phpunit.cache
|
/.php_cs.cache
|
||||||
|
|
||||||
/node_modules/
|
|
||||||
/js/
|
|
||||||
/css/
|
|
||||||
|
|
||||||
/build/
|
/build/
|
||||||
|
/vendor/
|
||||||
|
js/
|
||||||
|
node_modules/
|
||||||
|
9
.eslintignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
*.iml
|
||||||
|
.idea
|
||||||
|
/.php-cs-fixer.cache
|
||||||
|
/.php_cs.cache
|
||||||
|
/build/
|
||||||
|
/vendor/
|
||||||
|
js/
|
||||||
|
node_modules/
|
||||||
|
l10n/
|
@ -1,15 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
extends: [
|
|
||||||
'@nextcloud',
|
|
||||||
'@vue/eslint-config-typescript/recommended',
|
|
||||||
'plugin:pinia/recommended',
|
|
||||||
'plugin:prettier/recommended',
|
|
||||||
],
|
|
||||||
parser: 'vue-eslint-parser',
|
|
||||||
rules: {
|
|
||||||
'jsdoc/require-jsdoc': 'off',
|
|
||||||
'vue/first-attribute-linebreak': 'off',
|
|
||||||
'sort-imports': 'error',
|
|
||||||
'vue/attributes-order': ['error', { alphabetical: true }],
|
|
||||||
},
|
|
||||||
}
|
|
9
.eslintrc.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: [
|
||||||
|
'@nextcloud',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'sort-imports': 'error',
|
||||||
|
'vue/attributes-order': ['error', { alphabetical: true }],
|
||||||
|
},
|
||||||
|
}
|
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/js/* binary
|
@ -14,7 +14,7 @@ jobs:
|
|||||||
|
|
||||||
php:
|
php:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: nextcloud:30
|
container: nextcloud:28
|
||||||
steps:
|
steps:
|
||||||
- run: apt-get update
|
- run: apt-get update
|
||||||
- run: apt-get install -y git nodejs
|
- run: apt-get install -y git nodejs
|
||||||
@ -24,15 +24,21 @@ jobs:
|
|||||||
- run: composer install
|
- run: composer install
|
||||||
- run: composer run lint
|
- run: composer run lint
|
||||||
- run: composer run cs:check
|
- run: composer run cs:check
|
||||||
- run: composer run psalm
|
- run: composer run psalm:check
|
||||||
|
|
||||||
nodejs:
|
nodejs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: skjnldsv/read-package-engines-version-actions@v2
|
||||||
|
id: versions
|
||||||
|
with:
|
||||||
|
fallbackNode: '^20.0.0'
|
||||||
|
fallbackNpm: '^9.0.0'
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "^20"
|
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||||
|
- run: npm i -g npm@${{ steps.versions.outputs.npmVersion }}
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm run stylelint
|
- run: npm run stylelint
|
||||||
@ -41,21 +47,27 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
if: gitea.ref_type == 'tag'
|
if: gitea.ref_type == 'tag'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: nextcloud:30
|
container: nextcloud:28
|
||||||
steps:
|
steps:
|
||||||
- run: apt-get update
|
- run: apt-get update
|
||||||
- run: apt-get install -y git nodejs
|
- run: apt-get install -y git nodejs
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- run: curl -sSLo /usr/local/bin/composer https://getcomposer.org/download/latest-stable/composer.phar
|
- run: curl -sSLo /usr/local/bin/composer https://getcomposer.org/download/latest-stable/composer.phar
|
||||||
- run: chmod +x /usr/local/bin/composer
|
- run: chmod +x /usr/local/bin/composer
|
||||||
|
- uses: skjnldsv/read-package-engines-version-actions@v2
|
||||||
|
id: versions
|
||||||
|
with:
|
||||||
|
fallbackNode: '^20.0.0'
|
||||||
|
fallbackNpm: '^9.0.0'
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "^20"
|
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||||
|
- run: npm i -g npm@${{ steps.versions.outputs.npmVersion }}
|
||||||
- run: make dist
|
- run: make dist
|
||||||
- uses: akkuman/gitea-release-action@v1
|
- uses: akkuman/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
build/artifacts/${{ gitea.event.repository.name }}.tar.gz
|
build/artifacts/repod.tar.gz
|
||||||
- uses: FKLC/sign-files-action@v1.0.0
|
- uses: FKLC/sign-files-action@v1.0.0
|
||||||
with:
|
with:
|
||||||
privateKey: ${{ secrets.PRIVATEKEY }}
|
privateKey: ${{ secrets.PRIVATEKEY }}
|
||||||
@ -63,13 +75,13 @@ jobs:
|
|||||||
extension: .sig
|
extension: .sig
|
||||||
outputFolder: build/artifacts
|
outputFolder: build/artifacts
|
||||||
files: |
|
files: |
|
||||||
build/artifacts/${{ gitea.event.repository.name }}.tar.gz
|
build/artifacts/repod.tar.gz
|
||||||
- id: sign
|
- id: sign
|
||||||
run: echo "SIGNATURE=$(cat build/artifacts/${{ gitea.event.repository.name }}.tar.gz.sig | openssl base64 -A)" >> $GITHUB_OUTPUT
|
run: echo "SIGNATURE=$(cat build/artifacts/repod.tar.gz.sig | openssl base64 -A)" >> $GITHUB_OUTPUT
|
||||||
- uses: actionsflow/axios@v1
|
- uses: actionsflow/axios@v1
|
||||||
with:
|
with:
|
||||||
url: https://apps.nextcloud.com/api/v1/apps/releases
|
url: https://apps.nextcloud.com/api/v1/apps/releases
|
||||||
method: POST
|
method: POST
|
||||||
accept: 200,201
|
accept: 200,201
|
||||||
headers: '{ "Authorization": "Token <<<${{ secrets.TOKEN }}>>>" }'
|
headers: '{ "Authorization": "Token <<<${{ secrets.TOKEN }}>>>" }'
|
||||||
data: '{ "download": "https://git.crystalyx.net/${{ gitea.repository }}/releases/download/<<<${{ gitea.ref_name }}>>>/${{ gitea.event.repository.name }}.tar.gz", "signature": "<<<${{ steps.sign.outputs.SIGNATURE }}>>>" }'
|
data: '{ "download": "https://git.crystalyx.net/Xefir/repod/releases/download/<<<${{ gitea.ref_name }}>>>/repod.tar.gz", "signature": "<<<${{ steps.sign.outputs.SIGNATURE }}>>>" }'
|
||||||
|
18
.gitignore
vendored
@ -1,14 +1,8 @@
|
|||||||
/.idea/
|
*.iml
|
||||||
/*.iml
|
.idea
|
||||||
|
|
||||||
/vendor/
|
|
||||||
/vendor-bin/*/vendor/
|
|
||||||
|
|
||||||
/.php-cs-fixer.cache
|
/.php-cs-fixer.cache
|
||||||
/tests/.phpunit.cache
|
/.php_cs.cache
|
||||||
|
|
||||||
/node_modules/
|
|
||||||
/js/
|
|
||||||
/css/
|
|
||||||
|
|
||||||
/build/
|
/build/
|
||||||
|
/vendor/
|
||||||
|
js/
|
||||||
|
node_modules/
|
||||||
|
18
.l10nignore
@ -1,14 +1,8 @@
|
|||||||
.idea/
|
|
||||||
*.iml
|
*.iml
|
||||||
|
.idea
|
||||||
vendor/
|
/.php-cs-fixer.cache
|
||||||
vendor-bin/*/vendor/
|
/.php_cs.cache
|
||||||
|
|
||||||
.php-cs-fixer.cache
|
|
||||||
tests/.phpunit.cache
|
|
||||||
|
|
||||||
node_modules/
|
|
||||||
js/
|
|
||||||
css/
|
|
||||||
|
|
||||||
build/
|
build/
|
||||||
|
vendor/
|
||||||
|
js/
|
||||||
|
node_modules/
|
||||||
|
@ -13,7 +13,9 @@ class MyConfig extends Config
|
|||||||
$rules = parent::getRules();
|
$rules = parent::getRules();
|
||||||
$rules['@PhpCsFixer'] = true;
|
$rules['@PhpCsFixer'] = true;
|
||||||
$rules['curly_braces_position']['classes_opening_brace'] = 'next_line_unless_newline_at_signature_end';
|
$rules['curly_braces_position']['classes_opening_brace'] = 'next_line_unless_newline_at_signature_end';
|
||||||
|
$rules['phpdoc_separation'] = false;
|
||||||
$rules['phpdoc_to_comment'] = false;
|
$rules['phpdoc_to_comment'] = false;
|
||||||
|
$rules['single_line_comment_style'] = false;
|
||||||
return $rules;
|
return $rules;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -21,11 +23,10 @@ class MyConfig extends Config
|
|||||||
$config = new MyConfig();
|
$config = new MyConfig();
|
||||||
$config
|
$config
|
||||||
->getFinder()
|
->getFinder()
|
||||||
|
->ignoreVCSIgnored(true)
|
||||||
->notPath('build')
|
->notPath('build')
|
||||||
->notPath('l10n')
|
->notPath('l10n')
|
||||||
->notPath('node_modules')
|
|
||||||
->notPath('src')
|
->notPath('src')
|
||||||
->notPath('vendor')
|
->notPath('vendor')
|
||||||
->in(__DIR__);
|
->in(__DIR__);
|
||||||
|
|
||||||
return $config;
|
return $config;
|
||||||
|
305
CHANGELOG.md
@ -1,308 +1,3 @@
|
|||||||
## 3.4.1 - Skip & Chill - 2024-11-12
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 💄 Make a little gap between player's controls
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 📝 Descriptions are now well formatted
|
|
||||||
- ⏩ Chapters supported !
|
|
||||||
> Click on a timestamp in descriptions to skip to the specified part of the podcast
|
|
||||||
|
|
||||||
## 3.4.0 - Good Night - 2024-11-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- ♿ Improve accessibility by adding titles when missing
|
|
||||||
- ✨ Playback speed and volume setting doesn't stick
|
|
||||||
[#185](https://git.crystalyx.net/Xefir/repod/issues/185) reported by @SteveDinn
|
|
||||||
- ✨ Skip back or forward
|
|
||||||
[#159](https://git.crystalyx.net/Xefir/repod/issues/159) reported by @moonlike8812
|
|
||||||
- ✨ Sleep timer
|
|
||||||
[#119](https://git.crystalyx.net/Xefir/repod/issues/119) reported by @Markusphi and @OiledAmoeba
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 💄 Add padding around favorites on mobile
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 🔒 Increase security when Nextcloud is in debug mode
|
|
||||||
|
|
||||||
## 3.3.2 - What a Nightmare - 2024-10-24
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 🚑 Revert [#178](https://git.crystalyx.net/Xefir/repod/issues/178) not working on big subscriptions lists
|
|
||||||
[#182](https://git.crystalyx.net/Xefir/repod/issues/182) reported by @SteveDinn
|
|
||||||
|
|
||||||
## 3.3.1 - Breaking the Loop - 2024-10-24
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- ⚡ Speed up the loading time of subscriptions
|
|
||||||
[#178](https://git.crystalyx.net/Xefir/repod/issues/178) reported by @MikeAndrews
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 🐛 Prevent Firefox for going nuts when having Plasma Integration addon installed
|
|
||||||
[#164](https://git.crystalyx.net/Xefir/repod/issues/164) reported by @cichy1173, @Share1440 and @mark.collins
|
|
||||||
|
|
||||||
## 3.3.0 - Into The Jet Lag - 2024-10-18
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 🧑💻 CSS isn't mixed in the main JS file anymore
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 🐛 App won't load on Firefox 115
|
|
||||||
[#158](https://git.crystalyx.net/Xefir/repod/issues/158) reported by @Jaunty and @mark.collins
|
|
||||||
- 🔇 Volume slider didn't work properly
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
- 💣 Require Nextcloud 29 or more
|
|
||||||
|
|
||||||
## 3.2.0 - Typing fast - 2024-09-15
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- 📝 Add Cardo to list of compatible clients
|
|
||||||
[#176](https://github.com/thrillfall/nextcloud-gpodder/pull/176) reported by @n0vella
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 🧑💻 Switch entiere project to TypeScript
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 💄 Missing icon on home when aren't any favorites
|
|
||||||
- 💄 Tweaks spacing in several spaces on Home and banners
|
|
||||||
- 💩 Leverage the available space between the episode title and the play button (but hacky way for now)
|
|
||||||
[#59](https://git.crystalyx.net/Xefir/repod/issues/59#issuecomment-6246) reported by @W_LL_M
|
|
||||||
|
|
||||||
## 3.1.0 - Above the stars - 2024-09-02
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- ⭐ You can now add favorites subscriptions !
|
|
||||||
It will show's up on the homepage instead of the recommendations witch appear only when you add a new subscription.
|
|
||||||
[#59](https://git.crystalyx.net/Xefir/repod/issues/59) suggested by @W_LL_M, @Jaunty and @Satalink
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 💥 Use html5 routing instead of hashes. All the URLs has changed removing the `#/` part.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 🐛 Regression on 3.0 that prevent seeking player to episode last listened position
|
|
||||||
[#136](https://git.crystalyx.net/Xefir/repod/issues/136) reported by @randomuser1967
|
|
||||||
- ⚡ Improve the detection off mis-installed or mis-enabled gpodder app
|
|
||||||
|
|
||||||
## 3.0.0 - What a vue - 2024-08-17
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- 🌐 Add german translation
|
|
||||||
Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 🎉 Migrate to Vue 3
|
|
||||||
- 🔖 Support Nextcloud 30
|
|
||||||
- 🏗️ Switch from Vuex to Pinia
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 💄 Use iTunes image first for episode if available
|
|
||||||
- 💄 Displaying styles and proper HTML on episode's modal descriptions
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
- 🗑️ Temporary replacing @nextcloud/dialogs to toastyjs
|
|
||||||
|
|
||||||
## 2.3.3 - The Cake is a Lie - 2024-06-14
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- ⬆️ Update @nextcloud/dialogs to 5.3.2
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 🐛 App crashed when no cache system available
|
|
||||||
[#107](https://git.crystalyx.net/Xefir/repod/issues/107) reported by @skvaller and @PhilTraere
|
|
||||||
|
|
||||||
## 2.3.2 - Young Youth - 2024-05-31
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 🐛 New subscribe button on search not disapearing if subscribed
|
|
||||||
- ♿ Missing accessibility label on this button as well
|
|
||||||
|
|
||||||
## 2.3.1 - Powerwash the Universe - 2024-05-29
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- ⚡ Reduce app size by not shipping sourcemap
|
|
||||||
|
|
||||||
## 2.3.0 - Star Align - 2024-05-29
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- ➕ Ability to subscribe to podcast from search list
|
|
||||||
[#105](https://git.crystalyx.net/Xefir/repod/issues/105) suggested by @crystalyz
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 🔖 Full support for Nextcloud 29
|
|
||||||
- ⬆️ Update @nextcloud/vue to 8.12.0
|
|
||||||
- 📄 Use only AGPL licence
|
|
||||||
- ♻️ Refacto based on the new app template from Nextcloud devs
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
- 💀 Drop support for Nextcloud 26
|
|
||||||
- ⚰️ Drop support for PHP 8.0
|
|
||||||
- 🌐 Removed babel
|
|
||||||
|
|
||||||
## 2.2.1 - Shami was here - 2024-05-18
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
- ♻️ Rollback: Hide unreadable episodes because of insecure sources
|
|
||||||
|
|
||||||
## 2.2.0 - Moving in and out - 2024-05-18
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- 🚨 Linting the code with ESLint
|
|
||||||
- 🎨 Prettierify the code
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- ⬆️ Update all dependancies
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 🔓 Hide unreadable episodes because of insecure sources
|
|
||||||
|
|
||||||
## 2.1.0 - Pocket Gundams - 2024-03-16
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- 🔍 Add CTA for rating the app on the store
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- ⬆️ Update @nextcloud/dialogs to 5.2.0
|
|
||||||
- ⬆️ Update @nextcloud/vue to 8.11.0
|
|
||||||
- 🔖 Set compatibility with Nextcloud 29
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 🔒 App wasn't working for non admin users
|
|
||||||
[#76](https://git.crystalyx.net/Xefir/repod/issues/76) reported by @devasservice
|
|
||||||
|
|
||||||
## 2.0.0 - Taking Actions - 2024-03-05
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- 🍪 Saving filters preference
|
|
||||||
[#66](https://git.crystalyx.net/Xefir/repod/issues/66) suggested by @jeef
|
|
||||||
- 📋 Add several options on each episode :
|
|
||||||
- Mark as read
|
|
||||||
- Open the webpage of the episode
|
|
||||||
- Download the episode
|
|
||||||
- ↪️ Any actions will be reflected on the episode's list
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- ⬆️ Update @nextcloud/vue to 8.8.1
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- ❤️🔥 Better handling ended episodes
|
|
||||||
|
|
||||||
## 1.5.9 - Just According to Keikaku - 2024-02-21
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 🧑💻 Change some endpoints to match gPodder.net "specifications"
|
|
||||||
- ⬆️ Update @nextcloud/vue to 8.7.0
|
|
||||||
|
|
||||||
## 1.5.8 - Goblet of Eonothem - 2024-02-11
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Fyyd API sometime send empty feeds, ignoring them
|
|
||||||
|
|
||||||
## 1.5.7 - 2024-02-08
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
- Proxy episodes when they are behind an unsecure http server
|
|
||||||
|
|
||||||
## 1.5.6 - 2024-02-07
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Proxy episodes when they are behind an unsecure http server
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Update @nextcloud/vue to v8.6.2
|
|
||||||
|
|
||||||
## 1.5.5 - Hide and seek - 2024-02-04
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Can't open podcast details if cache missing or misconfigured
|
|
||||||
[#58](https://git.crystalyx.net/Xefir/repod/issues/58) reported by @raxventus
|
|
||||||
|
|
||||||
## 1.5.4 - In search of the truth - 2024-02-03
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Nextcloud search engine didn't work on Nextcloud 26 and 27
|
|
||||||
[#57](https://git.crystalyx.net/Xefir/repod/issues/57) reported by @JonOfUs
|
|
||||||
|
|
||||||
## 1.5.3 - The date where it all ends - 2024-02-01
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Update @nextcloud/vue to v8.6.1
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Fix episode listing crashing if an invalid publication date is present in the RSS
|
|
||||||
|
|
||||||
## 1.5.2 - A little to the top - 2024-02-01
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Update @nextcloud/router to v3.0.0
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Fix player alignment off by a couple of pixels
|
|
||||||
|
|
||||||
## 1.5.1 - Play on the PlayHead - 2024-01-30
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Update @nextcloud/vue to v8.6.0
|
|
||||||
- Change the player progress bar to the native browser component
|
|
||||||
[#52](https://git.crystalyx.net/Xefir/repod/issues/52) suggested by @W_LL_M
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Force the placement of the filter settings to the top
|
|
||||||
|
|
||||||
## 1.5.0 - Featuring the filtering - 2024-01-30
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Filtering options for each podcast section
|
|
||||||
[#50](https://git.crystalyx.net/Xefir/repod/issues/50) suggested by @W_LL_M
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Update @nextcloud/router to v2.2.1
|
|
||||||
- Update @nextcloud/dialogs to v5.1.1
|
|
||||||
- Update @nextcloud/vue to v8.5.1
|
|
||||||
- Update vue-material-deisgn-icons to v5.3.0
|
|
||||||
- Displaying episode publication as "dd mmm yyyy" instead of xyz ago
|
|
||||||
[#48](https://git.crystalyx.net/Xefir/repod/issues/48) suggested by @W_LL_M
|
|
||||||
- Better display the reading status for episodes
|
|
||||||
[#51](https://git.crystalyx.net/Xefir/repod/issues/51) suggested by @W_LL_M
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- When exporting feeds, if the RSS server fails, the export continue
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
- Remove @nextcloud/moment
|
|
||||||
|
|
||||||
## 1.4.4 - 2024-01-24
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Update @nextcloud/vue to v8.5.0
|
|
||||||
|
|
||||||
## 1.4.3 - 2024-01-21
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Expose the feed URL
|
|
||||||
[#41](https://git.crystalyx.net/Xefir/repod/issues/41) suggested by @SteveDinn
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- More granular playback speed adustment by steps of 0.1
|
|
||||||
[#40](https://git.crystalyx.net/Xefir/repod/issues/40) reported by @SteveDinn
|
|
||||||
|
|
||||||
## 1.4.2 - 2024-01-20
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Ended episodes still didn't show well, should be fixed now hopefully
|
|
||||||
|
|
||||||
## 1.4.1 - 2024-01-19
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- When deleting an episode on AntennaPod or on GPodder, it shows ended as expected
|
|
||||||
- Add space in the bottom of tops to allow catching the scrollbar
|
|
||||||
|
|
||||||
## 1.4.0 - 2024-01-18
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Import subscriptions
|
|
||||||
- Export subscriptions
|
|
||||||
|
|
||||||
## 1.3.0 - 2024-01-18
|
## 1.3.0 - 2024-01-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
12
Dockerfile
@ -1,10 +1,10 @@
|
|||||||
FROM nextcloud:30
|
FROM nextcloud:28
|
||||||
|
|
||||||
ENV NEXTCLOUD_UPDATE=1
|
ENV NEXTCLOUD_UPDATE 1
|
||||||
ENV NEXTCLOUD_ADMIN_USER=repod
|
ENV NEXTCLOUD_ADMIN_USER repod
|
||||||
ENV NEXTCLOUD_ADMIN_PASSWORD=repod
|
ENV NEXTCLOUD_ADMIN_PASSWORD repod
|
||||||
ENV NEXTCLOUD_INIT_HTACCESS=1
|
ENV NEXTCLOUD_INIT_HTACCESS 1
|
||||||
ENV SQLITE_DATABASE=repod
|
ENV SQLITE_DATABASE repod
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y nodejs npm sqlite3 && \
|
apt-get install -y nodejs npm sqlite3 && \
|
||||||
|
4
LICENSE
@ -633,8 +633,8 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||||||
Copyright (C) <year> <name of author>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
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
|
it under the terms of the GNU Affero General Public License as published
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
|
135
Makefile
@ -1,4 +1,38 @@
|
|||||||
# https://github.com/nextcloud/appstore/blob/fixed-templates/nextcloudappstore/scaffolding/app-templates/26/app/Makefile
|
# Generic Makefile for building and packaging a Nextcloud app which uses npm and
|
||||||
|
# Composer.
|
||||||
|
#
|
||||||
|
# Dependencies:
|
||||||
|
# * make
|
||||||
|
# * which
|
||||||
|
# * curl: used if phpunit and composer are not installed to fetch them from the web
|
||||||
|
# * tar: for building the archive
|
||||||
|
# * npm: for building and testing everything JS
|
||||||
|
#
|
||||||
|
# If no composer.json is in the app root directory, the Composer step
|
||||||
|
# will be skipped. The same goes for the package.json which can be located in
|
||||||
|
# the app root or the js/ directory.
|
||||||
|
#
|
||||||
|
# The npm command by launches the npm build script:
|
||||||
|
#
|
||||||
|
# npm run build
|
||||||
|
#
|
||||||
|
# The npm test command launches the npm test script:
|
||||||
|
#
|
||||||
|
# npm run test
|
||||||
|
#
|
||||||
|
# The idea behind this is to be completely testing and build tool agnostic. All
|
||||||
|
# build tools and additional package managers should be installed locally in
|
||||||
|
# your project, since this won't pollute people's global namespace.
|
||||||
|
#
|
||||||
|
# The following npm scripts in your package.json install and update the bower
|
||||||
|
# and npm dependencies and use gulp as build system (notice how everything is
|
||||||
|
# run from the node_modules folder):
|
||||||
|
#
|
||||||
|
# "scripts": {
|
||||||
|
# "test": "node node_modules/gulp-cli/bin/gulp.js karma",
|
||||||
|
# "prebuild": "npm install && node_modules/bower/bin/bower install && node_modules/bower/bin/bower update",
|
||||||
|
# "build": "node node_modules/gulp-cli/bin/gulp.js"
|
||||||
|
# },
|
||||||
|
|
||||||
app_name=$(notdir $(CURDIR))
|
app_name=$(notdir $(CURDIR))
|
||||||
build_tools_directory=$(CURDIR)/build/tools
|
build_tools_directory=$(CURDIR)/build/tools
|
||||||
@ -68,14 +102,14 @@ dist: build
|
|||||||
source:
|
source:
|
||||||
rm -rf $(source_build_directory)
|
rm -rf $(source_build_directory)
|
||||||
mkdir -p $(source_build_directory)
|
mkdir -p $(source_build_directory)
|
||||||
tar -C .. -cvzf $(source_package_name).tar.gz \
|
tar cvzf $(source_package_name).tar.gz \
|
||||||
--exclude-vcs \
|
--exclude-vcs \
|
||||||
--exclude="$(app_name)/build" \
|
--exclude="../$(app_name)/build" \
|
||||||
--exclude="$(app_name)/js/node_modules" \
|
--exclude="../$(app_name)/js/node_modules" \
|
||||||
--exclude="$(app_name)/node_modules" \
|
--exclude="../$(app_name)/node_modules" \
|
||||||
--exclude="$(app_name)/*.log" \
|
--exclude="../$(app_name)/*.log" \
|
||||||
--exclude="$(app_name)/js/*.log" \
|
--exclude="../$(app_name)/js/*.log" \
|
||||||
$(app_name)
|
../$(app_name)
|
||||||
|
|
||||||
# Builds the source package for the app store, ignores php tests, js tests
|
# Builds the source package for the app store, ignores php tests, js tests
|
||||||
# and build related folders that are unnecessary for an appstore release
|
# and build related folders that are unnecessary for an appstore release
|
||||||
@ -83,52 +117,53 @@ source:
|
|||||||
appstore:
|
appstore:
|
||||||
rm -rf $(appstore_build_directory)
|
rm -rf $(appstore_build_directory)
|
||||||
mkdir -p $(appstore_build_directory)
|
mkdir -p $(appstore_build_directory)
|
||||||
tar -C .. -cvzf $(appstore_package_name).tar.gz \
|
tar cvzf $(appstore_package_name).tar.gz \
|
||||||
--exclude="$(app_name)/build" \
|
--exclude-vcs \
|
||||||
--exclude="$(app_name)/tests" \
|
--exclude="../$(app_name)/build" \
|
||||||
--exclude="$(app_name)/Makefile" \
|
--exclude="../$(app_name)/tests" \
|
||||||
--exclude="$(app_name)/*.log" \
|
--exclude="../$(app_name)/Makefile" \
|
||||||
--exclude="$(app_name)/phpunit*xml" \
|
--exclude="../$(app_name)/*.log" \
|
||||||
--exclude="$(app_name)/composer.*" \
|
--exclude="../$(app_name)/phpunit*xml" \
|
||||||
--exclude="$(app_name)/node_modules" \
|
--exclude="../$(app_name)/composer.*" \
|
||||||
--exclude="$(app_name)/js/node_modules" \
|
--exclude="../$(app_name)/node_modules" \
|
||||||
--exclude="$(app_name)/js/tests" \
|
--exclude="../$(app_name)/js/node_modules" \
|
||||||
--exclude="$(app_name)/js/test" \
|
--exclude="../$(app_name)/js/tests" \
|
||||||
--exclude="$(app_name)/js/*.log" \
|
--exclude="../$(app_name)/js/test" \
|
||||||
--exclude="$(app_name)/js/package.json" \
|
--exclude="../$(app_name)/js/*.log" \
|
||||||
--exclude="$(app_name)/js/bower.json" \
|
--exclude="../$(app_name)/js/package.json" \
|
||||||
--exclude="$(app_name)/js/karma.*" \
|
--exclude="../$(app_name)/js/bower.json" \
|
||||||
--exclude="$(app_name)/js/protractor.*" \
|
--exclude="../$(app_name)/js/karma.*" \
|
||||||
--exclude="$(app_name)/package.json" \
|
--exclude="../$(app_name)/js/protractor.*" \
|
||||||
--exclude="$(app_name)/bower.json" \
|
--exclude="../$(app_name)/package.json" \
|
||||||
--exclude="$(app_name)/karma.*" \
|
--exclude="../$(app_name)/bower.json" \
|
||||||
--exclude="$(app_name)/protractor\.*" \
|
--exclude="../$(app_name)/karma.*" \
|
||||||
--exclude="$(app_name)/.*" \
|
--exclude="../$(app_name)/protractor\.*" \
|
||||||
--exclude="$(app_name)/js/.*" \
|
--exclude="../$(app_name)/.*" \
|
||||||
--exclude="$(app_name)/tsconfig.json" \
|
--exclude="../$(app_name)/js/.*" \
|
||||||
--exclude="$(app_name)/stylelint.config.cjs" \
|
--exclude="../$(app_name)/webpack.config.js" \
|
||||||
--exclude="$(app_name)/README.md" \
|
--exclude="../$(app_name)/stylelint.config.js" \
|
||||||
--exclude="$(app_name)/package-lock.json" \
|
--exclude="../$(app_name)/README.md" \
|
||||||
--exclude="$(app_name)/LICENSE" \
|
--exclude="../$(app_name)/package-lock.json" \
|
||||||
--exclude="$(app_name)/src" \
|
--exclude="../$(app_name)/LICENSE*" \
|
||||||
--exclude="$(app_name)/stubs" \
|
--exclude="../$(app_name)/src" \
|
||||||
--exclude="$(app_name)/screens" \
|
--exclude="../$(app_name)/stubs" \
|
||||||
--exclude="$(app_name)/vendor" \
|
--exclude="../$(app_name)/screens" \
|
||||||
--exclude="$(app_name)/translationfiles" \
|
--exclude="../$(app_name)/vendor" \
|
||||||
--exclude="$(app_name)/Dockerfile" \
|
--exclude="../$(app_name)/translationfiles" \
|
||||||
--exclude="$(app_name)/psalm.xml" \
|
--exclude="../$(app_name)/babel.config.js" \
|
||||||
--exclude="$(app_name)/renovate.json" \
|
--exclude="../$(app_name)/Dockerfile" \
|
||||||
--exclude="$(app_name)/vite.config.ts" \
|
--exclude="../$(app_name)/psalm.xml" \
|
||||||
$(app_name)
|
--exclude="../$(app_name)/renovate.json" \
|
||||||
|
../$(app_name)
|
||||||
|
|
||||||
# Start a nextcloud server on Docker to kickstart developement
|
# Start a nextcloud server on Docker to kickstart developement
|
||||||
.PHONY: dev
|
.PHONY: dev
|
||||||
dev: build
|
dev: build
|
||||||
docker stop $(app_name) || true
|
docker stop repod || true
|
||||||
docker rm $(app_name) || true
|
docker rm repod || true
|
||||||
docker build -t $(app_name) .
|
docker build -t repod .
|
||||||
docker run -itd --rm --name $(app_name) -v $(CURDIR):/var/www/html/apps/$(app_name) -p 80:80 $(app_name)
|
docker run -itd --rm --name repod -v $(CURDIR):/var/www/html/apps/repod -p 80:80 repod
|
||||||
npm run watch || docker stop $(app_name)
|
npm run watch
|
||||||
|
|
||||||
# Generate translations
|
# Generate translations
|
||||||
.PHONY: l10n
|
.PHONY: l10n
|
||||||
|
48
README.md
@ -6,46 +6,19 @@ You need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) inst
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Browse podcasts and play them directly in Nextcloud
|
- [x] Browse podcasts and play them directly in Nextcloud
|
||||||
- Keep track of subscribed shows and episodes
|
- [x] Keep track of subscribed shows and episodes
|
||||||
- Sync them with GPodderSync compatible clients
|
- [x] Sync them with GPodderSync compatible clients
|
||||||
- Import and export subscriptions
|
- [ ] Import and export subscriptions
|
||||||
- Mobile friendly interface
|
- [x] Mobile friendly interface
|
||||||
- Unified search integration
|
- [x] Unified search integration
|
||||||
|
- [x] Interface in multiple languages
|
||||||
## Comparaison with similar apps for Nextcloud
|
|
||||||
|
|
||||||
| | [RePod](https://apps.nextcloud.com/apps/repod) | [NextPod](https://apps.nextcloud.com/apps/nextpod) | [Music](https://apps.nextcloud.com/apps/music) | [Podcast](https://apps.nextcloud.com/apps/podcast) |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| Actively maintened | ✅ | ✅ | ✅ | ❌ |
|
|
||||||
| Play your local music files | ❌ | ❌ | ✅ | ❌ |
|
|
||||||
| Sync with [GPodder clients](#clients-supporting-sync-of-gpoddersync) | ✅ | ✅ | [⭕](https://github.com/owncloud/music/issues/975) | [⭕](https://git.project-insanity.org/onny/nextcloud-app-podcast/-/issues/103) |
|
|
||||||
| Add and manage subscriptions | ✅ | ❌ | ✅ | ✅ |
|
|
||||||
| Listen synced episodes by another clients | ✅ | ✅ | ❌ | ❌ |
|
|
||||||
| Fetch and listen new epidodes | ✅ | [⭕](https://github.com/pbek/nextcloud-nextpod/issues/5) | ✅ | ✅ |
|
|
||||||
| Keep track of listened episodes | ✅ | ✅ | [⭕](https://github.com/owncloud/music/issues/1148) | ✅ |
|
|
||||||
| Download epidodes | ✅ | ✅ | ❌ | ✅ |
|
|
||||||
| Sleep timer | ✅ | ❌ | [⭕](https://github.com/owncloud/music/issues/884#issuecomment-921582302) | ❌ |
|
|
||||||
| Advanced player controls | ✅ | ❌ | ✅ | ✅ |
|
|
||||||
| Import and export subscriptions | ✅ | ❌ | [⭕](https://github.com/owncloud/music/issues/904) | [⭕](https://git.project-insanity.org/onny/nextcloud-app-podcast/-/issues/185) |
|
|
||||||
| Search and discover new podcasts | ✅ | ❌ | ❌ | ✅ |
|
|
||||||
| Open episode website and RSS feed | ✅ | ✅ | ❌ | ✅ |
|
|
||||||
| Integrate with Nextcloud search engine | ✅ | ❌ | ❌ | ✅ |
|
|
||||||
| Integrate with [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) | ❌ | ✅ | ❌ | ❌ |
|
|
||||||
| Mobile friendly interface | ✅ | ❌ | ✅ | ✅ |
|
|
||||||
| Support chapters | ✅ | ❌ | ❌ | ✅ |
|
|
||||||
| Available in multiple languages | ⭕ (en/fr/de) | ❌ | ✅ | ⭕ (en/de) |
|
|
||||||
|
|
||||||
*Click on ⭕ to open the ticket*
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
### Homepage
|
### Homepage
|
||||||
![homepage](./screens/index.png)
|
![homepage](./screens/index.png)
|
||||||
|
|
||||||
### Discover
|
|
||||||
![homepage](./screens/discover.png)
|
|
||||||
|
|
||||||
### Search
|
### Search
|
||||||
![search](./screens/search.png)
|
![search](./screens/search.png)
|
||||||
|
|
||||||
@ -62,15 +35,10 @@ You need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) inst
|
|||||||
| [AntennaPod](https://antennapod.org) | Initial purpose for this project, as a synchronization endpoint for this client.<br> Support is available [as of version 2.5.1](https://github.com/AntennaPod/AntennaPod/pull/5243/). |
|
| [AntennaPod](https://antennapod.org) | Initial purpose for this project, as a synchronization endpoint for this client.<br> Support is available [as of version 2.5.1](https://github.com/AntennaPod/AntennaPod/pull/5243/). |
|
||||||
| [KDE Kasts](https://apps.kde.org/de/kasts/) | Supported since version 21.12 |
|
| [KDE Kasts](https://apps.kde.org/de/kasts/) | Supported since version 21.12 |
|
||||||
| [Podcast Merlin](https://github.com/yoyoooooooooo/Podcast-Merlin--Nextcloud-Gpodder-Client-For-Windows) | Full sync support podcast client for Windows |
|
| [Podcast Merlin](https://github.com/yoyoooooooooo/Podcast-Merlin--Nextcloud-Gpodder-Client-For-Windows) | Full sync support podcast client for Windows |
|
||||||
| [Cardo](https://cardo-podcast.github.io/#/cardo) | Podcast client with sync support, for Windows, Mac and Linux |
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Either from the official Nextcloud [app store](https://apps.nextcloud.com/apps/repod) or by downloading the [latest release](https://git.crystalyx.net/Xefir/repod/releases/latest) and extracting it into your Nextcloud `apps/` directory.
|
Either from the official Nextcloud [app store](https://apps.nextcloud.com/apps/repod) or by downloading the [latest release](https://git.crystalyx.net/Xefir/RePod/releases/latest) and extracting it into your Nextcloud `apps/` directory.
|
||||||
|
|
||||||
## Known issues
|
|
||||||
|
|
||||||
- Conflict with Plasma Integration Firefox addon ([#164](https://git.crystalyx.net/Xefir/repod/issues/164))
|
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
|
@ -7,37 +7,30 @@
|
|||||||
<description><![CDATA[## Features
|
<description><![CDATA[## Features
|
||||||
- 🔍 Browse and subscribe huge collection of podcasts
|
- 🔍 Browse and subscribe huge collection of podcasts
|
||||||
- 🔊 Listen to episodes directly in Nextcloud
|
- 🔊 Listen to episodes directly in Nextcloud
|
||||||
- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/) and [other apps](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)
|
- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/)
|
||||||
- 📱 Mobile friendly interface
|
|
||||||
- 📡 Import and export your subscriptions
|
|
||||||
- ➡️ Full features comparison [here](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
You need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!]]></description>
|
You need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!]]></description>
|
||||||
<version>3.4.1</version>
|
<version>1.3.0</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author mail="xefir@crystalyx.net" homepage="https://crystalyx.net">Michel Roux</author>
|
<author mail="xefir@crystalyx.net" homepage="https://crystalyx.net">Xéfir Destiny</author>
|
||||||
<namespace>RePod</namespace>
|
<namespace>RePod</namespace>
|
||||||
<category>integration</category>
|
<category>integration</category>
|
||||||
<category>multimedia</category>
|
<category>multimedia</category>
|
||||||
<website>https://git.crystalyx.net/Xefir/repod</website>
|
<website>https://git.crystalyx.net/Xefir/RePod</website>
|
||||||
<bugs>https://git.crystalyx.net/Xefir/repod/issues</bugs>
|
<bugs>https://git.crystalyx.net/Xefir/RePod/issues</bugs>
|
||||||
<screenshot>https://git.crystalyx.net/Xefir/repod/raw/branch/main/screens/index.png</screenshot>
|
<screenshot>https://git.crystalyx.net/Xefir/RePod/raw/branch/main/screens/index.png</screenshot>
|
||||||
<screenshot>https://git.crystalyx.net/Xefir/repod/raw/branch/main/screens/discover.png</screenshot>
|
<screenshot>https://git.crystalyx.net/Xefir/RePod/raw/branch/main/screens/search.png</screenshot>
|
||||||
<screenshot>https://git.crystalyx.net/Xefir/repod/raw/branch/main/screens/search.png</screenshot>
|
<screenshot>https://git.crystalyx.net/Xefir/RePod/raw/branch/main/screens/episodes.png</screenshot>
|
||||||
<screenshot>https://git.crystalyx.net/Xefir/repod/raw/branch/main/screens/episodes.png</screenshot>
|
<screenshot>https://git.crystalyx.net/Xefir/RePod/raw/branch/main/screens/modal.png</screenshot>
|
||||||
<screenshot>https://git.crystalyx.net/Xefir/repod/raw/branch/main/screens/modal.png</screenshot>
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<php min-version="8.1"/>
|
<php min-version="8.0"/>
|
||||||
<nextcloud min-version="29" max-version="30"/>
|
<nextcloud min-version="26" max-version="28"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<navigations>
|
<navigations>
|
||||||
<navigation>
|
<navigation>
|
||||||
<id>repod</id>
|
|
||||||
<name>Podcast</name>
|
<name>Podcast</name>
|
||||||
<route>repod.page.index</route>
|
<route>repod.page.index</route>
|
||||||
<icon>app.svg</icon>
|
|
||||||
<type>link</type>
|
|
||||||
</navigation>
|
</navigation>
|
||||||
</navigations>
|
</navigations>
|
||||||
</info>
|
</info>
|
||||||
|
23
appinfo/routes.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create your routes in here. The name is the lowercase name of the controller
|
||||||
|
* without the controller part, the stuff after the hash is the method.
|
||||||
|
* e.g. page#index -> OCA\RePod\Controller\PageController->index().
|
||||||
|
*
|
||||||
|
* The controller class has to be registered in the application.php file since
|
||||||
|
* it's instantiated in there
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'routes' => [
|
||||||
|
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
|
||||||
|
['name' => 'episodes#action', 'url' => '/episodes/action', 'verb' => 'GET'],
|
||||||
|
['name' => 'episodes#list', 'url' => '/episodes/list', 'verb' => 'GET'],
|
||||||
|
['name' => 'podcast#index', 'url' => '/podcast', 'verb' => 'GET'],
|
||||||
|
['name' => 'search#index', 'url' => '/search', 'verb' => 'GET'],
|
||||||
|
['name' => 'tops#hot', 'url' => '/tops/hot', 'verb' => 'GET'],
|
||||||
|
['name' => 'tops#new', 'url' => '/tops/new', 'verb' => 'GET'],
|
||||||
|
],
|
||||||
|
];
|
3
babel.config.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const babelConfig = require('@nextcloud/babel-config')
|
||||||
|
|
||||||
|
module.exports = babelConfig
|
@ -1,36 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "nextcloud/repod",
|
"name": "nextcloud/repod",
|
||||||
"description": "🔊 Browse, manage and listen to podcasts",
|
"description": "🔊 Browse, manage and listen to podcasts",
|
||||||
|
"type": "project",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"version": "1.3.0",
|
||||||
|
"require-dev": {
|
||||||
|
"nextcloud/ocp": "^28.0.1",
|
||||||
|
"psalm/phar": "^5.19.1",
|
||||||
|
"nextcloud/coding-standard": "^1.1.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"lint": "find . -name \\*.php -not -path './vendor/*' -print0 | xargs -0 -n1 php -l",
|
||||||
|
"cs:check": "php-cs-fixer fix --dry-run --diff",
|
||||||
|
"cs:fix": "php-cs-fixer fix",
|
||||||
|
"psalm:check": "psalm.phar --threads=1 --no-cache --show-info=true",
|
||||||
|
"psalm:fix": "psalm.phar --no-cache --alter --issues=InvalidReturnType,InvalidNullableReturnType,MissingParamType,InvalidFalsableReturnType"
|
||||||
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"OCA\\RePod\\": "lib/",
|
"OCA\\RePod\\": "lib/",
|
||||||
"OCA\\GPodderSync\\": "stubs/OCA/GPodderSync/"
|
"OCA\\GPodderSync\\": "stubs/OCA/GPodderSync/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
|
||||||
"lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
|
|
||||||
"cs:check": "php-cs-fixer fix --dry-run --diff",
|
|
||||||
"cs:fix": "php-cs-fixer fix",
|
|
||||||
"psalm": "psalm --threads=1 --no-cache --show-info=true",
|
|
||||||
"rector": "rector && composer cs:fix"
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^8.1"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"nextcloud/ocp": "^30.0.2",
|
|
||||||
"roave/security-advisories": "dev-latest",
|
|
||||||
"nextcloud/coding-standard": "^1.3.2",
|
|
||||||
"nextcloud/rector": "^0.2.1",
|
|
||||||
"rector/rector": "^1.2.10",
|
|
||||||
"vimeo/psalm": "^5.26.1"
|
|
||||||
},
|
|
||||||
"config": {
|
"config": {
|
||||||
"optimize-autoloader": true,
|
|
||||||
"sort-packages": true,
|
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "8.1"
|
"php": "8.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
3110
composer.lock
generated
59
l10n/de.js
@ -1,59 +0,0 @@
|
|||||||
OC.L10N.register(
|
|
||||||
"repod",
|
|
||||||
{
|
|
||||||
"RePod Subscriptions" : "RePod Abonnements",
|
|
||||||
"Podcast" : "Podcast",
|
|
||||||
"RePod" : "RePod",
|
|
||||||
"🔊 Browse, manage and listen to podcasts" : "🔊 Suchen, Verwalten und Anhören von Podcasts",
|
|
||||||
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/) and [other apps](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Mobile friendly interface\n- 📡 Import and export your subscriptions\n- ➡️ Full features comparison [here](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Funktionen\n- 🔍 Durchsuchen und abonnieren einer großen Sammlung von Podcasts\n- 🔊 Episoden direkt in Nextcloud anhören\n- 🌐 Synchronisiere deine Aktivität mit [AntennaPod](https://antennapod.org/)\n- 📱 Mobile-freundliche Schnittstelle\n- 📡 Importieren und Exportieren Ihrer Abonnements\n\n## Voraussetzungen\nDu musst [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installiert haben, um diese App zu benutzen!",
|
|
||||||
"Download" : "Herunterladen",
|
|
||||||
"Skip to {match}" : "Springen zu {match}",
|
|
||||||
"Add a RSS link" : "Einen RSS-Link hinzufügen",
|
|
||||||
"Subscribe" : "Abonnieren",
|
|
||||||
"Error while adding the feed" : "Fehler beim Hinzufügen des Feeds",
|
|
||||||
"Could not fetch search results" : "Suchergebnisse können nicht geladen werden",
|
|
||||||
"New podcasts" : "Neue Podcasts",
|
|
||||||
"Hot podcasts" : "Beliebte Podcasts",
|
|
||||||
"Could not fetch tops" : "Titel können nicht abgerufen werden",
|
|
||||||
"Copy feed" : "Feed kopieren",
|
|
||||||
"Link copied to the clipboard" : "Der Link des Feeds wurde in die Zwischenablage kopiert",
|
|
||||||
"Play" : "Abspielen",
|
|
||||||
"Stop" : "Stopp",
|
|
||||||
"Read" : "Gelesen",
|
|
||||||
"Open website" : "Webseite aufrufen",
|
|
||||||
"Could not change the status of the episode" : "Kann den Status der Folge nicht ändern",
|
|
||||||
"Could not fetch episodes" : "Folgen können nicht abgerufen werden",
|
|
||||||
"Rewind 10 seconds" : "10 Sekunden zurückspulen",
|
|
||||||
"Pause" : "Pause",
|
|
||||||
"Fast forward 30 seconds" : "30 Sekunden vorspulen",
|
|
||||||
"Mute" : "Stumm",
|
|
||||||
"Unmute" : "Stummschalten",
|
|
||||||
"Export subscriptions" : "Abonnements exportieren",
|
|
||||||
"Filtering episodes" : "Folgen filtern",
|
|
||||||
"Show all" : "Zeige alles",
|
|
||||||
"Listened" : "Gehört",
|
|
||||||
"Listening" : "Läuft",
|
|
||||||
"Unlistened" : "Nicht angehört",
|
|
||||||
"Import subscriptions" : "Importiere Abonnements",
|
|
||||||
"Import OPML file" : "Importiere OPML-Datei",
|
|
||||||
"Rate RePod ❤️" : "Bewerte RePod ❤️",
|
|
||||||
"Sleep timer" : "Einschlaftimer",
|
|
||||||
"Minutes" : "Minuten",
|
|
||||||
"_%n min_::_%n mins_" : ["%n min","%n mins"],
|
|
||||||
"_%n sec_::_%n secs_" : ["%s sec","%n secs"],
|
|
||||||
"Playback speed" : "Wiedergabegeschwindigkeit",
|
|
||||||
"Favorite" : "Favorit",
|
|
||||||
"Are you sure you want to delete this subscription?" : "Bist Du sicher, dass Du das Abonnement löschen möchtest?",
|
|
||||||
"Error while removing the feed" : "Fehler beim Löschen des Feeds",
|
|
||||||
"You can only have 10 favorites" : "Du kannst nur 10 Favoriten haben",
|
|
||||||
"Add a podcast" : "Einen Podcast hinzufügen",
|
|
||||||
"Could not fetch subscriptions" : "Abonnements können nicht abgerufen werden",
|
|
||||||
"Find a podcast" : "Finde einen Podcast",
|
|
||||||
"Error loading feed" : "Fehler beim Laden des Feeds",
|
|
||||||
"Missing required app" : "Benötigte App fehlt",
|
|
||||||
"Install GPodder Sync" : "Installiere GPodder Sync",
|
|
||||||
"Pin some subscriptions to see their latest updates" : "Pinne einige Abonnements, um ihre neuesten Updates zu sehen",
|
|
||||||
"No favorites" : "Keine Favoriten",
|
|
||||||
"A browser extension conflict with RePod" : "Ein Browser-Erweiterungskonflikt mit RePod"
|
|
||||||
},
|
|
||||||
"");
|
|
57
l10n/de.json
@ -1,57 +0,0 @@
|
|||||||
{ "translations": {
|
|
||||||
"RePod Subscriptions" : "RePod Abonnements",
|
|
||||||
"Podcast" : "Podcast",
|
|
||||||
"RePod" : "RePod",
|
|
||||||
"🔊 Browse, manage and listen to podcasts" : "🔊 Suchen, Verwalten und Anhören von Podcasts",
|
|
||||||
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/) and [other apps](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Mobile friendly interface\n- 📡 Import and export your subscriptions\n- ➡️ Full features comparison [here](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Funktionen\n- 🔍 Durchsuchen und abonnieren einer großen Sammlung von Podcasts\n- 🔊 Episoden direkt in Nextcloud anhören\n- 🌐 Synchronisiere deine Aktivität mit [AntennaPod](https://antennapod.org/)\n- 📱 Mobile-freundliche Schnittstelle\n- 📡 Importieren und Exportieren Ihrer Abonnements\n\n## Voraussetzungen\nDu musst [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installiert haben, um diese App zu benutzen!",
|
|
||||||
"Download" : "Herunterladen",
|
|
||||||
"Skip to {match}" : "Springen zu {match}",
|
|
||||||
"Add a RSS link" : "Einen RSS-Link hinzufügen",
|
|
||||||
"Subscribe" : "Abonnieren",
|
|
||||||
"Error while adding the feed" : "Fehler beim Hinzufügen des Feeds",
|
|
||||||
"Could not fetch search results" : "Suchergebnisse können nicht geladen werden",
|
|
||||||
"New podcasts" : "Neue Podcasts",
|
|
||||||
"Hot podcasts" : "Beliebte Podcasts",
|
|
||||||
"Could not fetch tops" : "Titel können nicht abgerufen werden",
|
|
||||||
"Copy feed" : "Feed kopieren",
|
|
||||||
"Link copied to the clipboard" : "Der Link des Feeds wurde in die Zwischenablage kopiert",
|
|
||||||
"Play" : "Abspielen",
|
|
||||||
"Stop" : "Stopp",
|
|
||||||
"Read" : "Gelesen",
|
|
||||||
"Open website" : "Webseite aufrufen",
|
|
||||||
"Could not change the status of the episode" : "Kann den Status der Folge nicht ändern",
|
|
||||||
"Could not fetch episodes" : "Folgen können nicht abgerufen werden",
|
|
||||||
"Rewind 10 seconds" : "10 Sekunden zurückspulen",
|
|
||||||
"Pause" : "Pause",
|
|
||||||
"Fast forward 30 seconds" : "30 Sekunden vorspulen",
|
|
||||||
"Mute" : "Stumm",
|
|
||||||
"Unmute" : "Stummschalten",
|
|
||||||
"Export subscriptions" : "Abonnements exportieren",
|
|
||||||
"Filtering episodes" : "Folgen filtern",
|
|
||||||
"Show all" : "Zeige alles",
|
|
||||||
"Listened" : "Gehört",
|
|
||||||
"Listening" : "Läuft",
|
|
||||||
"Unlistened" : "Nicht angehört",
|
|
||||||
"Import subscriptions" : "Importiere Abonnements",
|
|
||||||
"Import OPML file" : "Importiere OPML-Datei",
|
|
||||||
"Rate RePod ❤️" : "Bewerte RePod ❤️",
|
|
||||||
"Sleep timer" : "Einschlaftimer",
|
|
||||||
"Minutes" : "Minuten",
|
|
||||||
"_%n min_::_%n mins_" : ["%n min","%n mins"],
|
|
||||||
"_%n sec_::_%n secs_" : ["%s sec","%n secs"],
|
|
||||||
"Playback speed" : "Wiedergabegeschwindigkeit",
|
|
||||||
"Favorite" : "Favorit",
|
|
||||||
"Are you sure you want to delete this subscription?" : "Bist Du sicher, dass Du das Abonnement löschen möchtest?",
|
|
||||||
"Error while removing the feed" : "Fehler beim Löschen des Feeds",
|
|
||||||
"You can only have 10 favorites" : "Du kannst nur 10 Favoriten haben",
|
|
||||||
"Add a podcast" : "Einen Podcast hinzufügen",
|
|
||||||
"Could not fetch subscriptions" : "Abonnements können nicht abgerufen werden",
|
|
||||||
"Find a podcast" : "Finde einen Podcast",
|
|
||||||
"Error loading feed" : "Fehler beim Laden des Feeds",
|
|
||||||
"Missing required app" : "Benötigte App fehlt",
|
|
||||||
"Install GPodder Sync" : "Installiere GPodder Sync",
|
|
||||||
"Pin some subscriptions to see their latest updates" : "Pinne einige Abonnements, um ihre neuesten Updates zu sehen",
|
|
||||||
"No favorites" : "Keine Favoriten",
|
|
||||||
"A browser extension conflict with RePod" : "Ein Browser-Erweiterungskonflikt mit RePod"
|
|
||||||
},"pluralForm" :""
|
|
||||||
}
|
|
49
l10n/fr.js
@ -1,59 +1,30 @@
|
|||||||
OC.L10N.register(
|
OC.L10N.register(
|
||||||
"repod",
|
"repod",
|
||||||
{
|
{
|
||||||
"RePod Subscriptions" : "Abonnements sur RePod",
|
|
||||||
"Podcast" : "Podcast",
|
|
||||||
"RePod" : "RePod",
|
"RePod" : "RePod",
|
||||||
|
"Podcast" : "Podcast",
|
||||||
"🔊 Browse, manage and listen to podcasts" : "🔊 Parcourir, gérer et écouter vos podcasts",
|
"🔊 Browse, manage and listen to podcasts" : "🔊 Parcourir, gérer et écouter vos podcasts",
|
||||||
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/) and [other apps](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Mobile friendly interface\n- 📡 Import and export your subscriptions\n- ➡️ Full features comparison [here](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Fonctionnalités\n- 🔍 Parcourir et s'abonner à une grande collections de podcasts\n- 🔊 Écouter vos épisodes directement sur Nextcloud\n- 🌐 Synchroniser son activité avec [AntennaPod](https://antennapod.org/) et d'autres [applications](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Interface optimisée pour mobiles et ordinateurs\n- 📡 Import/export de ses abonnements\n- ➡️ Tableau récapitulatif complet des fonctionnalitées [ici](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Pré-requis\nVous devez avoir [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installé pour utiliser cette application !",
|
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Fonctionnalités\n- 🔍 Parcourir et s'abonner à une grande collections de podcasts\n- 🔊 Écouter vos épisodes directement sur Nextcloud\n- 🌐 Synchroniser son activité avec [AntennaPod](https://antennapod.org/)\n\n## Pré-requis\nVous devez avoir [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installé pour utiliser cette application !",
|
||||||
"Download" : "Télécharger",
|
|
||||||
"Skip to {match}" : "Sauter à {match}",
|
|
||||||
"Add a RSS link" : "Ajouter un lien RSS",
|
"Add a RSS link" : "Ajouter un lien RSS",
|
||||||
|
"Could not fetch search results" : "Impossible de récupérer les resultats de la recherche",
|
||||||
|
"Hot podcasts" : "Tendances",
|
||||||
|
"New podcasts" : "Nouveautés",
|
||||||
|
"Could not fetch tops" : "Impossible de récupérer les tops",
|
||||||
"Subscribe" : "S'abonner",
|
"Subscribe" : "S'abonner",
|
||||||
"Error while adding the feed" : "Erreur lors de l'ajout du flux",
|
"Error while adding the feed" : "Erreur lors de l'ajout du flux",
|
||||||
"Could not fetch search results" : "Impossible de récupérer les resultats de la recherche",
|
|
||||||
"New podcasts" : "Nouveautés",
|
|
||||||
"Hot podcasts" : "Tendances",
|
|
||||||
"Could not fetch tops" : "Impossible de récupérer les tops",
|
|
||||||
"Copy feed" : "Copier le flux",
|
|
||||||
"Link copied to the clipboard" : "Lien vers le flux copié dans le presse-papiers",
|
|
||||||
"Play" : "Lecture",
|
"Play" : "Lecture",
|
||||||
"Stop" : "Arrêter",
|
"Stop" : "Arrêter",
|
||||||
"Read" : "Lu",
|
|
||||||
"Open website" : "Ouvrir le site web",
|
|
||||||
"Could not change the status of the episode" : "Impossible de changer le status de l'épisode",
|
|
||||||
"Could not fetch episodes" : "Impossible de récuprer les épisodes",
|
"Could not fetch episodes" : "Impossible de récuprer les épisodes",
|
||||||
"Rewind 10 seconds" : "Retour rapide de 10 secondes",
|
"Download" : "Télécharger",
|
||||||
"Pause" : "Pause",
|
"Delete" : "Supprimer",
|
||||||
"Fast forward 30 seconds" : "Avance rapide de 30 secondes",
|
|
||||||
"Mute" : "Silencer",
|
|
||||||
"Unmute" : "Paroler",
|
|
||||||
"Export subscriptions" : "Exporter les abonnements",
|
|
||||||
"Filtering episodes" : "Filtrage des épisodes",
|
|
||||||
"Show all" : "Montrer tout",
|
|
||||||
"Listened" : "Écoutés",
|
|
||||||
"Listening" : "En cours",
|
|
||||||
"Unlistened" : "Non lus",
|
|
||||||
"Import subscriptions" : "Importer les abonnements",
|
|
||||||
"Import OPML file" : "Importer un fichier OPML",
|
|
||||||
"Rate RePod ❤️" : "Donnez votre avis ❤️",
|
|
||||||
"Sleep timer" : "Minuteur",
|
|
||||||
"Minutes" : "Minutes",
|
|
||||||
"_%n min_::_%n mins_" : ["%n min","%n mins"],
|
|
||||||
"_%n sec_::_%n secs_" : ["%s sec","%n secs"],
|
|
||||||
"Playback speed" : "Vitesse de lecture",
|
|
||||||
"Favorite" : "Favori",
|
|
||||||
"Are you sure you want to delete this subscription?" : "Êtes-vous sûr de vouloir supprimer ce flux ?",
|
"Are you sure you want to delete this subscription?" : "Êtes-vous sûr de vouloir supprimer ce flux ?",
|
||||||
"Error while removing the feed" : "Erreur lors de la suppression du flux",
|
"Error while removing the feed" : "Erreur lors de la suppression du flux",
|
||||||
"You can only have 10 favorites" : "Vous ne pouvez avoir que 10 favoris",
|
"Playback speed" : "Vitesse de lecture",
|
||||||
"Add a podcast" : "Ajouter un podcast",
|
"Add a podcast" : "Ajouter un podcast",
|
||||||
"Could not fetch subscriptions" : "Impossible de récupérer les flux",
|
"Could not fetch subscriptions" : "Impossible de récupérer les flux",
|
||||||
"Find a podcast" : "Chercher un podcast",
|
"Find a podcast" : "Chercher un podcast",
|
||||||
"Error loading feed" : "Erreur lors du chargement du flux",
|
"Error loading feed" : "Erreur lors du chargement du flux",
|
||||||
"Missing required app" : "Une application requise est manquante",
|
"Missing required app" : "Une application requise est manquante",
|
||||||
"Install GPodder Sync" : "Installer GPodder Sync",
|
"Install GPodder Sync" : "Installer GPodder Sync"
|
||||||
"Pin some subscriptions to see their latest updates" : "Ajoutez des abonnements en favoris pour obtenir les dernières nouvelles ici",
|
|
||||||
"No favorites" : "Aucun favoris",
|
|
||||||
"A browser extension conflict with RePod" : "Une extension de votre navigateur entre en conflit avec RePod"
|
|
||||||
},
|
},
|
||||||
"");
|
"");
|
||||||
|
49
l10n/fr.json
@ -1,57 +1,28 @@
|
|||||||
{ "translations": {
|
{ "translations": {
|
||||||
"RePod Subscriptions" : "Abonnements sur RePod",
|
|
||||||
"Podcast" : "Podcast",
|
|
||||||
"RePod" : "RePod",
|
"RePod" : "RePod",
|
||||||
|
"Podcast" : "Podcast",
|
||||||
"🔊 Browse, manage and listen to podcasts" : "🔊 Parcourir, gérer et écouter vos podcasts",
|
"🔊 Browse, manage and listen to podcasts" : "🔊 Parcourir, gérer et écouter vos podcasts",
|
||||||
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/) and [other apps](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Mobile friendly interface\n- 📡 Import and export your subscriptions\n- ➡️ Full features comparison [here](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Fonctionnalités\n- 🔍 Parcourir et s'abonner à une grande collections de podcasts\n- 🔊 Écouter vos épisodes directement sur Nextcloud\n- 🌐 Synchroniser son activité avec [AntennaPod](https://antennapod.org/) et d'autres [applications](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Interface optimisée pour mobiles et ordinateurs\n- 📡 Import/export de ses abonnements\n- ➡️ Tableau récapitulatif complet des fonctionnalitées [ici](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Pré-requis\nVous devez avoir [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installé pour utiliser cette application !",
|
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Fonctionnalités\n- 🔍 Parcourir et s'abonner à une grande collections de podcasts\n- 🔊 Écouter vos épisodes directement sur Nextcloud\n- 🌐 Synchroniser son activité avec [AntennaPod](https://antennapod.org/)\n\n## Pré-requis\nVous devez avoir [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installé pour utiliser cette application !",
|
||||||
"Download" : "Télécharger",
|
|
||||||
"Skip to {match}" : "Sauter à {match}",
|
|
||||||
"Add a RSS link" : "Ajouter un lien RSS",
|
"Add a RSS link" : "Ajouter un lien RSS",
|
||||||
|
"Could not fetch search results" : "Impossible de récupérer les resultats de la recherche",
|
||||||
|
"Hot podcasts" : "Tendances",
|
||||||
|
"New podcasts" : "Nouveautés",
|
||||||
|
"Could not fetch tops" : "Impossible de récupérer les tops",
|
||||||
"Subscribe" : "S'abonner",
|
"Subscribe" : "S'abonner",
|
||||||
"Error while adding the feed" : "Erreur lors de l'ajout du flux",
|
"Error while adding the feed" : "Erreur lors de l'ajout du flux",
|
||||||
"Could not fetch search results" : "Impossible de récupérer les resultats de la recherche",
|
|
||||||
"New podcasts" : "Nouveautés",
|
|
||||||
"Hot podcasts" : "Tendances",
|
|
||||||
"Could not fetch tops" : "Impossible de récupérer les tops",
|
|
||||||
"Copy feed" : "Copier le flux",
|
|
||||||
"Link copied to the clipboard" : "Lien vers le flux copié dans le presse-papiers",
|
|
||||||
"Play" : "Lecture",
|
"Play" : "Lecture",
|
||||||
"Stop" : "Arrêter",
|
"Stop" : "Arrêter",
|
||||||
"Read" : "Lu",
|
|
||||||
"Open website" : "Ouvrir le site web",
|
|
||||||
"Could not change the status of the episode" : "Impossible de changer le status de l'épisode",
|
|
||||||
"Could not fetch episodes" : "Impossible de récuprer les épisodes",
|
"Could not fetch episodes" : "Impossible de récuprer les épisodes",
|
||||||
"Rewind 10 seconds" : "Retour rapide de 10 secondes",
|
"Download" : "Télécharger",
|
||||||
"Pause" : "Pause",
|
"Delete" : "Supprimer",
|
||||||
"Fast forward 30 seconds" : "Avance rapide de 30 secondes",
|
|
||||||
"Mute" : "Silencer",
|
|
||||||
"Unmute" : "Paroler",
|
|
||||||
"Export subscriptions" : "Exporter les abonnements",
|
|
||||||
"Filtering episodes" : "Filtrage des épisodes",
|
|
||||||
"Show all" : "Montrer tout",
|
|
||||||
"Listened" : "Écoutés",
|
|
||||||
"Listening" : "En cours",
|
|
||||||
"Unlistened" : "Non lus",
|
|
||||||
"Import subscriptions" : "Importer les abonnements",
|
|
||||||
"Import OPML file" : "Importer un fichier OPML",
|
|
||||||
"Rate RePod ❤️" : "Donnez votre avis ❤️",
|
|
||||||
"Sleep timer" : "Minuteur",
|
|
||||||
"Minutes" : "Minutes",
|
|
||||||
"_%n min_::_%n mins_" : ["%n min","%n mins"],
|
|
||||||
"_%n sec_::_%n secs_" : ["%s sec","%n secs"],
|
|
||||||
"Playback speed" : "Vitesse de lecture",
|
|
||||||
"Favorite" : "Favori",
|
|
||||||
"Are you sure you want to delete this subscription?" : "Êtes-vous sûr de vouloir supprimer ce flux ?",
|
"Are you sure you want to delete this subscription?" : "Êtes-vous sûr de vouloir supprimer ce flux ?",
|
||||||
"Error while removing the feed" : "Erreur lors de la suppression du flux",
|
"Error while removing the feed" : "Erreur lors de la suppression du flux",
|
||||||
"You can only have 10 favorites" : "Vous ne pouvez avoir que 10 favoris",
|
"Playback speed" : "Vitesse de lecture",
|
||||||
"Add a podcast" : "Ajouter un podcast",
|
"Add a podcast" : "Ajouter un podcast",
|
||||||
"Could not fetch subscriptions" : "Impossible de récupérer les flux",
|
"Could not fetch subscriptions" : "Impossible de récupérer les flux",
|
||||||
"Find a podcast" : "Chercher un podcast",
|
"Find a podcast" : "Chercher un podcast",
|
||||||
"Error loading feed" : "Erreur lors du chargement du flux",
|
"Error loading feed" : "Erreur lors du chargement du flux",
|
||||||
"Missing required app" : "Une application requise est manquante",
|
"Missing required app" : "Une application requise est manquante",
|
||||||
"Install GPodder Sync" : "Installer GPodder Sync",
|
"Install GPodder Sync" : "Installer GPodder Sync"
|
||||||
"Pin some subscriptions to see their latest updates" : "Ajoutez des abonnements en favoris pour obtenir les dernières nouvelles ici",
|
|
||||||
"No favorites" : "Aucun favoris",
|
|
||||||
"A browser extension conflict with RePod" : "Une extension de votre navigateur entre en conflit avec RePod"
|
|
||||||
},"pluralForm" :""
|
},"pluralForm" :""
|
||||||
}
|
}
|
@ -16,7 +16,6 @@ use OCP\AppFramework\Services\IInitialState;
|
|||||||
class Application extends App implements IBootstrap
|
class Application extends App implements IBootstrap
|
||||||
{
|
{
|
||||||
public const APP_ID = 'repod';
|
public const APP_ID = 'repod';
|
||||||
|
|
||||||
private const GPODDERSYNC_ID = 'gpoddersync';
|
private const GPODDERSYNC_ID = 'gpoddersync';
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct() {
|
||||||
@ -33,15 +32,11 @@ class Application extends App implements IBootstrap
|
|||||||
/** @var IInitialState $initialState */
|
/** @var IInitialState $initialState */
|
||||||
$initialState = $appContainer->get(IInitialState::class);
|
$initialState = $appContainer->get(IInitialState::class);
|
||||||
|
|
||||||
if (null === $appManager->getAppInfo(self::GPODDERSYNC_ID)) {
|
|
||||||
$appManager->disableApp(self::GPODDERSYNC_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
$gpoddersync = $appManager->isEnabledForUser(self::GPODDERSYNC_ID);
|
$gpoddersync = $appManager->isEnabledForUser(self::GPODDERSYNC_ID);
|
||||||
if (!$gpoddersync) {
|
if (!$gpoddersync) {
|
||||||
try {
|
try {
|
||||||
$appManager->enableApp(self::GPODDERSYNC_ID);
|
$appManager->enableApp(self::GPODDERSYNC_ID);
|
||||||
} catch (AppPathNotFoundException) {
|
} catch (AppPathNotFoundException $e) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,9 +11,6 @@ use OCA\RePod\Core\EpisodeAction\EpisodeActionReader;
|
|||||||
use OCA\RePod\Service\UserService;
|
use OCA\RePod\Service\UserService;
|
||||||
use OCP\AppFramework\Controller;
|
use OCP\AppFramework\Controller;
|
||||||
use OCP\AppFramework\Http;
|
use OCP\AppFramework\Http;
|
||||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
|
||||||
use OCP\AppFramework\Http\JSONResponse;
|
use OCP\AppFramework\Http\JSONResponse;
|
||||||
use OCP\Http\Client\IClientService;
|
use OCP\Http\Client\IClientService;
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
@ -22,30 +19,26 @@ class EpisodesController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IRequest $request,
|
IRequest $request,
|
||||||
private readonly EpisodeActionReader $episodeActionReader,
|
private EpisodeActionReader $episodeActionReader,
|
||||||
private readonly EpisodeActionRepository $episodeActionRepository,
|
private EpisodeActionRepository $episodeActionRepository,
|
||||||
private readonly IClientService $clientService,
|
private IClientService $clientService,
|
||||||
private readonly UserService $userService
|
private UserService $userService
|
||||||
) {
|
) {
|
||||||
parent::__construct(Application::APP_ID, $request);
|
parent::__construct(Application::APP_ID, $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[NoAdminRequired]
|
|
||||||
#[NoCSRFRequired]
|
|
||||||
#[FrontpageRoute(verb: 'GET', url: '/episodes/list')]
|
|
||||||
public function list(string $url): JSONResponse {
|
public function list(string $url): JSONResponse {
|
||||||
$client = $this->clientService->newClient();
|
$client = $this->clientService->newClient();
|
||||||
$feed = $client->get($url);
|
$feed = $client->get($url);
|
||||||
|
|
||||||
$episodes = $this->episodeActionReader->parseRssXml((string) $feed->getBody());
|
$episodes = $this->episodeActionReader->parseRssXml((string) $feed->getBody());
|
||||||
usort($episodes, fn (EpisodeActionExtraData $a, EpisodeActionExtraData $b): int => $b->getPubDate() <=> $a->getPubDate());
|
|
||||||
$episodes = array_values(array_intersect_key($episodes, array_unique(array_map(fn (EpisodeActionExtraData $episode): string => $episode->getGuid(), $episodes))));
|
usort($episodes, fn (EpisodeActionExtraData $a, EpisodeActionExtraData $b) => $b->getPubDate() <=> $a->getPubDate());
|
||||||
|
$episodes = array_values(array_intersect_key($episodes, array_unique(array_map(fn (EpisodeActionExtraData $episode) => $episode->getGuid(), $episodes))));
|
||||||
|
|
||||||
return new JSONResponse($episodes, $feed->getStatusCode());
|
return new JSONResponse($episodes, $feed->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[NoAdminRequired]
|
|
||||||
#[NoCSRFRequired]
|
|
||||||
#[FrontpageRoute(verb: 'GET', url: '/episodes/action')]
|
|
||||||
public function action(string $url): JSONResponse {
|
public function action(string $url): JSONResponse {
|
||||||
$action = $this->episodeActionRepository->findByEpisodeUrl($url, $this->userService->getUserUID());
|
$action = $this->episodeActionRepository->findByEpisodeUrl($url, $this->userService->getUserUID());
|
||||||
|
|
||||||
|
@ -1,109 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace OCA\RePod\Controller;
|
|
||||||
|
|
||||||
use OCA\GPodderSync\Core\PodcastData\PodcastDataReader;
|
|
||||||
use OCA\GPodderSync\Core\PodcastData\PodcastMetricsReader;
|
|
||||||
use OCA\GPodderSync\Core\SubscriptionChange\SubscriptionChangeSaver;
|
|
||||||
use OCA\RePod\AppInfo\Application;
|
|
||||||
use OCA\RePod\Service\UserService;
|
|
||||||
use OCP\AppFramework\Controller;
|
|
||||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
|
||||||
use OCP\AppFramework\Http\DataDownloadResponse;
|
|
||||||
use OCP\AppFramework\Http\Response;
|
|
||||||
use OCP\IL10N;
|
|
||||||
use OCP\IRequest;
|
|
||||||
|
|
||||||
class OpmlController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
IRequest $request,
|
|
||||||
private readonly IL10N $l10n,
|
|
||||||
private readonly PodcastDataReader $podcastDataReader,
|
|
||||||
private readonly PodcastMetricsReader $podcastMetricsReader,
|
|
||||||
private readonly SubscriptionChangeSaver $subscriptionChangeSaver,
|
|
||||||
private readonly UserService $userService
|
|
||||||
) {
|
|
||||||
parent::__construct(Application::APP_ID, $request);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[NoAdminRequired]
|
|
||||||
#[NoCSRFRequired]
|
|
||||||
#[FrontpageRoute(verb: 'GET', url: '/opml/export')]
|
|
||||||
public function export(): DataDownloadResponse {
|
|
||||||
// https://github.com/AntennaPod/AntennaPod/blob/master/core/src/main/java/de/danoeh/antennapod/core/export/opml/OpmlWriter.java
|
|
||||||
$xml = new \SimpleXMLElement('<opml/>', namespaceOrPrefix: 'http://xmlpull.org/v1/doc/features.html#indent-output');
|
|
||||||
$xml->addAttribute('version', '2.0');
|
|
||||||
|
|
||||||
$dateCreated = new \DateTime();
|
|
||||||
$head = $xml->addChild('head');
|
|
||||||
|
|
||||||
if (isset($head)) {
|
|
||||||
$head->addChild('title', $this->l10n->t('RePod Subscriptions'));
|
|
||||||
$head->addChild('dateCreated', $dateCreated->format(\DateTime::RFC822));
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = $xml->addChild('body');
|
|
||||||
|
|
||||||
if (isset($body)) {
|
|
||||||
$subscriptions = $this->podcastMetricsReader->metrics($this->userService->getUserUID());
|
|
||||||
|
|
||||||
foreach ($subscriptions as $subscription) {
|
|
||||||
try {
|
|
||||||
$podcast = $this->podcastDataReader->getCachedOrFetchPodcastData($subscription->getUrl(), $this->userService->getUserUID());
|
|
||||||
} catch (\Exception) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($podcast) {
|
|
||||||
$outline = $body->addChild('outline');
|
|
||||||
|
|
||||||
if (isset($outline)) {
|
|
||||||
$outline->addAttribute('xmlUrl', $subscription->getUrl());
|
|
||||||
|
|
||||||
$title = $podcast->getTitle();
|
|
||||||
$link = $podcast->getLink();
|
|
||||||
|
|
||||||
if (isset($title)) {
|
|
||||||
$outline->addAttribute('text', $title);
|
|
||||||
$outline->addAttribute('title', $title);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($link)) {
|
|
||||||
$outline->addAttribute('htmlUrl', $link);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new DataDownloadResponse((string) $xml->asXML(), 'repod-'.$dateCreated->getTimestamp().'.opml', ' application/xml');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[NoAdminRequired]
|
|
||||||
#[NoCSRFRequired]
|
|
||||||
#[FrontpageRoute(verb: 'POST', url: '/opml/import')]
|
|
||||||
public function import(): Response {
|
|
||||||
$file = $this->request->getUploadedFile('import');
|
|
||||||
|
|
||||||
if ($file) {
|
|
||||||
$xml = new \SimpleXMLElement(file_get_contents((string) $file['tmp_name']));
|
|
||||||
|
|
||||||
/** @var \SimpleXMLElement[] $outlines */
|
|
||||||
$outlines = $xml->body->children();
|
|
||||||
|
|
||||||
$toSubscribe = [];
|
|
||||||
foreach ($outlines as $outline) {
|
|
||||||
$toSubscribe[] = (string) $outline['xmlUrl'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->subscriptionChangeSaver->saveSubscriptionChanges($toSubscribe, [], $this->userService->getUserUID());
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response();
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,50 +6,41 @@ namespace OCA\RePod\Controller;
|
|||||||
|
|
||||||
use OCA\RePod\AppInfo\Application;
|
use OCA\RePod\AppInfo\Application;
|
||||||
use OCP\AppFramework\Controller;
|
use OCP\AppFramework\Controller;
|
||||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
|
||||||
use OCP\AppFramework\Http\ContentSecurityPolicy;
|
use OCP\AppFramework\Http\ContentSecurityPolicy;
|
||||||
use OCP\AppFramework\Http\TemplateResponse;
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
|
use OCP\IConfig;
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
use OCP\Util;
|
use OCP\Util;
|
||||||
|
|
||||||
class PageController extends Controller
|
class PageController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IRequest $request
|
IRequest $request,
|
||||||
|
private IConfig $config
|
||||||
) {
|
) {
|
||||||
parent::__construct(Application::APP_ID, $request);
|
parent::__construct(Application::APP_ID, $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[NoAdminRequired]
|
/**
|
||||||
#[NoCSRFRequired]
|
* @NoAdminRequired
|
||||||
#[FrontpageRoute(verb: 'GET', url: '/')]
|
* @NoCSRFRequired
|
||||||
|
*/
|
||||||
public function index(): TemplateResponse {
|
public function index(): TemplateResponse {
|
||||||
Util::addScript(Application::APP_ID, Application::APP_ID.'-main');
|
Util::addScript(Application::APP_ID, 'repod-main');
|
||||||
Util::addStyle(Application::APP_ID, Application::APP_ID.'-main');
|
|
||||||
|
|
||||||
$csp = new ContentSecurityPolicy();
|
$csp = new ContentSecurityPolicy();
|
||||||
$csp->addAllowedImageDomain('*');
|
$csp->addAllowedImageDomain('*');
|
||||||
$csp->addAllowedMediaDomain('*');
|
$csp->addAllowedMediaDomain('*');
|
||||||
|
|
||||||
|
if ($this->config->getSystemValueBool('debug')) {
|
||||||
|
// Unblock HMR requests.
|
||||||
|
$csp->addAllowedConnectDomain('*');
|
||||||
|
$csp->addAllowedScriptDomain('*');
|
||||||
|
}
|
||||||
|
|
||||||
$response = new TemplateResponse(Application::APP_ID, 'main');
|
$response = new TemplateResponse(Application::APP_ID, 'main');
|
||||||
$response->setContentSecurityPolicy($csp);
|
$response->setContentSecurityPolicy($csp);
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[NoAdminRequired]
|
|
||||||
#[NoCSRFRequired]
|
|
||||||
#[FrontpageRoute(verb: 'GET', url: '/discover')]
|
|
||||||
public function discover(): TemplateResponse {
|
|
||||||
return $this->index();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[NoAdminRequired]
|
|
||||||
#[NoCSRFRequired]
|
|
||||||
#[FrontpageRoute(verb: 'GET', url: '/feed/{path}', requirements: ['path' => '.+'])]
|
|
||||||
public function feed(): TemplateResponse {
|
|
||||||
return $this->index();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -8,37 +8,22 @@ use OCA\GPodderSync\Core\PodcastData\PodcastData;
|
|||||||
use OCA\GPodderSync\Core\PodcastData\PodcastDataReader;
|
use OCA\GPodderSync\Core\PodcastData\PodcastDataReader;
|
||||||
use OCA\RePod\AppInfo\Application;
|
use OCA\RePod\AppInfo\Application;
|
||||||
use OCP\AppFramework\Controller;
|
use OCP\AppFramework\Controller;
|
||||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
|
||||||
use OCP\AppFramework\Http\JSONResponse;
|
use OCP\AppFramework\Http\JSONResponse;
|
||||||
use OCP\Http\Client\IClientService;
|
use OCP\Http\Client\IClientService;
|
||||||
use OCP\ICacheFactory;
|
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
|
|
||||||
class PodcastController extends Controller
|
class PodcastController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IRequest $request,
|
IRequest $request,
|
||||||
private readonly ICacheFactory $cacheFactory,
|
private IClientService $clientService,
|
||||||
private readonly IClientService $clientService,
|
private PodcastDataReader $podcastDataReader
|
||||||
private readonly PodcastDataReader $podcastDataReader
|
|
||||||
) {
|
) {
|
||||||
parent::__construct(Application::APP_ID, $request);
|
parent::__construct(Application::APP_ID, $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[NoAdminRequired]
|
|
||||||
#[NoCSRFRequired]
|
|
||||||
#[FrontpageRoute(verb: 'GET', url: '/podcast')]
|
|
||||||
public function index(string $url): JSONResponse {
|
public function index(string $url): JSONResponse {
|
||||||
$podcast = null;
|
$podcast = $this->podcastDataReader->tryGetCachedPodcastData($url);
|
||||||
|
|
||||||
if ($this->cacheFactory->isLocalCacheAvailable()) {
|
|
||||||
try {
|
|
||||||
$podcast = $this->podcastDataReader->tryGetCachedPodcastData($url);
|
|
||||||
} catch (\Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($podcast) {
|
if ($podcast) {
|
||||||
return new JSONResponse($podcast);
|
return new JSONResponse($podcast);
|
||||||
@ -47,13 +32,7 @@ class PodcastController extends Controller
|
|||||||
$client = $this->clientService->newClient();
|
$client = $this->clientService->newClient();
|
||||||
$feed = $client->get($url);
|
$feed = $client->get($url);
|
||||||
$podcast = PodcastData::parseRssXml((string) $feed->getBody());
|
$podcast = PodcastData::parseRssXml((string) $feed->getBody());
|
||||||
|
$this->podcastDataReader->trySetCachedPodcastData($url, $podcast);
|
||||||
if ($this->cacheFactory->isLocalCacheAvailable()) {
|
|
||||||
try {
|
|
||||||
$this->podcastDataReader->trySetCachedPodcastData($url, $podcast);
|
|
||||||
} catch (\Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JSONResponse($podcast, $feed->getStatusCode());
|
return new JSONResponse($podcast, $feed->getStatusCode());
|
||||||
}
|
}
|
||||||
|
@ -7,9 +7,6 @@ namespace OCA\RePod\Controller;
|
|||||||
use OCA\RePod\AppInfo\Application;
|
use OCA\RePod\AppInfo\Application;
|
||||||
use OCA\RePod\Service\MultiPodService;
|
use OCA\RePod\Service\MultiPodService;
|
||||||
use OCP\AppFramework\Controller;
|
use OCP\AppFramework\Controller;
|
||||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
|
||||||
use OCP\AppFramework\Http\JSONResponse;
|
use OCP\AppFramework\Http\JSONResponse;
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
|
|
||||||
@ -17,15 +14,12 @@ class SearchController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IRequest $request,
|
IRequest $request,
|
||||||
private readonly MultiPodService $multiPodService
|
private MultiPodService $multiPodService
|
||||||
) {
|
) {
|
||||||
parent::__construct(Application::APP_ID, $request);
|
parent::__construct(Application::APP_ID, $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[NoAdminRequired]
|
public function index(string $value): JSONResponse {
|
||||||
#[NoCSRFRequired]
|
return new JSONResponse($this->multiPodService->search($value));
|
||||||
#[FrontpageRoute(verb: 'GET', url: '/search')]
|
|
||||||
public function index(string $q): JSONResponse {
|
|
||||||
return new JSONResponse($this->multiPodService->search($q));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,31 +7,22 @@ namespace OCA\RePod\Controller;
|
|||||||
use OCA\RePod\AppInfo\Application;
|
use OCA\RePod\AppInfo\Application;
|
||||||
use OCA\RePod\Service\FyydService;
|
use OCA\RePod\Service\FyydService;
|
||||||
use OCP\AppFramework\Controller;
|
use OCP\AppFramework\Controller;
|
||||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
|
||||||
use OCP\AppFramework\Http\JSONResponse;
|
use OCP\AppFramework\Http\JSONResponse;
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
|
|
||||||
class ToplistController extends Controller
|
class TopsController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IRequest $request,
|
IRequest $request,
|
||||||
private readonly FyydService $fyydService
|
private FyydService $fyydService
|
||||||
) {
|
) {
|
||||||
parent::__construct(Application::APP_ID, $request);
|
parent::__construct(Application::APP_ID, $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[NoAdminRequired]
|
|
||||||
#[NoCSRFRequired]
|
|
||||||
#[FrontpageRoute(verb: 'GET', url: '/toplist/hot')]
|
|
||||||
public function hot(): JSONResponse {
|
public function hot(): JSONResponse {
|
||||||
return new JSONResponse($this->fyydService->hot());
|
return new JSONResponse($this->fyydService->hot());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[NoAdminRequired]
|
|
||||||
#[NoCSRFRequired]
|
|
||||||
#[FrontpageRoute(verb: 'GET', url: '/toplist/new')]
|
|
||||||
public function new(): JSONResponse {
|
public function new(): JSONResponse {
|
||||||
return new JSONResponse($this->fyydService->latest());
|
return new JSONResponse($this->fyydService->latest());
|
||||||
}
|
}
|
@ -28,22 +28,22 @@ use OCA\GPodderSync\Core\EpisodeAction\EpisodeAction;
|
|||||||
* action: ?EpisodeActionType
|
* action: ?EpisodeActionType
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
class EpisodeActionExtraData implements \JsonSerializable, \Stringable
|
class EpisodeActionExtraData implements \JsonSerializable
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $title,
|
private string $title,
|
||||||
private readonly ?string $url,
|
private ?string $url,
|
||||||
private readonly string $name,
|
private string $name,
|
||||||
private readonly ?string $link,
|
private ?string $link,
|
||||||
private readonly ?string $image,
|
private ?string $image,
|
||||||
private readonly ?string $description,
|
private ?string $description,
|
||||||
private readonly int $fetchedAtUnix,
|
private int $fetchedAtUnix,
|
||||||
private readonly string $guid,
|
private string $guid,
|
||||||
private readonly ?string $type,
|
private ?string $type,
|
||||||
private readonly ?int $size,
|
private ?int $size,
|
||||||
private readonly ?\DateTime $pubDate,
|
private ?\DateTime $pubDate,
|
||||||
private readonly ?string $duration,
|
private ?string $duration,
|
||||||
private readonly ?EpisodeAction $action
|
private ?EpisodeAction $action
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __toString(): string {
|
public function __toString(): string {
|
||||||
@ -120,7 +120,7 @@ class EpisodeActionExtraData implements \JsonSerializable, \Stringable
|
|||||||
'size' => $this->size,
|
'size' => $this->size,
|
||||||
'pubDate' => $this->pubDate,
|
'pubDate' => $this->pubDate,
|
||||||
'duration' => $this->duration,
|
'duration' => $this->duration,
|
||||||
'action' => $this->action instanceof EpisodeAction ? $this->action->toArray() : null,
|
'action' => $this->action ? $this->action->toArray() : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,8 +11,8 @@ use OCA\RePod\Service\UserService;
|
|||||||
class EpisodeActionReader extends CoreEpisodeActionReader
|
class EpisodeActionReader extends CoreEpisodeActionReader
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EpisodeActionRepository $episodeActionRepository,
|
private EpisodeActionRepository $episodeActionRepository,
|
||||||
private readonly UserService $userService
|
private UserService $userService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -20,8 +20,7 @@ class EpisodeActionReader extends CoreEpisodeActionReader
|
|||||||
* Specs : https://github.com/Podcast-Standards-Project/PSP-1-Podcast-RSS-Specification/blob/main/README.md.
|
* Specs : https://github.com/Podcast-Standards-Project/PSP-1-Podcast-RSS-Specification/blob/main/README.md.
|
||||||
*
|
*
|
||||||
* @return EpisodeActionExtraData[]
|
* @return EpisodeActionExtraData[]
|
||||||
*
|
* @throws \Exception if the XML data could not be parsed
|
||||||
* @throws \Exception if the XML data could not be parsed
|
|
||||||
*/
|
*/
|
||||||
public function parseRssXml(string $xmlString, ?int $fetchedAtUnix = null): array {
|
public function parseRssXml(string $xmlString, ?int $fetchedAtUnix = null): array {
|
||||||
$episodes = [];
|
$episodes = [];
|
||||||
@ -56,59 +55,52 @@ class EpisodeActionReader extends CoreEpisodeActionReader
|
|||||||
$link = $this->stringOrNull($item->link);
|
$link = $this->stringOrNull($item->link);
|
||||||
|
|
||||||
// Get episode image
|
// Get episode image
|
||||||
if (isset($iTunesItemChildren)) {
|
$image = $this->stringOrNull($item->image->url);
|
||||||
|
|
||||||
|
if (!$image && $iTunesItemChildren) {
|
||||||
$imageAttributes = $iTunesItemChildren->image->attributes();
|
$imageAttributes = $iTunesItemChildren->image->attributes();
|
||||||
$image = $this->stringOrNull(isset($imageAttributes) ? (string) $imageAttributes->href : '');
|
$image = $this->stringOrNull($imageAttributes ? (string) $imageAttributes->href : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($image) && isset($iTunesChannelChildren)) {
|
if (!$image) {
|
||||||
$imageAttributes = $iTunesChannelChildren->image->attributes();
|
|
||||||
$image = $this->stringOrNull(isset($imageAttributes) ? (string) $imageAttributes->href : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($image)) {
|
|
||||||
$image = $this->stringOrNull($item->image->url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($image)) {
|
|
||||||
$image = $this->stringOrNull($channel->image->url);
|
$image = $this->stringOrNull($channel->image->url);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($image)) {
|
if (!$image && $iTunesChannelChildren) {
|
||||||
|
$imageAttributes = $iTunesChannelChildren->image->attributes();
|
||||||
|
$image = $this->stringOrNull($imageAttributes ? (string) $imageAttributes->href : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$image) {
|
||||||
preg_match('/<itunes:image\s+href="([^"]+)"/', $xmlString, $matches);
|
preg_match('/<itunes:image\s+href="([^"]+)"/', $xmlString, $matches);
|
||||||
if (count($matches) > 1) {
|
$image = $this->stringOrNull($matches[1]);
|
||||||
$image = $this->stringOrNull($matches[1]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get episode description
|
// Get episode description
|
||||||
$itemContent = $item->children('content', true);
|
$itemContent = $item->children('content', true);
|
||||||
if (isset($itemContent)) {
|
if ($itemContent) {
|
||||||
$description = $this->stringOrNull($itemContent->encoded);
|
$description = $this->stringOrNull($itemContent->encoded);
|
||||||
} else {
|
} else {
|
||||||
$description = $this->stringOrNull($item->description);
|
$description = $this->stringOrNull($item->description);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($description) && isset($iTunesItemChildren)) {
|
if (!$description && $iTunesItemChildren) {
|
||||||
$description = $this->stringOrNull($iTunesItemChildren->summary);
|
$description = $this->stringOrNull($iTunesItemChildren->summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove tags
|
||||||
|
$description = strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $description ?? ''));
|
||||||
|
|
||||||
// Get episode duration
|
// Get episode duration
|
||||||
if (isset($iTunesItemChildren)) {
|
if ($iTunesItemChildren) {
|
||||||
$duration = $this->stringOrNull($iTunesItemChildren->duration);
|
$duration = $this->stringOrNull($iTunesItemChildren->duration);
|
||||||
} else {
|
} else {
|
||||||
$duration = $this->stringOrNull($item->duration);
|
$duration = $this->stringOrNull($item->duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get episode pubDate
|
// Get episode pubDate
|
||||||
$pubDate = $this->stringOrNull($item->pubDate);
|
$rawPubDate = $this->stringOrNull($item->pubDate);
|
||||||
if (isset($pubDate)) {
|
$pubDate = $rawPubDate ? new \DateTime($rawPubDate) : null;
|
||||||
try {
|
|
||||||
$pubDate = new \DateTime($pubDate);
|
|
||||||
} catch (\Exception) {
|
|
||||||
$pubDate = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$episodes[] = new EpisodeActionExtraData(
|
$episodes[] = new EpisodeActionExtraData(
|
||||||
$title,
|
$title,
|
||||||
@ -134,8 +126,7 @@ class EpisodeActionReader extends CoreEpisodeActionReader
|
|||||||
* @param null|\SimpleXMLElement|string $value
|
* @param null|\SimpleXMLElement|string $value
|
||||||
*/
|
*/
|
||||||
private function stringOrNull($value): ?string {
|
private function stringOrNull($value): ?string {
|
||||||
/** @psalm-suppress RiskyTruthyFalsyComparison */
|
if ($value) {
|
||||||
if (!empty($value)) {
|
|
||||||
return (string) $value;
|
return (string) $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,9 +13,9 @@ class FyydService implements IPodProvider
|
|||||||
private const BASE_URL = 'https://api.fyyd.de/0.2/';
|
private const BASE_URL = 'https://api.fyyd.de/0.2/';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly IClientService $clientService,
|
private IClientService $clientService,
|
||||||
private readonly LoggerInterface $logger,
|
private LoggerInterface $logger,
|
||||||
private readonly UserService $userService
|
private UserService $userService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function search(string $value): array {
|
public function search(string $value): array {
|
||||||
@ -34,16 +34,14 @@ class FyydService implements IPodProvider
|
|||||||
if (array_key_exists('data', $json) && is_array($json['data'])) {
|
if (array_key_exists('data', $json) && is_array($json['data'])) {
|
||||||
/** @var string[] $feed */
|
/** @var string[] $feed */
|
||||||
foreach ($json['data'] as $feed) {
|
foreach ($json['data'] as $feed) {
|
||||||
if ($feed['title']) {
|
$podcasts[] = new PodcastData(
|
||||||
$podcasts[] = new PodcastData(
|
$feed['title'],
|
||||||
$feed['title'],
|
$feed['author'],
|
||||||
$feed['author'],
|
$feed['xmlURL'],
|
||||||
$feed['xmlURL'],
|
$feed['description'],
|
||||||
$feed['description'],
|
$feed['imgURL'],
|
||||||
$feed['imgURL'],
|
strtotime($feed['lastpub'])
|
||||||
strtotime($feed['lastpub'])
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,16 +60,14 @@ class FyydService implements IPodProvider
|
|||||||
if (array_key_exists('data', $podcastJson) && is_array($podcastJson['data'])) {
|
if (array_key_exists('data', $podcastJson) && is_array($podcastJson['data'])) {
|
||||||
/** @var string[] $feed */
|
/** @var string[] $feed */
|
||||||
foreach ($podcastJson['data'] as $feed) {
|
foreach ($podcastJson['data'] as $feed) {
|
||||||
if ($feed['title']) {
|
$podcasts[] = new PodcastData(
|
||||||
$podcasts[] = new PodcastData(
|
$feed['title'],
|
||||||
$feed['title'],
|
$feed['author'],
|
||||||
$feed['author'],
|
$feed['xmlURL'],
|
||||||
$feed['xmlURL'],
|
$feed['description'],
|
||||||
$feed['description'],
|
$feed['imgURL'],
|
||||||
$feed['imgURL'],
|
strtotime($feed['lastpub'])
|
||||||
strtotime($feed['lastpub'])
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,12 +86,11 @@ class FyydService implements IPodProvider
|
|||||||
$langClient = $this->clientService->newClient();
|
$langClient = $this->clientService->newClient();
|
||||||
$langResponse = $langClient->get(self::BASE_URL.'feature/podcast/hot/languages');
|
$langResponse = $langClient->get(self::BASE_URL.'feature/podcast/hot/languages');
|
||||||
$langJson = (array) json_decode((string) $langResponse->getBody(), true, flags: JSON_THROW_ON_ERROR);
|
$langJson = (array) json_decode((string) $langResponse->getBody(), true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
if (array_key_exists('data', $langJson) && is_array($langJson['data'])) {
|
if (array_key_exists('data', $langJson) && is_array($langJson['data'])) {
|
||||||
$language = in_array($userLang, $langJson['data']) ? $userLang : 'en';
|
$language = in_array($userLang, $langJson['data']) ? $userLang : 'en';
|
||||||
}
|
}
|
||||||
} catch (\Exception $exception) {
|
} catch (\Exception $e) {
|
||||||
$this->logger->error($exception->getMessage(), $exception->getTrace());
|
$this->logger->error($e->getMessage(), $e->getTrace());
|
||||||
}
|
}
|
||||||
|
|
||||||
$podcastClient = $this->clientService->newClient();
|
$podcastClient = $this->clientService->newClient();
|
||||||
@ -110,16 +105,14 @@ class FyydService implements IPodProvider
|
|||||||
if (array_key_exists('data', $postCastJson) && is_array($postCastJson['data'])) {
|
if (array_key_exists('data', $postCastJson) && is_array($postCastJson['data'])) {
|
||||||
/** @var string[] $feed */
|
/** @var string[] $feed */
|
||||||
foreach ($postCastJson['data'] as $feed) {
|
foreach ($postCastJson['data'] as $feed) {
|
||||||
if ($feed['title']) {
|
$podcasts[] = new PodcastData(
|
||||||
$podcasts[] = new PodcastData(
|
$feed['title'],
|
||||||
$feed['title'],
|
$feed['author'],
|
||||||
$feed['author'],
|
$feed['xmlURL'],
|
||||||
$feed['xmlURL'],
|
$feed['description'],
|
||||||
$feed['description'],
|
$feed['imgURL'],
|
||||||
$feed['imgURL'],
|
strtotime($feed['lastpub'])
|
||||||
strtotime($feed['lastpub'])
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,8 +12,8 @@ class ItunesService implements IPodProvider
|
|||||||
private const BASE_URL = 'https://itunes.apple.com/';
|
private const BASE_URL = 'https://itunes.apple.com/';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly IClientService $clientService,
|
private IClientService $clientService,
|
||||||
private readonly UserService $userService
|
private UserService $userService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function search(string $value): array {
|
public function search(string $value): array {
|
||||||
|
@ -17,7 +17,7 @@ class MultiPodService implements IPodProvider
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
FyydService $fyydService,
|
FyydService $fyydService,
|
||||||
ItunesService $itunesService,
|
ItunesService $itunesService,
|
||||||
private readonly LoggerInterface $logger
|
private LoggerInterface $logger
|
||||||
) {
|
) {
|
||||||
$this->providers = [$fyydService, $itunesService];
|
$this->providers = [$fyydService, $itunesService];
|
||||||
}
|
}
|
||||||
@ -36,7 +36,7 @@ class MultiPodService implements IPodProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
usort($podcasts, fn (PodcastData $a, PodcastData $b): int => $b->getFetchedAtUnix() <=> $a->getFetchedAtUnix());
|
usort($podcasts, fn (PodcastData $a, PodcastData $b) => $b->getFetchedAtUnix() <=> $a->getFetchedAtUnix());
|
||||||
|
|
||||||
return array_values(
|
return array_values(
|
||||||
array_intersect_key(
|
array_intersect_key(
|
||||||
|
@ -16,9 +16,9 @@ use OCP\Search\SearchResultEntry;
|
|||||||
class SearchProvider implements IProvider
|
class SearchProvider implements IProvider
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly IL10N $l10n,
|
private IL10N $l10n,
|
||||||
private readonly IURLGenerator $urlGenerator,
|
private IURLGenerator $urlGenerator,
|
||||||
private readonly MultiPodService $multiPodService
|
private MultiPodService $multiPodService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getId(): string {
|
public function getId(): string {
|
||||||
@ -26,11 +26,11 @@ class SearchProvider implements IProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getName(): string {
|
public function getName(): string {
|
||||||
return $this->l10n->t('Podcast');
|
return $this->l10n->t('Podcasts');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getOrder(string $route, array $routeParameters): int {
|
public function getOrder(string $route, array $routeParameters): ?int {
|
||||||
if (str_starts_with($route, Application::APP_ID.'.')) {
|
if (0 === strpos($route, Application::APP_ID.'.')) {
|
||||||
// Active app, prefer my results
|
// Active app, prefer my results
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
@ -46,7 +46,7 @@ class SearchProvider implements IProvider
|
|||||||
$title = $podcast->getTitle();
|
$title = $podcast->getTitle();
|
||||||
$link = $podcast->getLink();
|
$link = $podcast->getLink();
|
||||||
|
|
||||||
if (isset($title, $link)) {
|
if ($title && $link) {
|
||||||
$searchResults[] = new SearchResultEntry(
|
$searchResults[] = new SearchResultEntry(
|
||||||
$podcast->getImageUrl() ?? $this->urlGenerator->linkTo(Application::APP_ID, 'img/app.svg'),
|
$podcast->getImageUrl() ?? $this->urlGenerator->linkTo(Application::APP_ID, 'img/app.svg'),
|
||||||
$title,
|
$title,
|
||||||
@ -58,7 +58,7 @@ class SearchProvider implements IProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
return SearchResult::complete(
|
return SearchResult::complete(
|
||||||
$this->l10n->t('Podcast'),
|
$this->l10n->t('Podcasts'),
|
||||||
$searchResults
|
$searchResults
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,8 @@ use OCP\L10N\IFactory;
|
|||||||
class UserService
|
class UserService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly IFactory $l10n,
|
private IFactory $l10n,
|
||||||
private readonly IUserSession $userSession
|
private IUserSession $userSession
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getUserUID(): string {
|
public function getUserUID(): string {
|
||||||
@ -27,7 +27,7 @@ class UserService
|
|||||||
public function getCountryCode(): string {
|
public function getCountryCode(): string {
|
||||||
$isoCodes = explode('_', $this->getIsoCode());
|
$isoCodes = explode('_', $this->getIsoCode());
|
||||||
|
|
||||||
return $isoCodes[1] ?? 'us';
|
return isset($isoCodes[1]) ? $isoCodes[1] : 'us';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getLangCode(): string {
|
public function getLangCode(): string {
|
||||||
|
18211
package-lock.json
generated
74
package.json
@ -1,49 +1,47 @@
|
|||||||
{
|
{
|
||||||
"name": "repod",
|
"name": "repod",
|
||||||
"license": "AGPL-3.0-or-later",
|
"description": "🔊 Browse, manage and listen to podcasts",
|
||||||
"type": "module",
|
"version": "1.3.0",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://git.crystalyx.net/Xefir/RePod/issues"
|
||||||
|
},
|
||||||
|
"license": "agpl",
|
||||||
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "webpack --node-env production --progress",
|
||||||
"dev": "vite --mode development build",
|
"dev": "webpack --node-env development --progress",
|
||||||
"lint": "vue-tsc && eslint src",
|
"watch": "webpack --node-env development --progress --watch",
|
||||||
"lint:fix": "vue-tsc && eslint src --fix",
|
"serve": "webpack --node-env development serve --progress",
|
||||||
"stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css",
|
"lint": "eslint --ext .js,.vue src",
|
||||||
"stylelint:fix": "stylelint src/**/*.vue src/**/*.scss src/**/*.css --fix",
|
"lint:fix": "eslint --ext .js,.vue src --fix",
|
||||||
"watch": "vite --mode development build --watch"
|
"stylelint": "stylelint css/*.css css/*.scss src/**/*.scss src/**/*.vue",
|
||||||
|
"stylelint:fix": "stylelint css/*.css css/*.scss src/**/*.scss src/**/*.vue --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nextcloud/axios": "^2.4.0",
|
||||||
|
"@nextcloud/dialogs": "^5.0.3",
|
||||||
|
"@nextcloud/initial-state": "^2.1.0",
|
||||||
|
"@nextcloud/l10n": "^2.2.0",
|
||||||
|
"@nextcloud/moment": "1.2.2",
|
||||||
|
"@nextcloud/router": "^2.2.0",
|
||||||
|
"@nextcloud/vue": "^8.4.0",
|
||||||
|
"vue": "^2",
|
||||||
|
"vue-material-design-icons": "^5.2.0",
|
||||||
|
"vue-router": "^3",
|
||||||
|
"vuex": "^3"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"extends @nextcloud/browserslist-config"
|
"extends @nextcloud/browserslist-config"
|
||||||
],
|
],
|
||||||
"prettier": "@nextcloud/prettier-config",
|
"engines": {
|
||||||
"dependencies": {
|
"node": "^20.0.0",
|
||||||
"@formatjs/intl-segmenter": "^11.7.3",
|
"npm": "^9.0.0"
|
||||||
"@nextcloud/axios": "^2.5.1",
|
|
||||||
"@nextcloud/initial-state": "^2.2.0",
|
|
||||||
"@nextcloud/l10n": "^3.1.0",
|
|
||||||
"@nextcloud/router": "^3.0.1",
|
|
||||||
"@nextcloud/vite-config": "^2.2.2",
|
|
||||||
"@nextcloud/vue": "9.0.0-alpha.5",
|
|
||||||
"dompurify": "^3.2.0",
|
|
||||||
"linkify-html": "^4.1.3",
|
|
||||||
"pinia": "^2.2.6",
|
|
||||||
"toastify-js": "^1.12.0",
|
|
||||||
"vite": "^5.4.11",
|
|
||||||
"vue": "^3.5.12",
|
|
||||||
"vue-material-design-icons": "^5.3.1",
|
|
||||||
"vue-router": "^4.4.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nextcloud/browserslist-config": "^3.0.1",
|
"@nextcloud/babel-config": "^1.0.0",
|
||||||
"@nextcloud/eslint-config": "^8.4.1",
|
"@nextcloud/browserslist-config": "^3.0.0",
|
||||||
"@nextcloud/prettier-config": "^1.1.0",
|
"@nextcloud/eslint-config": "^8.3.0",
|
||||||
"@nextcloud/stylelint-config": "^3.0.1",
|
"@nextcloud/stylelint-config": "^2.3.1",
|
||||||
"@types/toastify-js": "^1.12.3",
|
"@nextcloud/webpack-vue-config": "^6.0.0"
|
||||||
"@vue/eslint-config-typescript": "^13",
|
|
||||||
"@vue/tsconfig": "^0.6.0",
|
|
||||||
"eslint-config-prettier": "^9.1.0",
|
|
||||||
"eslint-plugin-pinia": "^0.4.1",
|
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
|
||||||
"typescript": "5.5",
|
|
||||||
"vue-tsc": "^2.1.10"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
19
psalm.xml
@ -2,12 +2,11 @@
|
|||||||
<psalm
|
<psalm
|
||||||
errorLevel="1"
|
errorLevel="1"
|
||||||
resolveFromConfigFile="true"
|
resolveFromConfigFile="true"
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xmlns="https://getpsalm.org/schema/config"
|
|
||||||
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
|
|
||||||
findUnusedBaselineEntry="true"
|
findUnusedBaselineEntry="true"
|
||||||
findUnusedCode="false"
|
findUnusedCode="false"
|
||||||
phpVersion="8.1"
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns="https://getpsalm.org/schema/config"
|
||||||
|
xsi:schemaLocation="https://getpsalm.org/schema/config https://raw.githubusercontent.com/vimeo/psalm/master/config.xsd"
|
||||||
>
|
>
|
||||||
<projectFiles>
|
<projectFiles>
|
||||||
<directory name="lib" />
|
<directory name="lib" />
|
||||||
@ -18,8 +17,20 @@
|
|||||||
</projectFiles>
|
</projectFiles>
|
||||||
<extraFiles>
|
<extraFiles>
|
||||||
<directory name="vendor" />
|
<directory name="vendor" />
|
||||||
|
<ignoreFiles>
|
||||||
|
<directory name="vendor/psalm" />
|
||||||
|
</ignoreFiles>
|
||||||
</extraFiles>
|
</extraFiles>
|
||||||
<issueHandlers>
|
<issueHandlers>
|
||||||
|
<UndefinedDocblockClass>
|
||||||
|
<errorLevel type="suppress">
|
||||||
|
<referencedClass name="OC\AppFramework\OCS\BaseResponse"/>
|
||||||
|
<referencedClass name="Doctrine\DBAL\Schema\Schema" />
|
||||||
|
<referencedClass name="Doctrine\DBAL\Schema\SchemaException" />
|
||||||
|
<referencedClass name="Doctrine\DBAL\Driver\Statement" />
|
||||||
|
<referencedClass name="Doctrine\DBAL\Schema\Table" />
|
||||||
|
</errorLevel>
|
||||||
|
</UndefinedDocblockClass>
|
||||||
<InvalidReturnType>
|
<InvalidReturnType>
|
||||||
<errorLevel type="suppress">
|
<errorLevel type="suppress">
|
||||||
<directory name="stubs" />
|
<directory name="stubs" />
|
||||||
|
34
rector.php
@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Nextcloud\Rector\Set\NextcloudSets;
|
|
||||||
use Rector\Config\RectorConfig;
|
|
||||||
|
|
||||||
return RectorConfig::configure()
|
|
||||||
->withPaths([
|
|
||||||
__DIR__.'/appinfo',
|
|
||||||
__DIR__.'/lib',
|
|
||||||
])
|
|
||||||
->withPhpSets(php81: true)
|
|
||||||
->withSets([
|
|
||||||
NextcloudSets::NEXTCLOUD_27,
|
|
||||||
])
|
|
||||||
->withPreparedSets(
|
|
||||||
deadCode: true,
|
|
||||||
codeQuality: true,
|
|
||||||
codingStyle: true,
|
|
||||||
typeDeclarations: true,
|
|
||||||
privatization: true,
|
|
||||||
instanceOf: true,
|
|
||||||
earlyReturn: true,
|
|
||||||
strictBooleans: true,
|
|
||||||
rectorPreset: true,
|
|
||||||
phpunitCodeQuality: true,
|
|
||||||
doctrineCodeQuality: true,
|
|
||||||
symfonyCodeQuality: true,
|
|
||||||
symfonyConfigs: true,
|
|
||||||
twig: true,
|
|
||||||
phpunit: true,
|
|
||||||
)
|
|
||||||
;
|
|
@ -1,4 +1,3 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||||
"rangeStrategy": "bump"
|
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 1.7 MiB |
Before Width: | Height: | Size: 857 KiB After Width: | Height: | Size: 282 KiB |
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 429 KiB |
Before Width: | Height: | Size: 817 KiB After Width: | Height: | Size: 171 KiB |
Before Width: | Height: | Size: 961 KiB After Width: | Height: | Size: 214 KiB |
13
src/App.vue
@ -7,15 +7,13 @@
|
|||||||
</NcContent>
|
</NcContent>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import 'toastify-js/src/toastify.css'
|
import '@nextcloud/dialogs/style.css'
|
||||||
import { mapActions, mapState } from 'pinia'
|
|
||||||
import Bar from './components/Player/Bar.vue'
|
import Bar from './components/Player/Bar.vue'
|
||||||
import GPodder from './views/GPodder.vue'
|
import GPodder from './views/GPodder.vue'
|
||||||
import { NcContent } from '@nextcloud/vue'
|
import { NcContent } from '@nextcloud/vue'
|
||||||
import Subscriptions from './components/Sidebar/Subscriptions.vue'
|
import Subscriptions from './components/Sidebar/Subscriptions.vue'
|
||||||
import { loadState } from '@nextcloud/initial-state'
|
import { loadState } from '@nextcloud/initial-state'
|
||||||
import { usePlayer } from './store/player.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'App',
|
name: 'App',
|
||||||
@ -26,16 +24,9 @@ export default {
|
|||||||
Subscriptions,
|
Subscriptions,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(usePlayer, ['paused']),
|
|
||||||
gpodder() {
|
gpodder() {
|
||||||
return loadState('repod', 'gpodder', false)
|
return loadState('repod', 'gpodder', false)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
|
||||||
this.init()
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
...mapActions(usePlayer, ['init']),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<NcAppContent :class="{ episode }">
|
<NcAppContent :class="episode ? 'padding' : ''">
|
||||||
<slot />
|
<slot />
|
||||||
</NcAppContent>
|
</NcAppContent>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import { NcAppContent } from '@nextcloud/vue'
|
import { NcAppContent } from '@nextcloud/vue'
|
||||||
import { mapState } from 'pinia'
|
|
||||||
import { usePlayer } from '../../store/player.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AppContent',
|
name: 'AppContent',
|
||||||
@ -15,13 +13,15 @@ export default {
|
|||||||
NcAppContent,
|
NcAppContent,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(usePlayer, ['episode']),
|
episode() {
|
||||||
|
return this.$store.state.player.episode
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.episode {
|
.padding {
|
||||||
padding-bottom: 6rem;
|
padding-bottom: 6rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<NcAppNavigation :class="{ episode }">
|
<NcAppNavigation :class="episode ? 'padding' : ''">
|
||||||
<slot />
|
<slot />
|
||||||
<template #list>
|
<template #list>
|
||||||
<slot name="list" />
|
<slot name="list" />
|
||||||
@ -10,10 +10,8 @@
|
|||||||
</NcAppNavigation>
|
</NcAppNavigation>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import { NcAppNavigation } from '@nextcloud/vue'
|
import { NcAppNavigation } from '@nextcloud/vue'
|
||||||
import { mapState } from 'pinia'
|
|
||||||
import { usePlayer } from '../../store/player.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AppNavigation',
|
name: 'AppNavigation',
|
||||||
@ -21,13 +19,15 @@ export default {
|
|||||||
NcAppNavigation,
|
NcAppNavigation,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(usePlayer, ['episode']),
|
episode() {
|
||||||
|
return this.$store.state.player.episode
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.episode {
|
.padding {
|
||||||
padding-bottom: 6rem;
|
padding-bottom: 6rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NcEmptyContent class="empty">
|
|
||||||
<slot />
|
|
||||||
<template #icon>
|
|
||||||
<slot name="icon" />
|
|
||||||
</template>
|
|
||||||
<template #name>
|
|
||||||
<slot name="name" />
|
|
||||||
</template>
|
|
||||||
<template #description>
|
|
||||||
<slot name="description" />
|
|
||||||
</template>
|
|
||||||
<template #action>
|
|
||||||
<slot name="action" />
|
|
||||||
</template>
|
|
||||||
</NcEmptyContent>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { NcEmptyContent } from '@nextcloud/vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'EmptyContent',
|
|
||||||
components: {
|
|
||||||
NcEmptyContent,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.empty {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -2,7 +2,7 @@
|
|||||||
<NcLoadingIcon class="loading" />
|
<NcLoadingIcon class="loading" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import { NcLoadingIcon } from '@nextcloud/vue'
|
import { NcLoadingIcon } from '@nextcloud/vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -14,7 +14,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.loading {
|
.loading {
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,73 +1,95 @@
|
|||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<template>
|
<template>
|
||||||
<div class="flex">
|
<div>
|
||||||
<NcAvatar
|
<NcAvatar :display-name="name"
|
||||||
:display-name="episode.name"
|
|
||||||
:is-no-user="true"
|
:is-no-user="true"
|
||||||
:size="256"
|
:size="256"
|
||||||
:url="episode.image" />
|
:url="image" />
|
||||||
<h2>{{ episode.name }}</h2>
|
<h2>{{ name }}</h2>
|
||||||
<SafeHtml :source="episode.description || ''" />
|
<p v-html="strippedDescription" />
|
||||||
<div class="flex">
|
<div>
|
||||||
<NcButton v-if="episode.link" :href="episode.link" target="_blank">
|
<NcButton v-if="link"
|
||||||
<template #icon>
|
:href="link"
|
||||||
<OpenInNewIcon :size="20" />
|
|
||||||
</template>
|
|
||||||
{{ episode.title }}
|
|
||||||
</NcButton>
|
|
||||||
<NcButton
|
|
||||||
v-if="episode.url"
|
|
||||||
:download="filenameFromUrl(episode.url)"
|
|
||||||
:href="episode.url"
|
|
||||||
target="_blank">
|
target="_blank">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<DownloadIcon :size="20" />
|
<OpenInNew :size="20" />
|
||||||
</template>
|
</template>
|
||||||
{{ t('repod', 'Download') }}
|
{{ title }}
|
||||||
{{ episode.size ? `(${humanFileSize(episode.size)})` : '' }}
|
</NcButton>
|
||||||
|
<NcButton v-if="url"
|
||||||
|
:href="url"
|
||||||
|
target="_blank">
|
||||||
|
<template #icon>
|
||||||
|
<Download :size="20" />
|
||||||
|
</template>
|
||||||
|
{{ t('repod', 'Download') }} {{ size ? `(${episodeFileSize})` : '' }}
|
||||||
</NcButton>
|
</NcButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import { NcAvatar, NcButton } from '@nextcloud/vue'
|
import { NcAvatar, NcButton } from '@nextcloud/vue'
|
||||||
import DownloadIcon from 'vue-material-design-icons/Download.vue'
|
import Download from 'vue-material-design-icons/Download.vue'
|
||||||
import type { EpisodeInterface } from '../../utils/types.ts'
|
import OpenInNew from 'vue-material-design-icons/OpenInNew.vue'
|
||||||
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'
|
import { cleanHtml } from '../../utils/text.js'
|
||||||
import SafeHtml from './SafeHtml.vue'
|
import { humanFileSize } from '../../utils/size.js'
|
||||||
import { filenameFromUrl } from '../../utils/url.ts'
|
|
||||||
import { humanFileSize } from '../../utils/size.ts'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Modal',
|
name: 'Modal',
|
||||||
components: {
|
components: {
|
||||||
DownloadIcon,
|
Download,
|
||||||
NcAvatar,
|
NcAvatar,
|
||||||
NcButton,
|
NcButton,
|
||||||
OpenInNewIcon,
|
OpenInNew,
|
||||||
SafeHtml,
|
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
episode: {
|
description: {
|
||||||
type: Object as () => EpisodeInterface,
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
computed: {
|
||||||
filenameFromUrl,
|
episodeFileSize() {
|
||||||
humanFileSize,
|
return humanFileSize(this.size)
|
||||||
t,
|
},
|
||||||
|
strippedDescription() {
|
||||||
|
return cleanHtml(this.description)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.flex {
|
div {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin: 2rem;
|
margin: 2rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,78 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div ref="html" v-sanitize="source" class="html" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import dompurify from 'dompurify'
|
|
||||||
import linkifyHtml from 'linkify-html'
|
|
||||||
import { mapActions } from 'pinia'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import { timeToSeconds } from '../../utils/time.ts'
|
|
||||||
import { usePlayer } from '../../store/player.ts'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'SafeHtml',
|
|
||||||
directives: {
|
|
||||||
sanitize: {
|
|
||||||
mounted(el, binding) {
|
|
||||||
el.innerHTML = dompurify
|
|
||||||
.sanitize(
|
|
||||||
linkifyHtml(binding.value, {
|
|
||||||
nl2br: true,
|
|
||||||
target: '_blank',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
/(([0-9]?[0-9]):)?([0-5]?[0-9]):([0-5][0-9])/g,
|
|
||||||
(
|
|
||||||
match,
|
|
||||||
noop: string,
|
|
||||||
hours: string,
|
|
||||||
minutes: string,
|
|
||||||
seconds: string,
|
|
||||||
) =>
|
|
||||||
`<seekable time="${timeToSeconds(
|
|
||||||
parseInt(hours),
|
|
||||||
parseInt(minutes),
|
|
||||||
parseInt(seconds),
|
|
||||||
)}" title="${t('repod', 'Skip to {match}', { match })}">${
|
|
||||||
match
|
|
||||||
}</seekable>`,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
source: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
const seekables = (this.$refs.html as HTMLElement).querySelectorAll(
|
|
||||||
'seekable',
|
|
||||||
)
|
|
||||||
for (const seekable of seekables) {
|
|
||||||
seekable.addEventListener('click', (event) => {
|
|
||||||
this.seek(
|
|
||||||
parseInt(
|
|
||||||
(event.target as HTMLElement).getAttribute('time') || '',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
this.play()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
...mapActions(usePlayer, ['play', 'seek']),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.html a,
|
|
||||||
seekable {
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,37 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<NcAppNavigationList class="list">
|
<ul>
|
||||||
<NcAppNavigationNewItem
|
<NcAppNavigationNewItem :name="t('repod', 'Add a RSS link')" @new-item="addSubscription">
|
||||||
:name="t('repod', 'Add a RSS link')"
|
|
||||||
@new-item="(url) => $router.push(toFeedUrl(url))">
|
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<PlusIcon :size="20" />
|
<Plus :size="20" />
|
||||||
</template>
|
</template>
|
||||||
</NcAppNavigationNewItem>
|
</NcAppNavigationNewItem>
|
||||||
</NcAppNavigationList>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import { NcAppNavigationList, NcAppNavigationNewItem } from '@nextcloud/vue'
|
import { NcAppNavigationNewItem } from '@nextcloud/vue'
|
||||||
import PlusIcon from 'vue-material-design-icons/Plus.vue'
|
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||||
import { t } from '@nextcloud/l10n'
|
import { encodeUrl } from '../../utils/url.js'
|
||||||
import { toFeedUrl } from '../../utils/url.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AddRss',
|
name: 'AddRss',
|
||||||
components: {
|
components: {
|
||||||
NcAppNavigationList,
|
|
||||||
NcAppNavigationNewItem,
|
NcAppNavigationNewItem,
|
||||||
PlusIcon,
|
Plus,
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
t,
|
addSubscription(feedUrl) {
|
||||||
toFeedUrl,
|
this.$router.push(encodeUrl(feedUrl))
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.list {
|
ul {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -2,60 +2,40 @@
|
|||||||
<div>
|
<div>
|
||||||
<Loading v-if="loading" />
|
<Loading v-if="loading" />
|
||||||
<ul v-if="!loading">
|
<ul v-if="!loading">
|
||||||
<NcListItem
|
<NcListItem v-for="feed in feeds"
|
||||||
v-for="feed in feeds"
|
|
||||||
:key="feed.link"
|
:key="feed.link"
|
||||||
:details="formatLocaleDate(new Date(feed.fetchedAtUnix * 1000))"
|
:details="moment(feed.fetchedAtUnix*1000).fromNow()"
|
||||||
:name="feed.title"
|
:name="feed.title"
|
||||||
:to="toFeedUrl(feed.link)">
|
:to="toUrl(feed.link)">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<NcAvatar
|
<NcAvatar :display-name="feed.author"
|
||||||
:display-name="feed.author"
|
|
||||||
:is-no-user="true"
|
:is-no-user="true"
|
||||||
:url="feed.imageUrl" />
|
:url="feed.imageUrl" />
|
||||||
</template>
|
</template>
|
||||||
<template #subname>
|
<template #subname>
|
||||||
{{ feed.author }}
|
{{ feed.author }}
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
|
||||||
<NcActionButton
|
|
||||||
v-if="!getSubByUrl(feed.link)"
|
|
||||||
:aria-label="t('repod', 'Subscribe')"
|
|
||||||
:name="t('repod', 'Subscribe')"
|
|
||||||
:title="t('repod', 'Subscribe')"
|
|
||||||
@click="addSubscription(feed.link)">
|
|
||||||
<template #icon>
|
|
||||||
<PlusIcon :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcActionButton>
|
|
||||||
</template>
|
|
||||||
</NcListItem>
|
</NcListItem>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import { NcActionButton, NcAvatar, NcListItem } from '@nextcloud/vue'
|
import { NcAvatar, NcListItem } from '@nextcloud/vue'
|
||||||
import { mapActions, mapState } from 'pinia'
|
|
||||||
import Loading from '../Atoms/Loading.vue'
|
import Loading from '../Atoms/Loading.vue'
|
||||||
import PlusIcon from 'vue-material-design-icons/Plus.vue'
|
|
||||||
import type { PodcastDataInterface } from '../../utils/types.ts'
|
|
||||||
import axios from '@nextcloud/axios'
|
import axios from '@nextcloud/axios'
|
||||||
import { formatLocaleDate } from '../../utils/time.ts'
|
import { debounce } from '../../utils/debounce.js'
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from '@nextcloud/router'
|
||||||
import { showError } from '../../utils/toast.ts'
|
import moment from '@nextcloud/moment'
|
||||||
import { t } from '@nextcloud/l10n'
|
import { showError } from '@nextcloud/dialogs'
|
||||||
import { toFeedUrl } from '../../utils/url.ts'
|
import { toUrl } from '../../utils/url.js'
|
||||||
import { useSubscriptions } from '../../store/subscriptions.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Search',
|
name: 'Search',
|
||||||
components: {
|
components: {
|
||||||
Loading,
|
Loading,
|
||||||
NcActionButton,
|
|
||||||
NcAvatar,
|
NcAvatar,
|
||||||
NcListItem,
|
NcListItem,
|
||||||
PlusIcon,
|
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
@ -63,55 +43,27 @@ export default {
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: () => ({
|
data() {
|
||||||
feeds: [] as PodcastDataInterface[],
|
return {
|
||||||
loading: false,
|
feeds: [],
|
||||||
timeout: null as NodeJS.Timeout | null,
|
loading: false,
|
||||||
}),
|
}
|
||||||
computed: {
|
|
||||||
...mapState(useSubscriptions, ['getSubByUrl']),
|
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value() {
|
value() {
|
||||||
if (this.timeout) {
|
this.search()
|
||||||
clearTimeout(this.timeout)
|
|
||||||
}
|
|
||||||
this.timeout = setTimeout(this.search, 200)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(useSubscriptions, ['fetch']),
|
moment,
|
||||||
formatLocaleDate,
|
toUrl,
|
||||||
t,
|
search: debounce(async function value() {
|
||||||
toFeedUrl,
|
|
||||||
async addSubscription(url: string) {
|
|
||||||
try {
|
|
||||||
await axios.post(
|
|
||||||
generateUrl('/apps/gpoddersync/subscription_change/create'),
|
|
||||||
{
|
|
||||||
add: [url],
|
|
||||||
remove: [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
showError(t('repod', 'Error while adding the feed'))
|
|
||||||
}
|
|
||||||
this.fetch()
|
|
||||||
},
|
|
||||||
async search() {
|
|
||||||
try {
|
try {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
const currentSearch = this.value
|
const currentSearch = this.value
|
||||||
const feeds = await axios.get<PodcastDataInterface[]>(
|
const feeds = await axios.get(generateUrl('/apps/repod/search?value={value}', { value: currentSearch }))
|
||||||
generateUrl('/apps/repod/search?q={value}', {
|
|
||||||
value: currentSearch,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (currentSearch === this.value) {
|
if (currentSearch === this.value) {
|
||||||
this.feeds = [...feeds.data].sort(
|
this.feeds = [...feeds.data].sort((a, b) => b.fetchedAtUnix - a.fetchedAtUnix)
|
||||||
(a, b) => b.fetchedAtUnix - a.fetchedAtUnix,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
@ -121,7 +73,7 @@ export default {
|
|||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}, 200),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,92 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<h2>{{ title }}</h2>
|
|
||||||
<Loading v-if="loading" />
|
|
||||||
<ul v-if="!loading">
|
|
||||||
<li v-for="top in tops" :key="top.link">
|
|
||||||
<router-link :to="toFeedUrl(top.link)">
|
|
||||||
<img :src="top.imageUrl" :title="top.author" />
|
|
||||||
</router-link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import Loading from '../Atoms/Loading.vue'
|
|
||||||
import type { PodcastDataInterface } from '../../utils/types.ts'
|
|
||||||
import axios from '@nextcloud/axios'
|
|
||||||
import { generateUrl } from '@nextcloud/router'
|
|
||||||
import { showError } from '../../utils/toast.ts'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import { toFeedUrl } from '../../utils/url.ts'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Toplist',
|
|
||||||
components: {
|
|
||||||
Loading,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: () => ({
|
|
||||||
loading: true,
|
|
||||||
tops: [] as PodcastDataInterface[],
|
|
||||||
}),
|
|
||||||
computed: {
|
|
||||||
title() {
|
|
||||||
switch (this.type) {
|
|
||||||
case 'new':
|
|
||||||
return t('repod', 'New podcasts')
|
|
||||||
case 'hot':
|
|
||||||
return t('repod', 'Hot podcasts')
|
|
||||||
default:
|
|
||||||
return this.type
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async mounted() {
|
|
||||||
try {
|
|
||||||
this.loading = true
|
|
||||||
const tops = await axios.get<PodcastDataInterface[]>(
|
|
||||||
generateUrl('/apps/repod/toplist/{type}', { type: this.type }),
|
|
||||||
)
|
|
||||||
this.tops = tops.data
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
showError(t('repod', 'Could not fetch tops'))
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
toFeedUrl,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
h2 {
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
flex-basis: 10rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
display: flex;
|
|
||||||
gap: 2rem;
|
|
||||||
overflow: scroll hidden;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
89
src/components/Discover/Tops.vue
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<ul v-if="!loading">
|
||||||
|
<li v-for="top in tops" :key="top.link">
|
||||||
|
<router-link :to="toUrl(top.link)">
|
||||||
|
<img :src="top.imageUrl" :title="top.author">
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Loading from '../Atoms/Loading.vue'
|
||||||
|
import axios from '@nextcloud/axios'
|
||||||
|
import { generateUrl } from '@nextcloud/router'
|
||||||
|
import { showError } from '@nextcloud/dialogs'
|
||||||
|
import { toUrl } from '../../utils/url.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Tops',
|
||||||
|
components: {
|
||||||
|
Loading,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: true,
|
||||||
|
tops: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
title() {
|
||||||
|
switch (this.type) {
|
||||||
|
case 'new':
|
||||||
|
return t('repod', 'New podcasts')
|
||||||
|
case 'hot':
|
||||||
|
return t('repod', 'Hot podcasts')
|
||||||
|
default:
|
||||||
|
return this.type
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
try {
|
||||||
|
this.loading = true
|
||||||
|
const tops = await axios.get(generateUrl(`/apps/repod/tops/${this.type}`))
|
||||||
|
this.tops = tops.data
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
showError(t('repod', 'Could not fetch tops'))
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toUrl,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h2 {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex-basis: 10rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
overflow: scroll hidden;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,33 +1,29 @@
|
|||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<template>
|
<template>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<img class="background" :src="feed.imageUrl" />
|
<img class="background" :src="imageUrl">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div>
|
<NcAvatar class="avatar"
|
||||||
<NcAvatar
|
:display-name="author || title"
|
||||||
:display-name="feed.author || feed.title"
|
:is-no-user="true"
|
||||||
:is-no-user="true"
|
:size="128"
|
||||||
:size="128"
|
:url="imageUrl" />
|
||||||
:url="feed.imageUrl" />
|
|
||||||
<a class="feed" :href="url" @click.prevent="copyFeed">
|
|
||||||
<RssIcon :size="20" />
|
|
||||||
<i>{{ t('repod', 'Copy feed') }}</i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
<div class="infos">
|
<div class="infos">
|
||||||
<h2>{{ feed.title }}</h2>
|
<h2>{{ title }}</h2>
|
||||||
<a :href="feed.link" target="_blank">
|
<a :href="link" target="_blank">
|
||||||
<i>{{ feed.author }}</i>
|
<i>{{ author }}</i>
|
||||||
</a>
|
</a>
|
||||||
<br /><br />
|
<br><br>
|
||||||
<SafeHtml :source="feed.description || ''" />
|
<p>
|
||||||
|
<small v-html="strippedDescription" />
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NcAppNavigationNew
|
<NcAppNavigationNew v-if="!isSubscribed"
|
||||||
v-if="!getSubByUrl(url)"
|
|
||||||
:text="t('repod', 'Subscribe')"
|
:text="t('repod', 'Subscribe')"
|
||||||
@click="addSubscription">
|
@click="addSubscription">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<PlusIcon :size="20" />
|
<Plus :size="20" />
|
||||||
</template>
|
</template>
|
||||||
</NcAppNavigationNew>
|
</NcAppNavigationNew>
|
||||||
</div>
|
</div>
|
||||||
@ -35,112 +31,112 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import { NcAppNavigationNew, NcAvatar } from '@nextcloud/vue'
|
import { NcAppNavigationNew, NcAvatar } from '@nextcloud/vue'
|
||||||
import { mapActions, mapState } from 'pinia'
|
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||||
import { showError, showSuccess } from '../../utils/toast.ts'
|
|
||||||
import PlusIcon from 'vue-material-design-icons/Plus.vue'
|
|
||||||
import type { PodcastDataInterface } from '../../utils/types.ts'
|
|
||||||
import RssIcon from 'vue-material-design-icons/Rss.vue'
|
|
||||||
import SafeHtml from '../Atoms/SafeHtml.vue'
|
|
||||||
import axios from '@nextcloud/axios'
|
import axios from '@nextcloud/axios'
|
||||||
import { decodeUrl } from '../../utils/url.ts'
|
import { cleanHtml } from '../../utils/text.js'
|
||||||
|
import { decodeUrl } from '../../utils/url.js'
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from '@nextcloud/router'
|
||||||
import { t } from '@nextcloud/l10n'
|
import { showError } from '@nextcloud/dialogs'
|
||||||
import { useSubscriptions } from '../../store/subscriptions.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Banner',
|
name: 'Banner',
|
||||||
components: {
|
components: {
|
||||||
NcAvatar,
|
NcAvatar,
|
||||||
NcAppNavigationNew,
|
NcAppNavigationNew,
|
||||||
PlusIcon,
|
Plus,
|
||||||
RssIcon,
|
|
||||||
SafeHtml,
|
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
feed: {
|
author: {
|
||||||
type: Object as () => PodcastDataInterface,
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
imageUrl: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(useSubscriptions, ['getSubByUrl']),
|
|
||||||
url() {
|
url() {
|
||||||
return decodeUrl(this.$route.params.url as string)
|
return decodeUrl(this.$route.params.url)
|
||||||
|
},
|
||||||
|
isSubscribed() {
|
||||||
|
return this.$store.state.subscriptions.subscriptions.includes(this.url)
|
||||||
|
},
|
||||||
|
strippedDescription() {
|
||||||
|
return cleanHtml(this.description)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(useSubscriptions, ['fetch']),
|
|
||||||
t,
|
|
||||||
async addSubscription() {
|
async addSubscription() {
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
await axios.post(generateUrl('/apps/gpoddersync/subscription_change/create'), { add: [this.url], remove: [] })
|
||||||
generateUrl('/apps/gpoddersync/subscription_change/create'),
|
|
||||||
{
|
|
||||||
add: [this.url],
|
|
||||||
remove: [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
showError(t('repod', 'Error while adding the feed'))
|
showError(t('repod', 'Error while adding the feed'))
|
||||||
}
|
}
|
||||||
this.fetch()
|
|
||||||
},
|
this.$store.dispatch('subscriptions/fetch')
|
||||||
copyFeed() {
|
|
||||||
window.navigator.clipboard.writeText(this.url)
|
|
||||||
showSuccess(t('repod', 'Link copied to the clipboard'))
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.background {
|
.avatar {
|
||||||
filter: blur(1rem) brightness(50%);
|
height: 8rem;
|
||||||
height: auto;
|
width: 8rem;
|
||||||
left: 0;
|
}
|
||||||
opacity: 0.4;
|
|
||||||
position: absolute;
|
.background {
|
||||||
top: 0;
|
filter: blur(1rem) brightness(50%);
|
||||||
width: 100%;
|
height: auto;
|
||||||
z-index: -1;
|
left: 0;
|
||||||
}
|
opacity: .4;
|
||||||
|
position: absolute;
|
||||||
.content {
|
top: 0;
|
||||||
display: flex;
|
width: 100%;
|
||||||
gap: 1rem;
|
z-index: -1;
|
||||||
height: 10rem;
|
}
|
||||||
position: relative;
|
|
||||||
}
|
.content {
|
||||||
|
display: flex;
|
||||||
.feed {
|
gap: 1rem;
|
||||||
display: flex;
|
height: 10rem;
|
||||||
gap: 0.2rem;
|
position: relative;
|
||||||
margin: 0.5rem;
|
}
|
||||||
}
|
|
||||||
|
.header {
|
||||||
.header {
|
height: 14rem;
|
||||||
height: 14rem;
|
overflow: hidden;
|
||||||
overflow: hidden;
|
padding: 2rem;
|
||||||
padding: 2rem;
|
position: relative;
|
||||||
position: relative;
|
}
|
||||||
}
|
|
||||||
|
.infos {
|
||||||
.infos {
|
overflow: auto;
|
||||||
flex: 1;
|
}
|
||||||
overflow: auto;
|
|
||||||
}
|
.inner {
|
||||||
|
display: flex;
|
||||||
.inner {
|
}
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
@media only screen and (max-width: 768px) {
|
||||||
}
|
.inner {
|
||||||
|
flex-direction: column;
|
||||||
@media only screen and (max-width: 768px) {
|
}
|
||||||
.inner {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,222 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NcListItem
|
|
||||||
:active="isCurrentEpisode(episode)"
|
|
||||||
class="episode"
|
|
||||||
:details="
|
|
||||||
!oneLine && episode.pubDate
|
|
||||||
? formatLocaleDate(new Date(episode.pubDate?.date))
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
:force-display-actions="true"
|
|
||||||
:name="episode.name"
|
|
||||||
:one-line="oneLine"
|
|
||||||
:style="{ opacity: hasEnded(episode) ? 0.4 : 1 }"
|
|
||||||
:title="episode.description"
|
|
||||||
@click="modalEpisode = episode">
|
|
||||||
<template #actions>
|
|
||||||
<NcActionButton
|
|
||||||
v-if="!isCurrentEpisode(episode)"
|
|
||||||
:aria-label="t('repod', 'Play')"
|
|
||||||
:title="t('repod', 'Play')"
|
|
||||||
@click="load(episode, url)">
|
|
||||||
<template #icon>
|
|
||||||
<PlayIcon :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcActionButton>
|
|
||||||
<NcActionButton
|
|
||||||
v-if="isCurrentEpisode(episode)"
|
|
||||||
:aria-label="t('repod', 'Stop')"
|
|
||||||
:title="t('repod', 'Stop')"
|
|
||||||
@click="load(null)">
|
|
||||||
<template #icon>
|
|
||||||
<StopIcon :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcActionButton>
|
|
||||||
</template>
|
|
||||||
<template #extra>
|
|
||||||
<NcActions>
|
|
||||||
<NcActionButton
|
|
||||||
v-if="episode.duration"
|
|
||||||
:aria-label="t('repod', 'Read')"
|
|
||||||
:disabled="loading"
|
|
||||||
:model-value="hasEnded(episode)"
|
|
||||||
:name="t('repod', 'Read')"
|
|
||||||
:title="t('repod', 'Read')"
|
|
||||||
@click="markAs(episode, !hasEnded(episode))">
|
|
||||||
<template #icon>
|
|
||||||
<PlaylistPlayIcon v-if="!hasEnded(episode)" :size="20" />
|
|
||||||
<PlaylistRemoveIcon v-if="hasEnded(episode)" :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcActionButton>
|
|
||||||
<NcActionLink
|
|
||||||
v-if="episode.link"
|
|
||||||
:href="episode.link"
|
|
||||||
:name="t('repod', 'Open website')"
|
|
||||||
target="_blank"
|
|
||||||
:title="t('repod', 'Open website')">
|
|
||||||
<template #icon>
|
|
||||||
<OpenInNewIcon :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcActionLink>
|
|
||||||
<NcActionLink
|
|
||||||
v-if="episode.url"
|
|
||||||
:download="filenameFromUrl(episode.url)"
|
|
||||||
:href="episode.url"
|
|
||||||
:name="t('repod', 'Download')"
|
|
||||||
target="_blank"
|
|
||||||
:title="t('repod', 'Download')">
|
|
||||||
<template #icon>
|
|
||||||
<DownloadIcon :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcActionLink>
|
|
||||||
</NcActions>
|
|
||||||
<NcModal v-if="modalEpisode" @close="modalEpisode = null">
|
|
||||||
<Modal :episode="episode" />
|
|
||||||
</NcModal>
|
|
||||||
</template>
|
|
||||||
<template #icon>
|
|
||||||
<NcAvatar
|
|
||||||
:display-name="episode.name"
|
|
||||||
:is-no-user="true"
|
|
||||||
:url="episode.image" />
|
|
||||||
</template>
|
|
||||||
<template #indicator>
|
|
||||||
<NcProgressBar
|
|
||||||
v-if="episode.action && isListening(episode) && !oneLine"
|
|
||||||
class="progress"
|
|
||||||
:value="(episode.action.position * 100) / episode.action.total" />
|
|
||||||
</template>
|
|
||||||
<template #subname>
|
|
||||||
{{ episode.duration }}
|
|
||||||
</template>
|
|
||||||
</NcListItem>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
NcActionButton,
|
|
||||||
NcActionLink,
|
|
||||||
NcActions,
|
|
||||||
NcAvatar,
|
|
||||||
NcListItem,
|
|
||||||
NcModal,
|
|
||||||
NcProgressBar,
|
|
||||||
} from '@nextcloud/vue'
|
|
||||||
import {
|
|
||||||
durationToSeconds,
|
|
||||||
formatEpisodeTimestamp,
|
|
||||||
formatLocaleDate,
|
|
||||||
} from '../../utils/time.ts'
|
|
||||||
import { hasEnded, isListening } from '../../utils/status.ts'
|
|
||||||
import { mapActions, mapState } from 'pinia'
|
|
||||||
import DownloadIcon from 'vue-material-design-icons/Download.vue'
|
|
||||||
import type { EpisodeInterface } from '../../utils/types.ts'
|
|
||||||
import Modal from '../Atoms/Modal.vue'
|
|
||||||
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'
|
|
||||||
import PlayIcon from 'vue-material-design-icons/Play.vue'
|
|
||||||
import PlaylistPlayIcon from 'vue-material-design-icons/PlaylistPlay.vue'
|
|
||||||
import PlaylistRemoveIcon from 'vue-material-design-icons/PlaylistRemove.vue'
|
|
||||||
import StopIcon from 'vue-material-design-icons/Stop.vue'
|
|
||||||
import axios from '@nextcloud/axios'
|
|
||||||
import { filenameFromUrl } from '../../utils/url.ts'
|
|
||||||
import { generateUrl } from '@nextcloud/router'
|
|
||||||
import { showError } from '../../utils/toast.ts'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import { usePlayer } from '../../store/player.ts'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Episode',
|
|
||||||
components: {
|
|
||||||
DownloadIcon,
|
|
||||||
Modal,
|
|
||||||
NcActionButton,
|
|
||||||
NcActionLink,
|
|
||||||
NcActions,
|
|
||||||
NcAvatar,
|
|
||||||
NcListItem,
|
|
||||||
NcModal,
|
|
||||||
NcProgressBar,
|
|
||||||
OpenInNewIcon,
|
|
||||||
PlayIcon,
|
|
||||||
PlaylistPlayIcon,
|
|
||||||
PlaylistRemoveIcon,
|
|
||||||
StopIcon,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
episode: {
|
|
||||||
type: Object as () => EpisodeInterface,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
oneLine: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
url: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: () => ({
|
|
||||||
loading: false,
|
|
||||||
modalEpisode: null as EpisodeInterface | null,
|
|
||||||
}),
|
|
||||||
computed: {
|
|
||||||
...mapState(usePlayer, { playerEpisode: 'episode' }),
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
...mapActions(usePlayer, ['load']),
|
|
||||||
filenameFromUrl,
|
|
||||||
formatLocaleDate,
|
|
||||||
hasEnded,
|
|
||||||
isListening,
|
|
||||||
t,
|
|
||||||
isCurrentEpisode(episode: EpisodeInterface) {
|
|
||||||
return this.playerEpisode?.url === episode.url
|
|
||||||
},
|
|
||||||
async markAs(episode: EpisodeInterface, read: boolean) {
|
|
||||||
try {
|
|
||||||
this.loading = true
|
|
||||||
episode.action = {
|
|
||||||
podcast: this.url,
|
|
||||||
episode: episode.url,
|
|
||||||
guid: episode.guid,
|
|
||||||
action: 'play',
|
|
||||||
timestamp: formatEpisodeTimestamp(new Date()),
|
|
||||||
started: episode.action?.started || 0,
|
|
||||||
position: read ? durationToSeconds(episode.duration || '') : 0,
|
|
||||||
total: durationToSeconds(episode.duration || ''),
|
|
||||||
}
|
|
||||||
await axios.post(
|
|
||||||
generateUrl('/apps/gpoddersync/episode_action/create'),
|
|
||||||
[episode.action],
|
|
||||||
)
|
|
||||||
if (read && this.isCurrentEpisode(episode)) {
|
|
||||||
this.load(null)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
showError(t('repod', 'Could not change the status of the episode'))
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.progress {
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.episode .list-item-content__name {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.episode .list-item-content__subname {
|
|
||||||
flex-basis: auto;
|
|
||||||
flex-grow: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -2,82 +2,95 @@
|
|||||||
<div>
|
<div>
|
||||||
<Loading v-if="loading" />
|
<Loading v-if="loading" />
|
||||||
<ul v-if="!loading">
|
<ul v-if="!loading">
|
||||||
<Episode
|
<NcListItem v-for="episode in episodes"
|
||||||
v-for="episode in filteredEpisodes"
|
|
||||||
:key="episode.guid"
|
:key="episode.guid"
|
||||||
:episode="episode"
|
:active="isCurrentEpisode(episode)"
|
||||||
:url="url" />
|
:class="hasEnded(episode) ? 'ended': ''"
|
||||||
|
:details="moment(episode.pubDate.date).fromNow()"
|
||||||
|
:force-display-actions="true"
|
||||||
|
:name="episode.name"
|
||||||
|
:title="episode.description"
|
||||||
|
@click="modalEpisode = episode">
|
||||||
|
<template #icon>
|
||||||
|
<NcAvatar :display-name="episode.name"
|
||||||
|
:is-no-user="true"
|
||||||
|
:url="episode.image" />
|
||||||
|
</template>
|
||||||
|
<template #subname>
|
||||||
|
{{ episode.duration }}
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<NcActionButton v-if="!isCurrentEpisode(episode)" @click="load(episode)">
|
||||||
|
<template #icon>
|
||||||
|
<PlayButton :size="20" />
|
||||||
|
</template>
|
||||||
|
{{ t('repod', 'Play') }}
|
||||||
|
</NcActionButton>
|
||||||
|
<NcActionButton v-if="isCurrentEpisode(episode)" @click="load(null)">
|
||||||
|
<template #icon>
|
||||||
|
<StopButton :size="20" />
|
||||||
|
</template>
|
||||||
|
{{ t('repod', 'Stop') }}
|
||||||
|
</NcActionButton>
|
||||||
|
</template>
|
||||||
|
</NcListItem>
|
||||||
</ul>
|
</ul>
|
||||||
|
<NcModal v-if="modalEpisode" @close="modalEpisode = null">
|
||||||
|
<Modal :description="modalEpisode.description"
|
||||||
|
:image="modalEpisode.image"
|
||||||
|
:link="modalEpisode.link"
|
||||||
|
:name="modalEpisode.name"
|
||||||
|
:size="modalEpisode.size"
|
||||||
|
:title="modalEpisode.title"
|
||||||
|
:url="modalEpisode.url" />
|
||||||
|
</NcModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import { hasEnded, isListening } from '../../utils/status.ts'
|
import { NcActionButton, NcAvatar, NcListItem, NcModal } from '@nextcloud/vue'
|
||||||
import Episode from './Episode.vue'
|
|
||||||
import type { EpisodeInterface } from '../../utils/types.ts'
|
|
||||||
import Loading from '../Atoms/Loading.vue'
|
import Loading from '../Atoms/Loading.vue'
|
||||||
|
import Modal from '../Atoms/Modal.vue'
|
||||||
|
import PlayButton from 'vue-material-design-icons/Play.vue'
|
||||||
|
import StopButton from 'vue-material-design-icons/Stop.vue'
|
||||||
import axios from '@nextcloud/axios'
|
import axios from '@nextcloud/axios'
|
||||||
import { decodeUrl } from '../../utils/url.ts'
|
import { decodeUrl } from '../../utils/url.js'
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from '@nextcloud/router'
|
||||||
import { mapState } from 'pinia'
|
import moment from '@nextcloud/moment'
|
||||||
import { showError } from '../../utils/toast.ts'
|
import { showError } from '@nextcloud/dialogs'
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import { usePlayer } from '../../store/player.ts'
|
|
||||||
import { useSettings } from '../../store/settings.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Episodes',
|
name: 'Episodes',
|
||||||
components: {
|
components: {
|
||||||
Episode,
|
|
||||||
Loading,
|
Loading,
|
||||||
|
Modal,
|
||||||
|
NcActionButton,
|
||||||
|
NcAvatar,
|
||||||
|
NcListItem,
|
||||||
|
NcModal,
|
||||||
|
PlayButton,
|
||||||
|
StopButton,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
episodes: [],
|
||||||
|
loading: true,
|
||||||
|
modalEpisode: null,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data: () => ({
|
|
||||||
episodes: [] as EpisodeInterface[],
|
|
||||||
loading: true,
|
|
||||||
}),
|
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(usePlayer, ['episode']),
|
currentEpisode() {
|
||||||
...mapState(useSettings, ['filters']),
|
return this.$store.state.player.episode
|
||||||
filteredEpisodes() {
|
|
||||||
return this.episodes.filter((episode) => {
|
|
||||||
if (!this.filters.listened && this.hasEnded(episode)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (!this.filters.listening && this.isListening(episode)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (!this.filters.unlistened && !this.isListening(episode)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
url() {
|
url() {
|
||||||
return decodeUrl(this.$route.params.url as string)
|
return decodeUrl(this.$route.params.url)
|
||||||
},
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
episode() {
|
|
||||||
if (this.episode) {
|
|
||||||
this.episodes = this.episodes.map((e) =>
|
|
||||||
e.url === this.episode?.url ? this.episode : e,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
try {
|
try {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
const episodes = await axios.get<EpisodeInterface[]>(
|
const episodes = await axios.get(generateUrl('/apps/repod/episodes/list?url={url}', { url: this.url }))
|
||||||
generateUrl('/apps/repod/episodes/list?url={url}', {
|
this.episodes = [...episodes.data].sort((a, b) => new Date(b.pubDate.date) - new Date(a.pubDate.date))
|
||||||
url: this.url,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
this.episodes = [...episodes.data].sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.pubDate?.date || '').getTime() -
|
|
||||||
new Date(a.pubDate?.date || '').getTime(),
|
|
||||||
)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
showError(t('repod', 'Could not fetch episodes'))
|
showError(t('repod', 'Could not fetch episodes'))
|
||||||
@ -86,8 +99,25 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
hasEnded,
|
moment,
|
||||||
isListening,
|
hasEnded(episode) {
|
||||||
|
return episode.action
|
||||||
|
&& episode.action.position > 0
|
||||||
|
&& episode.action.total > 0
|
||||||
|
&& episode.action.position >= episode.action.total
|
||||||
|
},
|
||||||
|
isCurrentEpisode(episode) {
|
||||||
|
return this.currentEpisode && this.currentEpisode.url === episode.url
|
||||||
|
},
|
||||||
|
load(episode) {
|
||||||
|
this.$store.dispatch('player/load', episode)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ended {
|
||||||
|
opacity: .4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -1,106 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NcGuestContent class="guest">
|
|
||||||
<Loading v-if="!feed.data" />
|
|
||||||
<NcAvatar
|
|
||||||
v-if="feed.data"
|
|
||||||
class="avatar"
|
|
||||||
:display-name="feed.data.author || feed.data.title"
|
|
||||||
:is-no-user="true"
|
|
||||||
:size="222"
|
|
||||||
:title="feed.data.author"
|
|
||||||
:url="feed.data.imageUrl" />
|
|
||||||
<div v-if="feed.data" class="list">
|
|
||||||
<h2 class="title">{{ feed.data.title }}</h2>
|
|
||||||
<Loading v-if="loading" />
|
|
||||||
<ul v-if="!loading">
|
|
||||||
<Episode
|
|
||||||
v-for="episode in episodes"
|
|
||||||
:key="episode.guid"
|
|
||||||
:episode="episode"
|
|
||||||
:one-line="true"
|
|
||||||
:url="feed.metrics.url" />
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</NcGuestContent>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import type { EpisodeInterface, SubscriptionInterface } from '../../utils/types.ts'
|
|
||||||
import { NcAvatar, NcGuestContent } from '@nextcloud/vue'
|
|
||||||
import Episode from './Episode.vue'
|
|
||||||
import Loading from '../Atoms/Loading.vue'
|
|
||||||
import axios from '@nextcloud/axios'
|
|
||||||
import { generateUrl } from '@nextcloud/router'
|
|
||||||
import { hasEnded } from '../../utils/status.ts'
|
|
||||||
import { showError } from '../../utils/toast.ts'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Favorite',
|
|
||||||
components: {
|
|
||||||
Episode,
|
|
||||||
Loading,
|
|
||||||
NcAvatar,
|
|
||||||
NcGuestContent,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
feed: {
|
|
||||||
type: Object as () => SubscriptionInterface,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: () => ({
|
|
||||||
episodes: [] as EpisodeInterface[],
|
|
||||||
loading: true,
|
|
||||||
}),
|
|
||||||
async mounted() {
|
|
||||||
try {
|
|
||||||
this.loading = true
|
|
||||||
const episodes = await axios.get<EpisodeInterface[]>(
|
|
||||||
generateUrl('/apps/repod/episodes/list?url={url}', {
|
|
||||||
url: this.feed.metrics.url,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
this.episodes = [...episodes.data]
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.pubDate?.date || '').getTime() -
|
|
||||||
new Date(a.pubDate?.date || '').getTime(),
|
|
||||||
)
|
|
||||||
.filter((episode) => !this.hasEnded(episode))
|
|
||||||
.slice(0, 4)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
showError(t('repod', 'Could not fetch episodes'))
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
hasEnded,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.guest {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
margin: 20px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
|
||||||
.avatar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="episode" class="footer">
|
<div v-if="player.episode" class="footer">
|
||||||
<img class="background" :src="episode.image" />
|
<img class="background" :src="player.episode.image">
|
||||||
<Loading v-if="!loaded" />
|
<Loading v-if="!player.loaded" />
|
||||||
<ProgressBar v-if="loaded" />
|
<ProgressBar v-if="player.loaded" />
|
||||||
<div v-if="loaded" class="player">
|
<div v-if="player.loaded" class="player">
|
||||||
<img :src="episode.image" />
|
<img :src="player.episode.image">
|
||||||
<Infos class="infos" />
|
<Infos class="infos" />
|
||||||
<Controls class="controls" />
|
<Controls class="controls" />
|
||||||
<Timer class="timer" />
|
<Timer class="timer" />
|
||||||
@ -13,15 +13,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import Controls from './Controls.vue'
|
import Controls from './Controls.vue'
|
||||||
import Infos from './Infos.vue'
|
import Infos from './Infos.vue'
|
||||||
import Loading from '../Atoms/Loading.vue'
|
import Loading from '../Atoms/Loading.vue'
|
||||||
import ProgressBar from './ProgressBar.vue'
|
import ProgressBar from './ProgressBar.vue'
|
||||||
import Timer from './Timer.vue'
|
import Timer from './Timer.vue'
|
||||||
import Volume from './Volume.vue'
|
import Volume from './Volume.vue'
|
||||||
import { mapState } from 'pinia'
|
|
||||||
import { usePlayer } from '../../store/player.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Bar',
|
name: 'Bar',
|
||||||
@ -34,56 +32,57 @@ export default {
|
|||||||
Volume,
|
Volume,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(usePlayer, ['episode', 'loaded']),
|
player() {
|
||||||
|
return this.$store.state.player
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.background {
|
.background {
|
||||||
filter: blur(1rem) brightness(50%);
|
filter: blur(1rem) brightness(50%);
|
||||||
height: auto;
|
height: auto;
|
||||||
left: 0;
|
left: 0;
|
||||||
opacity: 0.4;
|
opacity: .4;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
background-color: var(--color-main-background);
|
background-color: var(--color-main-background);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 6rem;
|
height: 6rem;
|
||||||
right: 0;
|
right: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 2000;
|
z-index: 2000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player {
|
.player {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
height: 6rem;
|
height: calc(6rem - 6px);
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timer {
|
.timer {
|
||||||
width: 30%;
|
width: 30%;
|
||||||
}
|
|
||||||
|
|
||||||
.volume {
|
|
||||||
width: 10%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
|
||||||
.infos {
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timer,
|
|
||||||
.volume {
|
.volume {
|
||||||
display: none;
|
width: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 768px) {
|
||||||
|
.infos {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer, .volume {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,71 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<Rewind10Icon
|
<PauseButton v-if="!player.paused"
|
||||||
class="pointer rewind"
|
|
||||||
:size="20"
|
|
||||||
:title="t('repod', 'Rewind 10 seconds')"
|
|
||||||
@click="seek((currentTime ?? 0) - 10)" />
|
|
||||||
<PauseIcon
|
|
||||||
v-if="!paused"
|
|
||||||
class="pointer"
|
class="pointer"
|
||||||
:size="50"
|
:size="50"
|
||||||
:title="t('repod', 'Pause')"
|
@click="$store.dispatch('player/pause')" />
|
||||||
@click="pause" />
|
<PlayButton v-if="player.paused"
|
||||||
<PlayIcon
|
|
||||||
v-if="paused"
|
|
||||||
class="pointer"
|
class="pointer"
|
||||||
:size="50"
|
:size="50"
|
||||||
:title="t('repod', 'Play')"
|
@click="$store.dispatch('player/play')" />
|
||||||
@click="play" />
|
|
||||||
<FastForward30Icon
|
|
||||||
class="pointer forward"
|
|
||||||
:size="20"
|
|
||||||
:title="t('repod', 'Fast forward 30 seconds')"
|
|
||||||
@click="seek((currentTime ?? 0) + 30)" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import { mapActions, mapState } from 'pinia'
|
import PauseButton from 'vue-material-design-icons/Pause.vue'
|
||||||
import FastForward30Icon from 'vue-material-design-icons/FastForward30.vue'
|
import PlayButton from 'vue-material-design-icons/Play.vue'
|
||||||
import PauseIcon from 'vue-material-design-icons/Pause.vue'
|
|
||||||
import PlayIcon from 'vue-material-design-icons/Play.vue'
|
|
||||||
import Rewind10Icon from 'vue-material-design-icons/Rewind10.vue'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import { usePlayer } from '../../store/player.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Controls',
|
name: 'Controls',
|
||||||
components: {
|
components: {
|
||||||
FastForward30Icon,
|
PauseButton,
|
||||||
PauseIcon,
|
PlayButton,
|
||||||
PlayIcon,
|
|
||||||
Rewind10Icon,
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(usePlayer, ['currentTime', 'paused']),
|
player() {
|
||||||
},
|
return this.$store.state.player
|
||||||
methods: {
|
},
|
||||||
...mapActions(usePlayer, ['play', 'pause', 'seek']),
|
|
||||||
t,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
}
|
||||||
}
|
|
||||||
|
.pointer {
|
||||||
.pointer {
|
cursor: pointer;
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
|
||||||
.forward,
|
|
||||||
.rewind {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,23 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="episode && podcastUrl" class="root">
|
<div class="root">
|
||||||
<strong class="pointer" @click="modal = true">
|
<strong class="pointer" @click="modal = true">
|
||||||
{{ episode.name }}
|
{{ player.episode.name }}
|
||||||
</strong>
|
</strong>
|
||||||
<router-link :to="toFeedUrl(podcastUrl)">
|
<router-link :to="hash">
|
||||||
<i>{{ episode.title }}</i>
|
<i>{{ player.episode.title }}</i>
|
||||||
</router-link>
|
</router-link>
|
||||||
<NcModal v-if="modal" @close="modal = false">
|
<NcModal v-if="modal" @close="modal = false">
|
||||||
<Modal :episode="episode" />
|
<Modal :description="player.episode.description"
|
||||||
|
:image="player.episode.image"
|
||||||
|
:link="player.episode.link"
|
||||||
|
:name="player.episode.name"
|
||||||
|
:size="player.episode.size"
|
||||||
|
:title="player.episode.title"
|
||||||
|
:url="player.episode.url" />
|
||||||
</NcModal>
|
</NcModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import Modal from '../Atoms/Modal.vue'
|
import Modal from '../Atoms/Modal.vue'
|
||||||
import { NcModal } from '@nextcloud/vue'
|
import { NcModal } from '@nextcloud/vue'
|
||||||
import { mapState } from 'pinia'
|
import { toUrl } from '../../utils/url.js'
|
||||||
import { toFeedUrl } from '../../utils/url.ts'
|
|
||||||
import { usePlayer } from '../../store/player.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Infos',
|
name: 'Infos',
|
||||||
@ -25,27 +29,31 @@ export default {
|
|||||||
Modal,
|
Modal,
|
||||||
NcModal,
|
NcModal,
|
||||||
},
|
},
|
||||||
data: () => ({
|
data() {
|
||||||
modal: false,
|
return {
|
||||||
}),
|
modal: false,
|
||||||
computed: {
|
}
|
||||||
...mapState(usePlayer, ['episode', 'podcastUrl']),
|
|
||||||
},
|
},
|
||||||
methods: {
|
computed: {
|
||||||
toFeedUrl,
|
player() {
|
||||||
|
return this.$store.state.player
|
||||||
|
},
|
||||||
|
hash() {
|
||||||
|
return toUrl(this.player.podcastUrl)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pointer {
|
.pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40%;
|
width: 40%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,37 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<input
|
<div class="pointer" @click="(event) => $store.dispatch('player/seek', event.x * player.duration / event.target.offsetWidth)">
|
||||||
v-if="duration"
|
<NcProgressBar size="medium" :value="player.currentTime * 100 / player.duration" />
|
||||||
class="progress"
|
</div>
|
||||||
:max="duration"
|
|
||||||
min="0"
|
|
||||||
type="range"
|
|
||||||
:value="currentTime"
|
|
||||||
@change="
|
|
||||||
(event) => seek(parseInt((event.target as HTMLInputElement).value))
|
|
||||||
" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import { mapActions, mapState } from 'pinia'
|
import { NcProgressBar } from '@nextcloud/vue'
|
||||||
import { usePlayer } from '../../store/player.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ProgressBar',
|
name: 'ProgressBar',
|
||||||
computed: {
|
components: {
|
||||||
...mapState(usePlayer, ['duration', 'currentTime']),
|
NcProgressBar,
|
||||||
},
|
},
|
||||||
methods: {
|
computed: {
|
||||||
...mapActions(usePlayer, ['seek']),
|
player() {
|
||||||
|
return this.$store.state.player
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.progress {
|
.pointer {
|
||||||
height: 4px;
|
cursor: pointer;
|
||||||
min-height: 4px;
|
}
|
||||||
position: absolute;
|
|
||||||
top: -2px;
|
|
||||||
width: 99%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="currentTime && duration" class="root">
|
<div>
|
||||||
<span>{{ formatTimer(new Date(currentTime * 1000)) }}</span>
|
<span>{{ formatTimer(new Date(player.currentTime*1000)) }}</span>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span>{{ formatTimer(new Date(duration * 1000)) }}</span>
|
<span>{{ formatTimer(new Date(player.duration*1000)) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import { formatTimer } from '../../utils/time.ts'
|
import { formatTimer } from '../../utils/time.js'
|
||||||
import { mapState } from 'pinia'
|
|
||||||
import { usePlayer } from '../../store/player.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Timer',
|
name: 'Timer',
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(usePlayer, ['duration', 'currentTime']),
|
player() {
|
||||||
|
return this.$store.state.player
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatTimer,
|
formatTimer,
|
||||||
@ -23,10 +23,10 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.root {
|
div {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,90 +1,80 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<VolumeHighIcon
|
<VolumeHigh v-if="player.volume > 0.7"
|
||||||
v-if="volume > 0.7"
|
|
||||||
class="pointer"
|
class="pointer"
|
||||||
:size="30"
|
:size="30"
|
||||||
:title="t('repod', 'Mute')"
|
|
||||||
@click="mute" />
|
@click="mute" />
|
||||||
<VolumeLowIcon
|
<VolumeLow v-if="player.volume > 0 && player.volume <= 0.3"
|
||||||
v-if="volume > 0 && volume <= 0.3"
|
|
||||||
class="pointer"
|
class="pointer"
|
||||||
:size="30"
|
:size="30"
|
||||||
:title="t('repod', 'Mute')"
|
|
||||||
@click="mute" />
|
@click="mute" />
|
||||||
<VolumeMediumIcon
|
<VolumeMedium v-if="player.volume > 0.3 && player.volume <= 0.7"
|
||||||
v-if="volume > 0.3 && volume <= 0.7"
|
|
||||||
class="pointer"
|
class="pointer"
|
||||||
:size="30"
|
:size="30"
|
||||||
:title="t('repod', 'Mute')"
|
|
||||||
@click="mute" />
|
@click="mute" />
|
||||||
<VolumeMuteIcon
|
<VolumeMute v-if="player.volume === 0"
|
||||||
v-if="volume === 0"
|
|
||||||
class="pointer"
|
class="pointer"
|
||||||
:size="30"
|
:size="30"
|
||||||
:title="t('repod', 'Unmute')"
|
@click="unmute" />
|
||||||
@click="setVolume(volumeMuted)" />
|
<input max="1"
|
||||||
<input
|
|
||||||
max="1"
|
|
||||||
min="0"
|
min="0"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
type="range"
|
type="range"
|
||||||
:value="volume"
|
:value="player.volume"
|
||||||
@change="
|
@change="(event) => $store.dispatch('player/volume', event.target.value)">
|
||||||
(event) =>
|
|
||||||
setVolume(parseFloat((event.target as HTMLInputElement).value))
|
|
||||||
" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import { mapActions, mapState } from 'pinia'
|
import VolumeHigh from 'vue-material-design-icons/VolumeHigh.vue'
|
||||||
import VolumeHighIcon from 'vue-material-design-icons/VolumeHigh.vue'
|
import VolumeLow from 'vue-material-design-icons/VolumeLow.vue'
|
||||||
import VolumeLowIcon from 'vue-material-design-icons/VolumeLow.vue'
|
import VolumeMedium from 'vue-material-design-icons/VolumeMedium.vue'
|
||||||
import VolumeMediumIcon from 'vue-material-design-icons/VolumeMedium.vue'
|
import VolumeMute from 'vue-material-design-icons/VolumeMute.vue'
|
||||||
import VolumeMuteIcon from 'vue-material-design-icons/VolumeMute.vue'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import { usePlayer } from '../../store/player.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Volume',
|
name: 'Volume',
|
||||||
components: {
|
components: {
|
||||||
VolumeHighIcon,
|
VolumeHigh,
|
||||||
VolumeLowIcon,
|
VolumeLow,
|
||||||
VolumeMediumIcon,
|
VolumeMedium,
|
||||||
VolumeMuteIcon,
|
VolumeMute,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
volumeMuted: 0,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data: () => ({
|
|
||||||
volumeMuted: 0,
|
|
||||||
}),
|
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(usePlayer, ['volume']),
|
player() {
|
||||||
|
return this.$store.state.player
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(usePlayer, ['setVolume']),
|
|
||||||
mute() {
|
mute() {
|
||||||
this.volumeMuted = this.volume
|
this.volumeMuted = this.player.volume
|
||||||
this.setVolume(0)
|
this.$store.dispatch('player/volume', 0)
|
||||||
|
},
|
||||||
|
unmute() {
|
||||||
|
this.$store.dispatch('player/volume', this.volumeMuted)
|
||||||
},
|
},
|
||||||
t,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
div {
|
div {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
transform: rotate(270deg);
|
transform: rotate(270deg);
|
||||||
width: 4rem;
|
width: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pointer {
|
.pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NcAppNavigationItem
|
|
||||||
:href="generateUrl('/apps/repod/opml/export')"
|
|
||||||
:name="t('repod', 'Export subscriptions')">
|
|
||||||
<template #icon>
|
|
||||||
<ExportIcon :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcAppNavigationItem>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import ExportIcon from 'vue-material-design-icons/Export.vue'
|
|
||||||
import { NcAppNavigationItem } from '@nextcloud/vue'
|
|
||||||
import { generateUrl } from '@nextcloud/router'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Export',
|
|
||||||
components: {
|
|
||||||
ExportIcon,
|
|
||||||
NcAppNavigationItem,
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
generateUrl,
|
|
||||||
t,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,72 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NcAppNavigationItem
|
|
||||||
menu-placement="top"
|
|
||||||
:name="t('repod', 'Filtering episodes')">
|
|
||||||
<template #actions>
|
|
||||||
<NcActionCheckbox
|
|
||||||
:disabled="all"
|
|
||||||
:model-value="all"
|
|
||||||
@change="
|
|
||||||
setFilters({
|
|
||||||
listened: true,
|
|
||||||
listening: true,
|
|
||||||
unlistened: true,
|
|
||||||
})
|
|
||||||
">
|
|
||||||
{{ t('repod', 'Show all') }}
|
|
||||||
</NcActionCheckbox>
|
|
||||||
<NcActionCheckbox
|
|
||||||
:model-value="filters.listened"
|
|
||||||
@change="setFilters({ listened: !filters.listened })">
|
|
||||||
{{ t('repod', 'Listened') }}
|
|
||||||
</NcActionCheckbox>
|
|
||||||
<NcActionCheckbox
|
|
||||||
:model-value="filters.listening"
|
|
||||||
@change="setFilters({ listening: !filters.listening })">
|
|
||||||
{{ t('repod', 'Listening') }}
|
|
||||||
</NcActionCheckbox>
|
|
||||||
<NcActionCheckbox
|
|
||||||
:model-value="filters.unlistened"
|
|
||||||
@change="setFilters({ unlistened: !filters.unlistened })">
|
|
||||||
{{ t('repod', 'Unlistened') }}
|
|
||||||
</NcActionCheckbox>
|
|
||||||
</template>
|
|
||||||
<template #icon>
|
|
||||||
<FilterIcon v-if="all" :size="20" />
|
|
||||||
<FilterSettingsIcon v-if="!all" :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcAppNavigationItem>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { NcActionCheckbox, NcAppNavigationItem } from '@nextcloud/vue'
|
|
||||||
import { mapActions, mapState } from 'pinia'
|
|
||||||
import FilterIcon from 'vue-material-design-icons/Filter.vue'
|
|
||||||
import FilterSettingsIcon from 'vue-material-design-icons/FilterSettings.vue'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import { useSettings } from '../../store/settings.ts'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Filters',
|
|
||||||
components: {
|
|
||||||
FilterIcon,
|
|
||||||
FilterSettingsIcon,
|
|
||||||
NcAppNavigationItem,
|
|
||||||
NcActionCheckbox,
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapState(useSettings, ['filters']),
|
|
||||||
all() {
|
|
||||||
return (
|
|
||||||
this.filters.listened &&
|
|
||||||
this.filters.listening &&
|
|
||||||
this.filters.unlistened
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
...mapActions(useSettings, ['setFilters']),
|
|
||||||
t,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,78 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NcAppNavigationItem
|
|
||||||
:name="t('repod', 'Import subscriptions')"
|
|
||||||
@click="modal = true">
|
|
||||||
<template #extra>
|
|
||||||
<NcModal v-if="modal" @close="modal = false">
|
|
||||||
<div class="modal">
|
|
||||||
<h2>{{ t('repod', 'Import OPML file') }}</h2>
|
|
||||||
<form
|
|
||||||
v-if="!loading"
|
|
||||||
:action="generateUrl('/apps/repod/opml/import')"
|
|
||||||
enctype="multipart/form-data"
|
|
||||||
method="post"
|
|
||||||
@submit.prevent="importOpml">
|
|
||||||
<input
|
|
||||||
accept="application/xml,.opml"
|
|
||||||
name="import"
|
|
||||||
required
|
|
||||||
type="file" />
|
|
||||||
<input type="submit" />
|
|
||||||
</form>
|
|
||||||
<Loading v-if="loading" />
|
|
||||||
</div>
|
|
||||||
</NcModal>
|
|
||||||
</template>
|
|
||||||
<template #icon>
|
|
||||||
<ImportIcon :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcAppNavigationItem>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { NcAppNavigationItem, NcModal } from '@nextcloud/vue'
|
|
||||||
import ImportIcon from 'vue-material-design-icons/Import.vue'
|
|
||||||
import Loading from '../Atoms/Loading.vue'
|
|
||||||
import axios from '@nextcloud/axios'
|
|
||||||
import { generateUrl } from '@nextcloud/router'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Import',
|
|
||||||
components: {
|
|
||||||
ImportIcon,
|
|
||||||
Loading,
|
|
||||||
NcAppNavigationItem,
|
|
||||||
NcModal,
|
|
||||||
},
|
|
||||||
data: () => ({
|
|
||||||
loading: false,
|
|
||||||
modal: false,
|
|
||||||
}),
|
|
||||||
methods: {
|
|
||||||
generateUrl,
|
|
||||||
t,
|
|
||||||
async importOpml(event: Event) {
|
|
||||||
try {
|
|
||||||
const target = event.target as HTMLFormElement
|
|
||||||
const formData = new FormData(target)
|
|
||||||
this.loading = true
|
|
||||||
await axios.post(target.action, formData)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
location.reload()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 2rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,26 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NcAppNavigationItem
|
|
||||||
href="https://apps.nextcloud.com/apps/repod#comments"
|
|
||||||
:name="t('repod', 'Rate RePod ❤️')">
|
|
||||||
<template #icon>
|
|
||||||
<StarIcon :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcAppNavigationItem>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { NcAppNavigationItem } from '@nextcloud/vue'
|
|
||||||
import StarIcon from 'vue-material-design-icons/Star.vue'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Rate',
|
|
||||||
components: {
|
|
||||||
NcAppNavigationItem,
|
|
||||||
StarIcon,
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
t,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,33 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NcAppNavigationSettings>
|
|
||||||
<Filters />
|
|
||||||
<Sleep />
|
|
||||||
<Speed />
|
|
||||||
<Import />
|
|
||||||
<Export />
|
|
||||||
<Rate />
|
|
||||||
</NcAppNavigationSettings>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import Export from './Export.vue'
|
|
||||||
import Filters from './Filters.vue'
|
|
||||||
import Import from './Import.vue'
|
|
||||||
import { NcAppNavigationSettings } from '@nextcloud/vue'
|
|
||||||
import Rate from './Rate.vue'
|
|
||||||
import Sleep from './Sleep.vue'
|
|
||||||
import Speed from './Speed.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Settings',
|
|
||||||
components: {
|
|
||||||
Export,
|
|
||||||
Filters,
|
|
||||||
Import,
|
|
||||||
NcAppNavigationSettings,
|
|
||||||
Rate,
|
|
||||||
Sleep,
|
|
||||||
Speed,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,103 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NcAppNavigationItem menu-placement="top" :name="t('repod', 'Sleep timer')">
|
|
||||||
<template #actions>
|
|
||||||
<NcActionInput
|
|
||||||
v-if="!sleep"
|
|
||||||
v-model="input"
|
|
||||||
:label="t('repod', 'Minutes')"
|
|
||||||
:label-outside="false"
|
|
||||||
type="number"
|
|
||||||
@submit="setTimer">
|
|
||||||
<template #icon>
|
|
||||||
<ClockVue :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcActionInput>
|
|
||||||
</template>
|
|
||||||
<template #extra>
|
|
||||||
<div v-if="sleep" class="extra">
|
|
||||||
{{ label }}
|
|
||||||
<BellCancel class="pointer" :size="20" @click="stopTimer" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #icon>
|
|
||||||
<BellSleepIcon v-if="sleep" :size="20" />
|
|
||||||
<BellSleepOutlineIcon v-if="!sleep" :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcAppNavigationItem>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { NcActionInput, NcAppNavigationItem } from '@nextcloud/vue'
|
|
||||||
import { n, t } from '@nextcloud/l10n'
|
|
||||||
import BellCancel from 'vue-material-design-icons/BellCancel.vue'
|
|
||||||
import BellSleepIcon from 'vue-material-design-icons/BellSleep.vue'
|
|
||||||
import BellSleepOutlineIcon from 'vue-material-design-icons/BellSleepOutline.vue'
|
|
||||||
import ClockVue from 'vue-material-design-icons/Clock.vue'
|
|
||||||
import { mapActions } from 'pinia'
|
|
||||||
import { usePlayer } from '../../store/player.ts'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Sleep',
|
|
||||||
components: {
|
|
||||||
BellCancel,
|
|
||||||
BellSleepIcon,
|
|
||||||
BellSleepOutlineIcon,
|
|
||||||
ClockVue,
|
|
||||||
NcActionInput,
|
|
||||||
NcAppNavigationItem,
|
|
||||||
},
|
|
||||||
data: () => ({
|
|
||||||
input: 10,
|
|
||||||
sleep: null as NodeJS.Timeout | null,
|
|
||||||
timer: 0,
|
|
||||||
}),
|
|
||||||
computed: {
|
|
||||||
label() {
|
|
||||||
if (this.timer > 60) {
|
|
||||||
return this.n(
|
|
||||||
'repod',
|
|
||||||
'%n min',
|
|
||||||
'%n mins',
|
|
||||||
Math.round(this.timer / 60),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return this.n('repod', '%n sec', '%n secs', this.timer)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
...mapActions(usePlayer, ['stop']),
|
|
||||||
n,
|
|
||||||
t,
|
|
||||||
setTimer() {
|
|
||||||
this.timer = this.input * 60
|
|
||||||
this.sleep = setInterval(() => {
|
|
||||||
if (this.timer > 0) {
|
|
||||||
this.timer--
|
|
||||||
} else if (this.sleep) {
|
|
||||||
this.stopTimer()
|
|
||||||
this.stop()
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
},
|
|
||||||
stopTimer() {
|
|
||||||
if (this.sleep) {
|
|
||||||
clearTimeout(this.sleep)
|
|
||||||
this.sleep = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.extra {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,68 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NcAppNavigationItem :name="t('repod', 'Playback speed')">
|
|
||||||
<template #extra>
|
|
||||||
<div class="extra">
|
|
||||||
<MinusIcon class="pointer" :size="20" @click="changeRate(-0.1)" />
|
|
||||||
<NcCounterBubble class="counter">x{{ rate }}</NcCounterBubble>
|
|
||||||
<PlusIcon class="pointer" :size="20" @click="changeRate(0.1)" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #icon>
|
|
||||||
<SpeedometerSlowIcon v-if="rate < 1" :size="20" />
|
|
||||||
<SpeedometerMediumIcon v-if="rate === 1" :size="20" />
|
|
||||||
<SpeedometerIcon v-if="rate > 1" :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcAppNavigationItem>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { NcAppNavigationItem, NcCounterBubble } from '@nextcloud/vue'
|
|
||||||
import { mapActions, mapState } from 'pinia'
|
|
||||||
import MinusIcon from 'vue-material-design-icons/Minus.vue'
|
|
||||||
import PlusIcon from 'vue-material-design-icons/Plus.vue'
|
|
||||||
import SpeedometerIcon from 'vue-material-design-icons/Speedometer.vue'
|
|
||||||
import SpeedometerMediumIcon from 'vue-material-design-icons/SpeedometerMedium.vue'
|
|
||||||
import SpeedometerSlowIcon from 'vue-material-design-icons/SpeedometerSlow.vue'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import { usePlayer } from '../../store/player.ts'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Speed',
|
|
||||||
components: {
|
|
||||||
NcAppNavigationItem,
|
|
||||||
NcCounterBubble,
|
|
||||||
MinusIcon,
|
|
||||||
PlusIcon,
|
|
||||||
SpeedometerIcon,
|
|
||||||
SpeedometerMediumIcon,
|
|
||||||
SpeedometerSlowIcon,
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapState(usePlayer, ['rate']),
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
...mapActions(usePlayer, ['setRate']),
|
|
||||||
t,
|
|
||||||
changeRate(diff: number) {
|
|
||||||
const newRate = parseFloat((this.rate + diff).toPrecision(2))
|
|
||||||
this.setRate(newRate > 0 ? newRate : this.rate)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.counter {
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.extra {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
87
src/components/Sidebar/Item.vue
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<NcAppNavigationItem :loading="loading"
|
||||||
|
:name="feed ? feed.title : url"
|
||||||
|
:to="hash">
|
||||||
|
<template #icon>
|
||||||
|
<NcAvatar v-if="feed"
|
||||||
|
:display-name="feed.author || feed.title"
|
||||||
|
:is-no-user="true"
|
||||||
|
:url="feed.imageUrl" />
|
||||||
|
<Alert v-if="failed" />
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<NcActionButton @click="deleteSubscription">
|
||||||
|
<template #icon>
|
||||||
|
<Delete :size="20" />
|
||||||
|
</template>
|
||||||
|
{{ t('repod', 'Delete') }}
|
||||||
|
</NcActionButton>
|
||||||
|
</template>
|
||||||
|
</NcAppNavigationItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { NcActionButton, NcAppNavigationItem, NcAvatar } from '@nextcloud/vue'
|
||||||
|
import Alert from 'vue-material-design-icons/Alert.vue'
|
||||||
|
import Delete from 'vue-material-design-icons/Delete.vue'
|
||||||
|
import axios from '@nextcloud/axios'
|
||||||
|
import { generateUrl } from '@nextcloud/router'
|
||||||
|
import { showError } from '@nextcloud/dialogs'
|
||||||
|
import { toUrl } from '../../utils/url.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Item',
|
||||||
|
components: {
|
||||||
|
Alert,
|
||||||
|
Delete,
|
||||||
|
NcActionButton,
|
||||||
|
NcAppNavigationItem,
|
||||||
|
NcAvatar,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
failed: false,
|
||||||
|
loading: true,
|
||||||
|
feed: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hash() {
|
||||||
|
return toUrl(this.url)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
try {
|
||||||
|
const podcastData = await axios.get(generateUrl('/apps/gpoddersync/personal_settings/podcast_data?url={url}', { url: this.url }))
|
||||||
|
this.feed = podcastData.data.data
|
||||||
|
} catch (e) {
|
||||||
|
this.failed = true
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async deleteSubscription() {
|
||||||
|
if (confirm(t('repod', 'Are you sure you want to delete this subscription?'))) {
|
||||||
|
try {
|
||||||
|
this.loading = true
|
||||||
|
await axios.post(generateUrl('/apps/gpoddersync/subscription_change/create'), { add: [], remove: [this.url] })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
showError(t('repod', 'Error while removing the feed'))
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
this.$store.dispatch('subscriptions/fetch')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
68
src/components/Sidebar/Settings.vue
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<NcAppNavigationSettings>
|
||||||
|
<div class="setting">
|
||||||
|
<label>
|
||||||
|
<SpeedometerSlow v-if="player.rate < 1" :size="20" />
|
||||||
|
<SpeedometerMedium v-if="player.rate === 1" :size="20" />
|
||||||
|
<Speedometer v-if="player.rate > 1" :size="20" />
|
||||||
|
{{ t('repod', 'Playback speed') }}
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<Minus class="pointer" :size="20" @click="changeRate(-.5)" />
|
||||||
|
<NcCounterBubble>x{{ player.rate }}</NcCounterBubble>
|
||||||
|
<Plus class="pointer" :size="20" @click="changeRate(.5)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NcAppNavigationSettings>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { NcAppNavigationSettings, NcCounterBubble } from '@nextcloud/vue'
|
||||||
|
import Minus from 'vue-material-design-icons/Minus.vue'
|
||||||
|
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||||
|
import Speedometer from 'vue-material-design-icons/Speedometer.vue'
|
||||||
|
import SpeedometerMedium from 'vue-material-design-icons/SpeedometerMedium.vue'
|
||||||
|
import SpeedometerSlow from 'vue-material-design-icons/SpeedometerSlow.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Settings',
|
||||||
|
components: {
|
||||||
|
Minus,
|
||||||
|
NcAppNavigationSettings,
|
||||||
|
NcCounterBubble,
|
||||||
|
Plus,
|
||||||
|
Speedometer,
|
||||||
|
SpeedometerMedium,
|
||||||
|
SpeedometerSlow,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
player() {
|
||||||
|
return this.$store.state.player
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
changeRate(diff) {
|
||||||
|
if (this.player.rate + diff > 0) {
|
||||||
|
this.$store.dispatch('player/rate', this.player.rate + diff)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting > label,
|
||||||
|
.setting > div {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,149 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NcAppNavigationItem
|
|
||||||
:loading="loading"
|
|
||||||
:name="feed?.data?.title || url"
|
|
||||||
:to="toFeedUrl(url)">
|
|
||||||
<template #actions>
|
|
||||||
<NcActionButton
|
|
||||||
:aria-label="t('repod', 'Favorite')"
|
|
||||||
:model-value="feed?.isFavorite"
|
|
||||||
:name="t('repod', 'Favorite')"
|
|
||||||
:title="t('repod', 'Favorite')"
|
|
||||||
@update:modelValue="switchFavorite($event)">
|
|
||||||
<template #icon>
|
|
||||||
<StarPlusIcon v-if="!feed?.isFavorite" :size="20" />
|
|
||||||
<StarRemoveIcon v-if="feed?.isFavorite" :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcActionButton>
|
|
||||||
<NcActionButton
|
|
||||||
:aria-label="t(`core`, 'Delete')"
|
|
||||||
:name="t(`core`, 'Delete')"
|
|
||||||
:title="t(`core`, 'Delete')"
|
|
||||||
@click="deleteSubscription">
|
|
||||||
<template #icon>
|
|
||||||
<DeleteIcon :size="20" />
|
|
||||||
</template>
|
|
||||||
</NcActionButton>
|
|
||||||
</template>
|
|
||||||
<template #icon>
|
|
||||||
<NcAvatar
|
|
||||||
:display-name="feed?.data?.author || feed?.data?.title"
|
|
||||||
:is-no-user="true"
|
|
||||||
:title="feed?.data?.author"
|
|
||||||
:url="feed?.data?.imageUrl" />
|
|
||||||
<StarIcon v-if="feed?.isFavorite" class="star" :size="20" />
|
|
||||||
<AlertIcon v-if="failed" />
|
|
||||||
</template>
|
|
||||||
</NcAppNavigationItem>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { NcActionButton, NcAppNavigationItem, NcAvatar } from '@nextcloud/vue'
|
|
||||||
import { mapActions, mapState } from 'pinia'
|
|
||||||
import AlertIcon from 'vue-material-design-icons/Alert.vue'
|
|
||||||
import DeleteIcon from 'vue-material-design-icons/Delete.vue'
|
|
||||||
import type { PersonalSettingsPodcastDataInterface } from '../../utils/types.ts'
|
|
||||||
import StarIcon from 'vue-material-design-icons/Star.vue'
|
|
||||||
import StarPlusIcon from 'vue-material-design-icons/StarPlus.vue'
|
|
||||||
import StarRemoveIcon from 'vue-material-design-icons/StarRemove.vue'
|
|
||||||
import axios from '@nextcloud/axios'
|
|
||||||
import { generateUrl } from '@nextcloud/router'
|
|
||||||
import { showError } from '../../utils/toast.ts'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import { toFeedUrl } from '../../utils/url.ts'
|
|
||||||
import { useSubscriptions } from '../../store/subscriptions.ts'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Subscription',
|
|
||||||
components: {
|
|
||||||
AlertIcon,
|
|
||||||
DeleteIcon,
|
|
||||||
NcActionButton,
|
|
||||||
NcAppNavigationItem,
|
|
||||||
NcAvatar,
|
|
||||||
StarIcon,
|
|
||||||
StarPlusIcon,
|
|
||||||
StarRemoveIcon,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
url: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: () => ({
|
|
||||||
failed: false,
|
|
||||||
loading: true,
|
|
||||||
}),
|
|
||||||
computed: {
|
|
||||||
...mapState(useSubscriptions, ['getSubByUrl', 'subs']),
|
|
||||||
feed() {
|
|
||||||
return this.getSubByUrl(this.url)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async mounted() {
|
|
||||||
try {
|
|
||||||
const podcastData =
|
|
||||||
await axios.get<PersonalSettingsPodcastDataInterface>(
|
|
||||||
generateUrl(
|
|
||||||
'/apps/gpoddersync/personal_settings/podcast_data?url={url}',
|
|
||||||
{
|
|
||||||
url: this.url,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
this.addMetadatas(this.url, podcastData.data.data)
|
|
||||||
} catch (e) {
|
|
||||||
this.failed = true
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
...mapActions(useSubscriptions, ['fetch', 'addMetadatas', 'setFavorite']),
|
|
||||||
t,
|
|
||||||
toFeedUrl,
|
|
||||||
async deleteSubscription() {
|
|
||||||
if (
|
|
||||||
confirm(
|
|
||||||
t('repod', 'Are you sure you want to delete this subscription?'),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
this.loading = true
|
|
||||||
await axios.post(
|
|
||||||
generateUrl('/apps/gpoddersync/subscription_change/create'),
|
|
||||||
{ add: [], remove: [this.url] },
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
showError(t('repod', 'Error while removing the feed'))
|
|
||||||
} finally {
|
|
||||||
this.setFavorite(this.url, false)
|
|
||||||
this.loading = false
|
|
||||||
this.fetch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
switchFavorite(value: boolean) {
|
|
||||||
if (value) {
|
|
||||||
if (this.subs.filter((sub) => sub.isFavorite).length >= 10) {
|
|
||||||
showError(t('repod', 'You can only have 10 favorites'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.setFavorite(this.url, value)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.star {
|
|
||||||
bottom: 2px;
|
|
||||||
color: yellow;
|
|
||||||
left: 22px;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -2,24 +2,19 @@
|
|||||||
<AppNavigation>
|
<AppNavigation>
|
||||||
<template #list>
|
<template #list>
|
||||||
<NcAppContentList>
|
<NcAppContentList>
|
||||||
<router-link to="/discover">
|
<router-link to="/">
|
||||||
<NcAppNavigationNew :text="t('repod', 'Add a podcast')">
|
<NcAppNavigationNew :text="t('repod', 'Add a podcast')">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<PlusIcon :size="20" />
|
<Plus :size="20" />
|
||||||
</template>
|
</template>
|
||||||
</NcAppNavigationNew>
|
</NcAppNavigationNew>
|
||||||
</router-link>
|
</router-link>
|
||||||
<Loading v-if="loading" />
|
<Loading v-if="loading" />
|
||||||
<NcAppNavigationList v-if="!loading">
|
<ul v-if="!loading">
|
||||||
<Subscription
|
<Item v-for="subscriptionUrl of subscriptions"
|
||||||
v-for="sub of subs.filter((sub) => sub.isFavorite)"
|
:key="subscriptionUrl"
|
||||||
:key="sub.metrics.url"
|
:url="subscriptionUrl" />
|
||||||
:url="sub.metrics.url" />
|
</ul>
|
||||||
<Subscription
|
|
||||||
v-for="sub of subs.filter((sub) => !sub.isFavorite)"
|
|
||||||
:key="sub.metrics.url"
|
|
||||||
:url="sub.metrics.url" />
|
|
||||||
</NcAppNavigationList>
|
|
||||||
</NcAppContentList>
|
</NcAppContentList>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@ -28,43 +23,39 @@
|
|||||||
</AppNavigation>
|
</AppNavigation>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script>
|
||||||
import {
|
import { NcAppContentList, NcAppNavigationNew } from '@nextcloud/vue'
|
||||||
NcAppContentList,
|
|
||||||
NcAppNavigationList,
|
|
||||||
NcAppNavigationNew,
|
|
||||||
} from '@nextcloud/vue'
|
|
||||||
import { mapActions, mapState } from 'pinia'
|
|
||||||
import AppNavigation from '../Atoms/AppNavigation.vue'
|
import AppNavigation from '../Atoms/AppNavigation.vue'
|
||||||
|
import Item from './Item.vue'
|
||||||
import Loading from '../Atoms/Loading.vue'
|
import Loading from '../Atoms/Loading.vue'
|
||||||
import PlusIcon from 'vue-material-design-icons/Plus.vue'
|
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||||
import Settings from '../Settings/Settings.vue'
|
import Settings from './Settings.vue'
|
||||||
import Subscription from './Subscription.vue'
|
import { showError } from '@nextcloud/dialogs'
|
||||||
import { showError } from '../../utils/toast.ts'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import { useSubscriptions } from '../../store/subscriptions.ts'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Subscriptions',
|
name: 'Subscriptions',
|
||||||
components: {
|
components: {
|
||||||
AppNavigation,
|
AppNavigation,
|
||||||
|
Item,
|
||||||
Loading,
|
Loading,
|
||||||
NcAppContentList,
|
NcAppContentList,
|
||||||
NcAppNavigationList,
|
|
||||||
NcAppNavigationNew,
|
NcAppNavigationNew,
|
||||||
PlusIcon,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
Subscription,
|
|
||||||
},
|
},
|
||||||
data: () => ({
|
data() {
|
||||||
loading: true,
|
return {
|
||||||
}),
|
loading: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(useSubscriptions, ['subs']),
|
subscriptions() {
|
||||||
|
return this.$store.state.subscriptions.subscriptions
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
try {
|
try {
|
||||||
await this.fetch()
|
await this.$store.dispatch('subscriptions/fetch')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
showError(t('repod', 'Could not fetch subscriptions'))
|
showError(t('repod', 'Could not fetch subscriptions'))
|
||||||
@ -72,9 +63,5 @@ export default {
|
|||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
|
||||||
...mapActions(useSubscriptions, ['fetch']),
|
|
||||||
t,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
18
src/main.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { translatePlural as n, translate as t } from '@nextcloud/l10n'
|
||||||
|
import App from './App.vue'
|
||||||
|
import Vue from 'vue'
|
||||||
|
import { generateFilePath } from '@nextcloud/router'
|
||||||
|
import router from './router.js'
|
||||||
|
import store from './store/main.js'
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
__webpack_public_path__ = generateFilePath(appName, '', 'js/')
|
||||||
|
|
||||||
|
Vue.mixin({ methods: { t, n } })
|
||||||
|
|
||||||
|
export default new Vue({
|
||||||
|
el: '#content',
|
||||||
|
router,
|
||||||
|
store,
|
||||||
|
render: h => h(App),
|
||||||
|
})
|
12
src/main.ts
@ -1,12 +0,0 @@
|
|||||||
import '@formatjs/intl-segmenter/polyfill'
|
|
||||||
import App from './App.vue'
|
|
||||||
import { createApp } from 'vue'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import router from './router.ts'
|
|
||||||
|
|
||||||
const Vue = createApp(App)
|
|
||||||
const pinia = createPinia()
|
|
||||||
|
|
||||||
Vue.use(pinia)
|
|
||||||
Vue.use(router)
|
|
||||||
Vue.mount('#content')
|
|
23
src/router.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import Discover from './views/Discover.vue'
|
||||||
|
import Feed from './views/Feed.vue'
|
||||||
|
import Router from 'vue-router'
|
||||||
|
import Vue from 'vue'
|
||||||
|
import { generateUrl } from '@nextcloud/router'
|
||||||
|
|
||||||
|
Vue.use(Router)
|
||||||
|
|
||||||
|
const router = new Router({
|
||||||
|
base: generateUrl('apps/repod'),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: Discover,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:url',
|
||||||
|
component: Feed,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
@ -1,25 +0,0 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
|
||||||
import Discover from './views/Discover.vue'
|
|
||||||
import Feed from './views/Feed.vue'
|
|
||||||
import Home from './views/Home.vue'
|
|
||||||
import { generateUrl } from '@nextcloud/router'
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(generateUrl('apps/repod')),
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
component: Home,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/discover',
|
|
||||||
component: Discover,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/feed/:url',
|
|
||||||
component: Feed,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
15
src/store/main.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import Vuex, { Store } from 'vuex'
|
||||||
|
import Vue from 'vue'
|
||||||
|
import { player } from './player.js'
|
||||||
|
import { subscriptions } from './subscriptions.js'
|
||||||
|
|
||||||
|
Vue.use(Vuex)
|
||||||
|
|
||||||
|
const store = new Store({
|
||||||
|
modules: {
|
||||||
|
player,
|
||||||
|
subscriptions,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default store
|
129
src/store/player.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import axios from '@nextcloud/axios'
|
||||||
|
import { decodeUrl } from '../utils/url.js'
|
||||||
|
import { generateUrl } from '@nextcloud/router'
|
||||||
|
import moment from '@nextcloud/moment'
|
||||||
|
import router from '../router.js'
|
||||||
|
import store from './main.js'
|
||||||
|
|
||||||
|
const audio = new Audio()
|
||||||
|
audio.ondurationchange = () => store.commit('player/duration', audio.duration)
|
||||||
|
audio.onended = () => store.dispatch('player/stop')
|
||||||
|
audio.onloadeddata = () => store.commit('player/loaded', true)
|
||||||
|
audio.onplay = () => store.commit('player/paused', false)
|
||||||
|
audio.onpause = () => store.commit('player/paused', true)
|
||||||
|
audio.onratechange = () => store.commit('player/rate', audio.playbackRate)
|
||||||
|
audio.onseeked = () => store.commit('player/currentTime', audio.currentTime)
|
||||||
|
audio.ontimeupdate = () => store.commit('player/currentTime', audio.currentTime)
|
||||||
|
audio.onvolumechange = () => store.commit('player/volume', audio.volume)
|
||||||
|
|
||||||
|
export const player = {
|
||||||
|
namespaced: true,
|
||||||
|
state: {
|
||||||
|
currentTime: null,
|
||||||
|
duration: null,
|
||||||
|
episode: null,
|
||||||
|
loaded: false,
|
||||||
|
paused: null,
|
||||||
|
podcastUrl: null,
|
||||||
|
volume: 1,
|
||||||
|
rate: 1,
|
||||||
|
started: 0,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
action: (state, action) => {
|
||||||
|
state.episode.action = action
|
||||||
|
|
||||||
|
if (action && action.position && action.position < action.total) {
|
||||||
|
audio.currentTime = action.position
|
||||||
|
state.started = audio.currentTime
|
||||||
|
}
|
||||||
|
},
|
||||||
|
currentTime: (state, currentTime) => {
|
||||||
|
state.currentTime = currentTime
|
||||||
|
},
|
||||||
|
duration: (state, duration) => {
|
||||||
|
state.duration = duration
|
||||||
|
},
|
||||||
|
episode: (state, episode) => {
|
||||||
|
state.episode = episode
|
||||||
|
|
||||||
|
if (episode) {
|
||||||
|
state.podcastUrl = decodeUrl(router.currentRoute.params.url)
|
||||||
|
audio.src = episode.url
|
||||||
|
audio.load()
|
||||||
|
audio.play()
|
||||||
|
|
||||||
|
if (episode.action && episode.action.position && episode.action.position < episode.action.total) {
|
||||||
|
audio.currentTime = episode.action.position
|
||||||
|
state.started = audio.currentTime
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.loaded = false
|
||||||
|
state.podcastUrl = null
|
||||||
|
audio.src = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loaded: (state, loaded) => {
|
||||||
|
state.loaded = loaded
|
||||||
|
},
|
||||||
|
paused: (state, paused) => {
|
||||||
|
state.paused = paused
|
||||||
|
},
|
||||||
|
volume: (state, volume) => {
|
||||||
|
state.volume = volume
|
||||||
|
},
|
||||||
|
rate: (state, rate) => {
|
||||||
|
state.rate = rate
|
||||||
|
},
|
||||||
|
started: (state, started) => {
|
||||||
|
state.started = started
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
load: async (context, episode) => {
|
||||||
|
context.commit('episode', episode)
|
||||||
|
try {
|
||||||
|
const action = await axios.get(generateUrl('/apps/repod/episodes/action?url={url}', { url: episode.url }))
|
||||||
|
context.commit('action', action.data)
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
pause: (context) => {
|
||||||
|
audio.pause()
|
||||||
|
context.dispatch('time')
|
||||||
|
},
|
||||||
|
play: (context) => {
|
||||||
|
audio.play()
|
||||||
|
context.commit('started', audio.currentTime)
|
||||||
|
},
|
||||||
|
seek: (context, currentTime) => {
|
||||||
|
audio.currentTime = currentTime
|
||||||
|
context.dispatch('time')
|
||||||
|
},
|
||||||
|
stop: (context) => {
|
||||||
|
context.dispatch('pause')
|
||||||
|
context.commit('episode', null)
|
||||||
|
},
|
||||||
|
time: async (context) => axios.post(generateUrl('/apps/gpoddersync/episode_action/create'), [{
|
||||||
|
podcast: context.state.podcastUrl,
|
||||||
|
episode: context.state.episode.url,
|
||||||
|
guid: context.state.episode.guid,
|
||||||
|
action: 'play',
|
||||||
|
timestamp: moment().format('YYYY-MM-DD[T]HH:mm:ss'),
|
||||||
|
started: Math.round(context.state.started),
|
||||||
|
position: Math.round(audio.currentTime),
|
||||||
|
total: Math.round(audio.duration),
|
||||||
|
}]),
|
||||||
|
volume: (_, volume) => {
|
||||||
|
audio.volume = volume
|
||||||
|
},
|
||||||
|
rate: (_, rate) => {
|
||||||
|
audio.playbackRate = rate
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (player.state.paused === false) {
|
||||||
|
store.dispatch('player/time')
|
||||||
|
}
|
||||||
|
}, 40000)
|
@ -1,137 +0,0 @@
|
|||||||
import type { EpisodeActionInterface, EpisodeInterface } from '../utils/types.ts'
|
|
||||||
import { getCookie, setCookie } from '../utils/cookies.ts'
|
|
||||||
import axios from '@nextcloud/axios'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { formatEpisodeTimestamp } from '../utils/time.ts'
|
|
||||||
import { generateUrl } from '@nextcloud/router'
|
|
||||||
import { showError } from '../utils/toast.ts'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
|
|
||||||
const audio = new Audio()
|
|
||||||
|
|
||||||
export const usePlayer = defineStore('player', {
|
|
||||||
state: () => ({
|
|
||||||
currentTime: null as number | null,
|
|
||||||
duration: null as number | null,
|
|
||||||
episode: null as EpisodeInterface | null,
|
|
||||||
loaded: false,
|
|
||||||
paused: true,
|
|
||||||
playCount: 0,
|
|
||||||
podcastUrl: null as string | null,
|
|
||||||
volume: 1,
|
|
||||||
rate: 1,
|
|
||||||
started: 0,
|
|
||||||
}),
|
|
||||||
actions: {
|
|
||||||
init() {
|
|
||||||
audio.playbackRate = parseFloat(getCookie('repod.rate') || '1')
|
|
||||||
audio.volume = parseFloat(getCookie('repod.volume') || '1')
|
|
||||||
|
|
||||||
audio.ondurationchange = () => (this.duration = audio.duration)
|
|
||||||
audio.onended = () => this.stop()
|
|
||||||
audio.onloadeddata = () => (this.loaded = true)
|
|
||||||
audio.onpause = () => this.pause()
|
|
||||||
audio.onplay = () => this.play()
|
|
||||||
audio.onratechange = () => (this.rate = audio.playbackRate)
|
|
||||||
audio.onseeked = () => (this.currentTime = audio.currentTime)
|
|
||||||
audio.ontimeupdate = () => (this.currentTime = audio.currentTime)
|
|
||||||
audio.onvolumechange = () => (this.volume = audio.volume)
|
|
||||||
|
|
||||||
setInterval(this.act, 40000)
|
|
||||||
setInterval(this.conflict, 1000)
|
|
||||||
},
|
|
||||||
act() {
|
|
||||||
if (this.paused === false) {
|
|
||||||
this.time()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
conflict() {
|
|
||||||
this.playCount = 0
|
|
||||||
},
|
|
||||||
async load(episode: EpisodeInterface | null, podcastUrl?: string) {
|
|
||||||
this.episode = episode
|
|
||||||
this.podcastUrl = podcastUrl || null
|
|
||||||
|
|
||||||
if (this.episode?.url) {
|
|
||||||
audio.src = this.episode.url
|
|
||||||
audio.load()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const action = await axios.get<EpisodeActionInterface>(
|
|
||||||
generateUrl('/apps/repod/episodes/action?url={url}', {
|
|
||||||
url: this.episode.url,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
this.episode.action = action.data
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.episode.action &&
|
|
||||||
this.episode.action.position < this.episode.action.total
|
|
||||||
) {
|
|
||||||
audio.currentTime = this.episode.action.position
|
|
||||||
this.started = audio.currentTime
|
|
||||||
}
|
|
||||||
|
|
||||||
audio.play()
|
|
||||||
} else {
|
|
||||||
this.loaded = false
|
|
||||||
this.podcastUrl = null
|
|
||||||
audio.src = ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
pause() {
|
|
||||||
audio.pause()
|
|
||||||
this.paused = true
|
|
||||||
this.time()
|
|
||||||
},
|
|
||||||
play() {
|
|
||||||
this.playCount++
|
|
||||||
|
|
||||||
if (this.playCount > 10) {
|
|
||||||
showError(t('repod', 'A browser extension conflict with RePod'))
|
|
||||||
} else {
|
|
||||||
audio.play()
|
|
||||||
this.paused = false
|
|
||||||
this.started = audio.currentTime
|
|
||||||
}
|
|
||||||
},
|
|
||||||
seek(currentTime: number) {
|
|
||||||
audio.currentTime = currentTime
|
|
||||||
this.time()
|
|
||||||
},
|
|
||||||
stop() {
|
|
||||||
this.pause()
|
|
||||||
this.episode = null
|
|
||||||
},
|
|
||||||
time() {
|
|
||||||
if (!this.podcastUrl || !this.episode?.url) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.episode.action = {
|
|
||||||
podcast: this.podcastUrl,
|
|
||||||
episode: this.episode.url,
|
|
||||||
guid: this.episode.guid,
|
|
||||||
action: 'play',
|
|
||||||
timestamp: formatEpisodeTimestamp(new Date()),
|
|
||||||
started: Math.round(this.started),
|
|
||||||
position: Math.round(audio.currentTime),
|
|
||||||
total: Math.round(audio.duration),
|
|
||||||
}
|
|
||||||
|
|
||||||
axios.post(generateUrl('/apps/gpoddersync/episode_action/create'), [
|
|
||||||
this.episode.action,
|
|
||||||
])
|
|
||||||
},
|
|
||||||
setVolume(volume: number) {
|
|
||||||
audio.volume = volume
|
|
||||||
setCookie('repod.volume', volume.toString(), 365)
|
|
||||||
},
|
|
||||||
setRate(rate: number) {
|
|
||||||
audio.playbackRate = rate
|
|
||||||
setCookie('repod.rate', rate.toString(), 365)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
@ -1,37 +0,0 @@
|
|||||||
import { getCookie, setCookie } from '../utils/cookies.ts'
|
|
||||||
import type { FiltersInterface } from '../utils/types.ts'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
|
|
||||||
export const useSettings = defineStore('settings', {
|
|
||||||
state: () => {
|
|
||||||
try {
|
|
||||||
const filters = JSON.parse(getCookie('repod.filters') || '{}') || {}
|
|
||||||
|
|
||||||
if (!filters.length) {
|
|
||||||
throw new Error('Empty cookie')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
filters: {
|
|
||||||
listened: filters.listened,
|
|
||||||
listening: filters.listening,
|
|
||||||
unlistened: filters.unlistened,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
filters: {
|
|
||||||
listened: true,
|
|
||||||
listening: true,
|
|
||||||
unlistened: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
setFilters(filters: Partial<FiltersInterface>) {
|
|
||||||
this.filters = { ...this.filters, ...filters }
|
|
||||||
setCookie('repod.filters', JSON.stringify(this.filters), 365)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
21
src/store/subscriptions.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import axios from '@nextcloud/axios'
|
||||||
|
import { generateUrl } from '@nextcloud/router'
|
||||||
|
|
||||||
|
export const subscriptions = {
|
||||||
|
namespaced: true,
|
||||||
|
state: {
|
||||||
|
subscriptions: [],
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
set: (state, subscriptions) => {
|
||||||
|
state.subscriptions = subscriptions
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
fetch: async (context) => {
|
||||||
|
const metrics = await axios.get(generateUrl('/apps/gpoddersync/personal_settings/metrics'))
|
||||||
|
const subs = [...metrics.data.subscriptions].sort((a, b) => b.listenedSeconds - a.listenedSeconds)
|
||||||
|
context.commit('set', subs.map(sub => sub.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
@ -1,59 +0,0 @@
|
|||||||
import type {
|
|
||||||
PersonalSettingsMetricsInterface,
|
|
||||||
PodcastDataInterface,
|
|
||||||
SubscriptionInterface,
|
|
||||||
} from '../utils/types.ts'
|
|
||||||
import { getCookie, setCookie } from '../utils/cookies.ts'
|
|
||||||
import axios from '@nextcloud/axios'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { generateUrl } from '@nextcloud/router'
|
|
||||||
|
|
||||||
export const useSubscriptions = defineStore('subscriptions', {
|
|
||||||
state: () => ({
|
|
||||||
subs: [] as SubscriptionInterface[],
|
|
||||||
}),
|
|
||||||
getters: {
|
|
||||||
getSubByUrl: (state) => (url: string) =>
|
|
||||||
state.subs.find((sub) => sub.metrics.url === url),
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
async fetch() {
|
|
||||||
let favorites: string[] = []
|
|
||||||
try {
|
|
||||||
favorites = JSON.parse(getCookie('repod.favorites') || '[]') || []
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const metrics = await axios.get<PersonalSettingsMetricsInterface>(
|
|
||||||
generateUrl('/apps/gpoddersync/personal_settings/metrics'),
|
|
||||||
)
|
|
||||||
|
|
||||||
this.subs = [...metrics.data.subscriptions]
|
|
||||||
.sort((a, b) => b.listenedSeconds - a.listenedSeconds)
|
|
||||||
.map((sub) => ({
|
|
||||||
metrics: sub,
|
|
||||||
isFavorite: favorites.includes(sub.url),
|
|
||||||
data: this.getSubByUrl(sub.url)?.data,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
addMetadatas(link: string, data: PodcastDataInterface) {
|
|
||||||
this.subs = this.subs.map((sub) =>
|
|
||||||
sub.metrics.url === link ? { ...sub, data } : sub,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
setFavorite(link: string, isFavorite: boolean) {
|
|
||||||
this.subs = this.subs.map((sub) =>
|
|
||||||
sub.metrics.url === link ? { ...sub, isFavorite } : sub,
|
|
||||||
)
|
|
||||||
|
|
||||||
setCookie(
|
|
||||||
'repod.favorites',
|
|
||||||
JSON.stringify(
|
|
||||||
this.subs
|
|
||||||
.filter((sub) => sub.isFavorite)
|
|
||||||
.map((sub) => sub.metrics.url),
|
|
||||||
),
|
|
||||||
365,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
@ -1,26 +0,0 @@
|
|||||||
// https://grafikart.fr/tutoriels/javascript-cookies-2079
|
|
||||||
/**
|
|
||||||
* Récupère les données associées à un cookie
|
|
||||||
* @param {string} name Nom du cookie à récupérer
|
|
||||||
* @return {string|null}
|
|
||||||
*/
|
|
||||||
export const getCookie = (name: string): string | null => {
|
|
||||||
const cookies = document.cookie.split('; ')
|
|
||||||
const value = cookies.find((c) => c.startsWith(name + '='))?.split('=')[1]
|
|
||||||
if (value === undefined) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return decodeURIComponent(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Créer ou modifie la valeur d'un cookie avec une durée spécifique
|
|
||||||
* @param {string} name Nom du cookie
|
|
||||||
* @param {string} value Value du cookie
|
|
||||||
* @param {number} days Durée de vie du cookie (en jours)
|
|
||||||
*/
|
|
||||||
export const setCookie = (name: string, value: string, days: number) => {
|
|
||||||
const date = new Date()
|
|
||||||
date.setDate(date.getDate() + days)
|
|
||||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${date.toUTCString()}; SameSite=Strict;`
|
|
||||||
}
|
|
12
src/utils/debounce.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// https://stackoverflow.com/a/53486112
|
||||||
|
export const debounce = (fn, delay) => {
|
||||||
|
let timeoutID = null
|
||||||
|
return function() {
|
||||||
|
clearTimeout(timeoutID)
|
||||||
|
const args = arguments
|
||||||
|
const that = this
|
||||||
|
timeoutID = setTimeout(function() {
|
||||||
|
fn.apply(that, args)
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
5
src/utils/size.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
// https://stackoverflow.com/a/20732091
|
||||||
|
export const humanFileSize = (size) => {
|
||||||
|
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024))
|
||||||
|
return (size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]
|
||||||
|
}
|
@ -1,9 +0,0 @@
|
|||||||
// https://stackoverflow.com/a/20732091
|
|
||||||
export const humanFileSize = (size: number) => {
|
|
||||||
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024))
|
|
||||||
return (
|
|
||||||
(size / Math.pow(1024, i)).toFixed(2) +
|
|
||||||
' ' +
|
|
||||||
['B', 'kB', 'MB', 'GB', 'TB'][i]
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
import type { EpisodeInterface } from './types'
|
|
||||||
|
|
||||||
export const hasEnded = (episode: EpisodeInterface) =>
|
|
||||||
episode.action &&
|
|
||||||
episode.action.action &&
|
|
||||||
(episode.action.action.toLowerCase() === 'delete' ||
|
|
||||||
(episode.action.position > 0 &&
|
|
||||||
episode.action.total > 0 &&
|
|
||||||
episode.action.position >= episode.action.total))
|
|
||||||
|
|
||||||
export const isListening = (episode: EpisodeInterface) =>
|
|
||||||
episode.action &&
|
|
||||||
episode.action.action &&
|
|
||||||
episode.action.action.toLowerCase() === 'play' &&
|
|
||||||
episode.action.position > 0 &&
|
|
||||||
!hasEnded(episode)
|
|
7
src/utils/text.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// https://stackoverflow.com/a/5002618
|
||||||
|
export const cleanHtml = (text) => {
|
||||||
|
const pre = document.createElement('pre')
|
||||||
|
pre.innerHTML = text.replace(/<br\s*\/?>/mg, '\n')
|
||||||
|
const strippedText = pre.textContent || pre.innerText || ''
|
||||||
|
return strippedText.replace(/\n/mg, '<br>')
|
||||||
|
}
|
11
src/utils/time.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export const formatTimer = (date) => {
|
||||||
|
const minutes = date.getUTCMinutes().toString().padStart(2, 0)
|
||||||
|
const seconds = date.getUTCSeconds().toString().padStart(2, 0)
|
||||||
|
let timer = `${minutes}:${seconds}`
|
||||||
|
|
||||||
|
if (date.getUTCHours()) {
|
||||||
|
timer = `${date.getUTCHours()}:${timer}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return timer
|
||||||
|
}
|
@ -1,66 +0,0 @@
|
|||||||
/**
|
|
||||||
* Format a date to a timer
|
|
||||||
* @param {Date} date The date
|
|
||||||
* @return {string}
|
|
||||||
*/
|
|
||||||
export const formatTimer = (date: Date): string => {
|
|
||||||
const minutes = date.getUTCMinutes().toString().padStart(2, '0')
|
|
||||||
const seconds = date.getUTCSeconds().toString().padStart(2, '0')
|
|
||||||
let timer = `${minutes}:${seconds}`
|
|
||||||
|
|
||||||
if (date.getUTCHours()) {
|
|
||||||
timer = `${date.getUTCHours()}:${timer}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return timer
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a date to a usefull timestamp string for the gPodder API
|
|
||||||
* @param {Date} date The date
|
|
||||||
* @return {string}
|
|
||||||
*/
|
|
||||||
export const formatEpisodeTimestamp = (date: Date): string => {
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
|
||||||
const day = date.getDate().toString().padStart(2, '0')
|
|
||||||
const hours = date.getHours().toString().padStart(2, '0')
|
|
||||||
const mins = date.getMinutes().toString().padStart(2, '0')
|
|
||||||
const secs = date.getSeconds().toString().padStart(2, '0')
|
|
||||||
|
|
||||||
return `${year}-${month}-${day}T${hours}:${mins}:${secs}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a date to a localized date string
|
|
||||||
* @param {Date} date The date
|
|
||||||
* @return {string}
|
|
||||||
*/
|
|
||||||
export const formatLocaleDate = (date: Date): string =>
|
|
||||||
date.toLocaleDateString(undefined, { dateStyle: 'medium' })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the number of seconds from a duration feed's entry
|
|
||||||
* @param {string} duration The duration feed's entry
|
|
||||||
* @return {number}
|
|
||||||
*/
|
|
||||||
export const durationToSeconds = (duration: string): number => {
|
|
||||||
const splitDuration = duration.split(':').reverse()
|
|
||||||
let seconds = parseInt(splitDuration[0])
|
|
||||||
seconds += splitDuration.length > 1 ? parseInt(splitDuration[1]) * 60 : 0
|
|
||||||
seconds += splitDuration.length > 2 ? parseInt(splitDuration[2]) * 60 * 60 : 0
|
|
||||||
return seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert splitted time to seconds
|
|
||||||
* @param {number} hours The number of seconds
|
|
||||||
* @param {number} minutes The number of seconds
|
|
||||||
* @param {number} seconds The number of seconds
|
|
||||||
* @return {number}
|
|
||||||
*/
|
|
||||||
export const timeToSeconds = (
|
|
||||||
hours: number,
|
|
||||||
minutes: number,
|
|
||||||
seconds: number,
|
|
||||||
): number => hours * 3600 + minutes * 60 + seconds
|
|
@ -1,14 +0,0 @@
|
|||||||
import toastify from 'toastify-js'
|
|
||||||
|
|
||||||
export const showMessage = (text: string, backgroundColor: string) =>
|
|
||||||
toastify({
|
|
||||||
text,
|
|
||||||
backgroundColor,
|
|
||||||
}).showToast()
|
|
||||||
|
|
||||||
export const showError = (text: string) => showMessage(text, 'var(--color-error)')
|
|
||||||
export const showWarning = (text: string) =>
|
|
||||||
showMessage(text, 'var(--color-warning)')
|
|
||||||
export const showInfo = (text: string) => showMessage(text, 'var(--color-primary)')
|
|
||||||
export const showSuccess = (text: string) =>
|
|
||||||
showMessage(text, 'var(--color-success)')
|
|
@ -1,73 +0,0 @@
|
|||||||
export interface EpisodeActionInterface {
|
|
||||||
podcast: string
|
|
||||||
episode: string
|
|
||||||
action: string
|
|
||||||
timestamp: string
|
|
||||||
started: number
|
|
||||||
position: number
|
|
||||||
total: number
|
|
||||||
guid?: string
|
|
||||||
id?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EpisodeInterface {
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
name: string
|
|
||||||
link?: string
|
|
||||||
image?: string
|
|
||||||
description?: string
|
|
||||||
fetchedAtUnix: number
|
|
||||||
guid: string
|
|
||||||
type?: string
|
|
||||||
size?: number
|
|
||||||
pubDate?: {
|
|
||||||
date: string
|
|
||||||
timezone_type: number
|
|
||||||
timezone: string
|
|
||||||
}
|
|
||||||
duration?: string
|
|
||||||
action?: EpisodeActionInterface
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FiltersInterface {
|
|
||||||
listened: boolean
|
|
||||||
listening: boolean
|
|
||||||
unlistened: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PodcastDataInterface {
|
|
||||||
title: string
|
|
||||||
author?: string
|
|
||||||
link: string
|
|
||||||
description?: string
|
|
||||||
imageUrl?: string
|
|
||||||
fetchedAtUnix: number
|
|
||||||
imageBlob?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PodcastMetricsInterface {
|
|
||||||
url: string
|
|
||||||
listenedSeconds: number
|
|
||||||
actionCounts: {
|
|
||||||
delete: number
|
|
||||||
download: number
|
|
||||||
flattr: number
|
|
||||||
new: number
|
|
||||||
play: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubscriptionInterface {
|
|
||||||
data?: PodcastDataInterface
|
|
||||||
isFavorite?: boolean
|
|
||||||
metrics: PodcastMetricsInterface
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PersonalSettingsMetricsInterface {
|
|
||||||
subscriptions: PodcastMetricsInterface[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PersonalSettingsPodcastDataInterface {
|
|
||||||
data: PodcastDataInterface
|
|
||||||
}
|
|