Compare commits

..

No commits in common. "main" and "3.0.0" have entirely different histories.
main ... 3.0.0

107 changed files with 2466 additions and 4045 deletions

View File

@ -9,6 +9,5 @@
/node_modules/ /node_modules/
/js/ /js/
/css/
/build/ /build/

View File

@ -1,11 +1,9 @@
module.exports = { module.exports = {
extends: [ extends: [
'@nextcloud', '@nextcloud',
'@vue/eslint-config-typescript/recommended',
'plugin:pinia/recommended', 'plugin:pinia/recommended',
'plugin:prettier/recommended', 'plugin:prettier/recommended',
], ],
parser: 'vue-eslint-parser',
rules: { rules: {
'jsdoc/require-jsdoc': 'off', 'jsdoc/require-jsdoc': 'off',
'vue/first-attribute-linebreak': 'off', 'vue/first-attribute-linebreak': 'off',

View File

@ -14,7 +14,7 @@ jobs:
php: php:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: nextcloud:30 container: nextcloud:29
steps: steps:
- run: apt-get update - run: apt-get update
- run: apt-get install -y git nodejs - run: apt-get install -y git nodejs
@ -30,9 +30,15 @@ jobs:
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@v3
id: versions
with:
fallbackNode: '^20'
fallbackNpm: '^10'
- 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:29
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@v3
id: versions
with:
fallbackNode: '^20'
fallbackNpm: '^10'
- 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 }}>>>" }'

1
.gitignore vendored
View File

@ -9,6 +9,5 @@
/node_modules/ /node_modules/
/js/ /js/
/css/
/build/ /build/

View File

@ -9,6 +9,5 @@ tests/.phpunit.cache
node_modules/ node_modules/
js/ js/
css/
build/ build/

View File

@ -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;
} }
} }

View File

@ -1,90 +1,4 @@
## 3.4.1 - Skip & Chill - 2024-11-12 ## 3.0.0 - 2024-08-17
### 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 ### Added
- 🌐 Add german translation - 🌐 Add german translation
@ -102,7 +16,7 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Removed ### Removed
- 🗑️ Temporary replacing @nextcloud/dialogs to toastyjs - 🗑️ Temporary replacing @nextcloud/dialogs to toastyjs
## 2.3.3 - The Cake is a Lie - 2024-06-14 ## 2.3.3 - 2024-06-14
### Changed ### Changed
- ⬆️ Update @nextcloud/dialogs to 5.3.2 - ⬆️ Update @nextcloud/dialogs to 5.3.2
@ -111,18 +25,18 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
- 🐛 App crashed when no cache system available - 🐛 App crashed when no cache system available
[#107](https://git.crystalyx.net/Xefir/repod/issues/107) reported by @skvaller and @PhilTraere [#107](https://git.crystalyx.net/Xefir/repod/issues/107) reported by @skvaller and @PhilTraere
## 2.3.2 - Young Youth - 2024-05-31 ## 2.3.2 - 2024-05-31
### Fixed ### Fixed
- 🐛 New subscribe button on search not disapearing if subscribed - 🐛 New subscribe button on search not disapearing if subscribed
- ♿ Missing accessibility label on this button as well - ♿ Missing accessibility label on this button as well
## 2.3.1 - Powerwash the Universe - 2024-05-29 ## 2.3.1 - 2024-05-29
### Changed ### Changed
- ⚡ Reduce app size by not shipping sourcemap - ⚡ Reduce app size by not shipping sourcemap
## 2.3.0 - Star Align - 2024-05-29 ## 2.3.0 - 2024-05-29
### Added ### Added
- Ability to subscribe to podcast from search list - Ability to subscribe to podcast from search list
@ -139,12 +53,12 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
- ⚰️ Drop support for PHP 8.0 - ⚰️ Drop support for PHP 8.0
- 🌐 Removed babel - 🌐 Removed babel
## 2.2.1 - Shami was here - 2024-05-18 ## 2.2.1 - 2024-05-18
### Removed ### Removed
- ♻️ Rollback: Hide unreadable episodes because of insecure sources - ♻️ Rollback: Hide unreadable episodes because of insecure sources
## 2.2.0 - Moving in and out - 2024-05-18 ## 2.2.0 - 2024-05-18
### Added ### Added
- 🚨 Linting the code with ESLint - 🚨 Linting the code with ESLint
@ -156,7 +70,7 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Fixed ### Fixed
- 🔓 Hide unreadable episodes because of insecure sources - 🔓 Hide unreadable episodes because of insecure sources
## 2.1.0 - Pocket Gundams - 2024-03-16 ## 2.1.0 - 2024-03-16
### Added ### Added
- 🔍 Add CTA for rating the app on the store - 🔍 Add CTA for rating the app on the store
@ -170,7 +84,7 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
- 🔒 App wasn't working for non admin users - 🔒 App wasn't working for non admin users
[#76](https://git.crystalyx.net/Xefir/repod/issues/76) reported by @devasservice [#76](https://git.crystalyx.net/Xefir/repod/issues/76) reported by @devasservice
## 2.0.0 - Taking Actions - 2024-03-05 ## 2.0.0 - 2024-03-05
### Added ### Added
- 🍪 Saving filters preference - 🍪 Saving filters preference
@ -187,13 +101,13 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Fixed ### Fixed
- ❤️‍🔥 Better handling ended episodes - ❤️‍🔥 Better handling ended episodes
## 1.5.9 - Just According to Keikaku - 2024-02-21 ## 1.5.9 - 2024-02-21
### Changed ### Changed
- 🧑‍💻 Change some endpoints to match gPodder.net "specifications" - 🧑‍💻 Change some endpoints to match gPodder.net "specifications"
- ⬆️ Update @nextcloud/vue to 8.7.0 - ⬆️ Update @nextcloud/vue to 8.7.0
## 1.5.8 - Goblet of Eonothem - 2024-02-11 ## 1.5.8 - 2024-02-11
### Fixed ### Fixed
- Fyyd API sometime send empty feeds, ignoring them - Fyyd API sometime send empty feeds, ignoring them
@ -211,19 +125,19 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Changed ### Changed
- Update @nextcloud/vue to v8.6.2 - Update @nextcloud/vue to v8.6.2
## 1.5.5 - Hide and seek - 2024-02-04 ## 1.5.5 - 2024-02-04
### Fixed ### Fixed
- Can't open podcast details if cache missing or misconfigured - Can't open podcast details if cache missing or misconfigured
[#58](https://git.crystalyx.net/Xefir/repod/issues/58) reported by @raxventus [#58](https://git.crystalyx.net/Xefir/repod/issues/58) reported by @raxventus
## 1.5.4 - In search of the truth - 2024-02-03 ## 1.5.4 - 2024-02-03
### Fixed ### Fixed
- Nextcloud search engine didn't work on Nextcloud 26 and 27 - Nextcloud search engine didn't work on Nextcloud 26 and 27
[#57](https://git.crystalyx.net/Xefir/repod/issues/57) reported by @JonOfUs [#57](https://git.crystalyx.net/Xefir/repod/issues/57) reported by @JonOfUs
## 1.5.3 - The date where it all ends - 2024-02-01 ## 1.5.3 - 2024-02-01
### Changed ### Changed
- Update @nextcloud/vue to v8.6.1 - Update @nextcloud/vue to v8.6.1
@ -231,7 +145,7 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Fixed ### Fixed
- Fix episode listing crashing if an invalid publication date is present in the RSS - 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 ## 1.5.2 - 2024-02-01
### Changed ### Changed
- Update @nextcloud/router to v3.0.0 - Update @nextcloud/router to v3.0.0
@ -239,7 +153,7 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Fixed ### Fixed
- Fix player alignment off by a couple of pixels - Fix player alignment off by a couple of pixels
## 1.5.1 - Play on the PlayHead - 2024-01-30 ## 1.5.1 - 2024-01-30
### Changed ### Changed
- Update @nextcloud/vue to v8.6.0 - Update @nextcloud/vue to v8.6.0
@ -249,7 +163,7 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Fixed ### Fixed
- Force the placement of the filter settings to the top - Force the placement of the filter settings to the top
## 1.5.0 - Featuring the filtering - 2024-01-30 ## 1.5.0 - 2024-01-30
### Added ### Added
- Filtering options for each podcast section - Filtering options for each podcast section

View File

@ -1,4 +1,4 @@
FROM nextcloud:30 FROM nextcloud:29
ENV NEXTCLOUD_UPDATE=1 ENV NEXTCLOUD_UPDATE=1
ENV NEXTCLOUD_ADMIN_USER=repod ENV NEXTCLOUD_ADMIN_USER=repod

View File

@ -1,4 +1,39 @@
# https://github.com/nextcloud/appstore/blob/fixed-templates/nextcloudappstore/scaffolding/app-templates/26/app/Makefile # 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
@ -105,8 +140,8 @@ appstore:
--exclude="$(app_name)/protractor\.*" \ --exclude="$(app_name)/protractor\.*" \
--exclude="$(app_name)/.*" \ --exclude="$(app_name)/.*" \
--exclude="$(app_name)/js/.*" \ --exclude="$(app_name)/js/.*" \
--exclude="$(app_name)/tsconfig.json" \ --exclude="$(app_name)/webpack.js" \
--exclude="$(app_name)/stylelint.config.cjs" \ --exclude="$(app_name)/stylelint.config.js" \
--exclude="$(app_name)/README.md" \ --exclude="$(app_name)/README.md" \
--exclude="$(app_name)/package-lock.json" \ --exclude="$(app_name)/package-lock.json" \
--exclude="$(app_name)/LICENSE" \ --exclude="$(app_name)/LICENSE" \
@ -118,17 +153,17 @@ appstore:
--exclude="$(app_name)/Dockerfile" \ --exclude="$(app_name)/Dockerfile" \
--exclude="$(app_name)/psalm.xml" \ --exclude="$(app_name)/psalm.xml" \
--exclude="$(app_name)/renovate.json" \ --exclude="$(app_name)/renovate.json" \
--exclude="$(app_name)/vite.config.ts" \ --exclude="$(app_name)/vite.config.mjs" \
$(app_name) $(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

View File

@ -19,33 +19,26 @@ You need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) inst
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Actively maintened | ✅ | ✅ | ✅ | ❌ | | Actively maintened | ✅ | ✅ | ✅ | ❌ |
| Play your local music files | ❌ | ❌ | ✅ | ❌ | | 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) | | Sync with [GPodder clients](#clients-supporting-sync-of-gpoddersync) | ✅ | ✅ | ❌ | ❌ |
| Add and manage subscriptions | ✅ | ❌ | ✅ | ✅ | | Add and manage subscriptions | ✅ | ❌ | ✅ | ✅ |
| Listen synced episodes by another clients | ✅ | ✅ | ❌ | ❌ | | Listen synced episodes by another clients | ✅ | ✅ | ❌ | ❌ |
| Fetch and listen new epidodes | ✅ | [](https://github.com/pbek/nextcloud-nextpod/issues/5) | ✅ | ✅ | | Fetch and listen new epidodes | ✅ | | ✅ | ✅ |
| Keep track of listened episodes | ✅ | ✅ | [](https://github.com/owncloud/music/issues/1148) | ✅ | | Keep track of listened episodes | ✅ | ✅ | | ✅ |
| Download epidodes | ✅ | ✅ | ❌ | ✅ | | Download epidodes | ✅ | ✅ | ❌ | ✅ |
| Sleep timer | ✅ | ❌ | [](https://github.com/owncloud/music/issues/884#issuecomment-921582302) | ❌ | | Import and export subscriptions | ✅ | ❌ | ❌ | ❌ |
| 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 | ✅ | ❌ | ❌ | ✅ | | Search and discover new podcasts | ✅ | ❌ | ❌ | ✅ |
| Open episode website and RSS feed | ✅ | ✅ | ❌ | ✅ | | Open episode website and RSS feed | ✅ | ✅ | ❌ | ✅ |
| Integrate with Nextcloud search engine | ✅ | ❌ | ❌ | ✅ | | Integrate with Nextcloud search engine | ✅ | ❌ | ❌ | ✅ |
| Integrate with [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) | ❌ | ✅ | ❌ | ❌ | | Integrate with [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) | ❌ | ✅ | ❌ | ❌ |
| Mobile friendly interface | ✅ | ❌ | ✅ | ✅ | | Mobile friendly interface | ✅ | ❌ | ✅ | ✅ |
| Support chapters | | ❌ | ❌ | ✅ | | Support chapters | | ❌ | ❌ | ✅ |
| Available in multiple languages | ⭕ (en/fr/de) | ❌ | ✅ | ⭕ (en/de) | | 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,16 +55,11 @@ 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
- [GPodder Sync](https://github.com/thrillfall/nextcloud-gpodder) for the database API - [GPodder Sync](https://github.com/thrillfall/nextcloud-gpodder) for the database API

View File

@ -7,14 +7,11 @@
<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>3.0.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">Michel Roux</author>
<namespace>RePod</namespace> <namespace>RePod</namespace>
@ -23,21 +20,17 @@ You need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) inst
<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.1"/>
<nextcloud min-version="29" max-version="30"/> <nextcloud min-version="27" max-version="30"/>
</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>

25
appinfo/routes.php Normal file
View File

@ -0,0 +1,25 @@
<?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' => 'opml#export', 'url' => '/opml/export', 'verb' => 'GET'],
['name' => 'opml#import', 'url' => '/opml/import', 'verb' => 'POST'],
['name' => 'podcast#index', 'url' => '/podcast', 'verb' => 'GET'],
['name' => 'search#index', 'url' => '/search', 'verb' => 'GET'],
['name' => 'toplist#hot', 'url' => '/toplist/hot', 'verb' => 'GET'],
['name' => 'toplist#new', 'url' => '/toplist/new', 'verb' => 'GET'],
],
];

View File

@ -12,19 +12,13 @@
"lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", "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:check": "php-cs-fixer fix --dry-run --diff",
"cs:fix": "php-cs-fixer fix", "cs:fix": "php-cs-fixer fix",
"psalm": "psalm --threads=1 --no-cache --show-info=true", "psalm": "psalm --threads=1 --no-cache --show-info=true"
"rector": "rector && composer cs:fix"
},
"require": {
"php": "^8.1"
}, },
"require-dev": { "require-dev": {
"nextcloud/ocp": "^30.0.2", "nextcloud/ocp": "^29.0.4",
"roave/security-advisories": "dev-latest", "roave/security-advisories": "dev-latest",
"nextcloud/coding-standard": "^1.3.2", "nextcloud/coding-standard": "^1.2.1",
"nextcloud/rector": "^0.2.1", "vimeo/psalm": "^5.25.0"
"rector/rector": "^1.2.10",
"vimeo/psalm": "^5.26.1"
}, },
"config": { "config": {
"optimize-autoloader": true, "optimize-autoloader": true,

656
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,9 +5,8 @@ OC.L10N.register(
"Podcast" : "Podcast", "Podcast" : "Podcast",
"RePod" : "RePod", "RePod" : "RePod",
"🔊 Browse, manage and listen to podcasts" : "🔊 Suchen, Verwalten und Anhören von Podcasts", "🔊 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!", "## 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!" : "## 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\n## Voraussetzungen\nDu musst [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installiert haben, um diese App zu benutzen!",
"Download" : "Herunterladen", "Download" : "Herunterladen",
"Skip to {match}" : "Springen zu {match}",
"Add a RSS link" : "Einen RSS-Link hinzufügen", "Add a RSS link" : "Einen RSS-Link hinzufügen",
"Subscribe" : "Abonnieren", "Subscribe" : "Abonnieren",
"Error while adding the feed" : "Fehler beim Hinzufügen des Feeds", "Error while adding the feed" : "Fehler beim Hinzufügen des Feeds",
@ -19,15 +18,11 @@ OC.L10N.register(
"Link copied to the clipboard" : "Der Link des Feeds wurde in die Zwischenablage kopiert", "Link copied to the clipboard" : "Der Link des Feeds wurde in die Zwischenablage kopiert",
"Play" : "Abspielen", "Play" : "Abspielen",
"Stop" : "Stopp", "Stop" : "Stopp",
"Read" : "Gelesen", "Mark as read" : "Als gelesen markieren",
"Mark as unread" : "Als ungelesen markieren",
"Open website" : "Webseite aufrufen", "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", "Could not fetch episodes" : "Folgen können nicht abgerufen werden",
"Rewind 10 seconds" : "10 Sekunden zurückspulen", "Could not change the status of the episode" : "Kann den Status der Folge nicht ändern",
"Pause" : "Pause",
"Fast forward 30 seconds" : "30 Sekunden vorspulen",
"Mute" : "Stumm",
"Unmute" : "Stummschalten",
"Export subscriptions" : "Abonnements exportieren", "Export subscriptions" : "Abonnements exportieren",
"Filtering episodes" : "Folgen filtern", "Filtering episodes" : "Folgen filtern",
"Show all" : "Zeige alles", "Show all" : "Zeige alles",
@ -37,23 +32,14 @@ OC.L10N.register(
"Import subscriptions" : "Importiere Abonnements", "Import subscriptions" : "Importiere Abonnements",
"Import OPML file" : "Importiere OPML-Datei", "Import OPML file" : "Importiere OPML-Datei",
"Rate RePod ❤️" : "Bewerte RePod ❤️", "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", "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?", "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", "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", "Add a podcast" : "Einen Podcast hinzufügen",
"Could not fetch subscriptions" : "Abonnements können nicht abgerufen werden", "Could not fetch subscriptions" : "Abonnements können nicht abgerufen werden",
"Find a podcast" : "Finde einen Podcast", "Find a podcast" : "Finde einen Podcast",
"Error loading feed" : "Fehler beim Laden des Feeds", "Error loading feed" : "Fehler beim Laden des Feeds",
"Missing required app" : "Benötigte App fehlt", "Missing required app" : "Benötigte App fehlt",
"Install GPodder Sync" : "Installiere GPodder Sync", "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"
}, },
""); "");

View File

@ -3,9 +3,8 @@
"Podcast" : "Podcast", "Podcast" : "Podcast",
"RePod" : "RePod", "RePod" : "RePod",
"🔊 Browse, manage and listen to podcasts" : "🔊 Suchen, Verwalten und Anhören von Podcasts", "🔊 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!", "## 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!" : "## 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\n## Voraussetzungen\nDu musst [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installiert haben, um diese App zu benutzen!",
"Download" : "Herunterladen", "Download" : "Herunterladen",
"Skip to {match}" : "Springen zu {match}",
"Add a RSS link" : "Einen RSS-Link hinzufügen", "Add a RSS link" : "Einen RSS-Link hinzufügen",
"Subscribe" : "Abonnieren", "Subscribe" : "Abonnieren",
"Error while adding the feed" : "Fehler beim Hinzufügen des Feeds", "Error while adding the feed" : "Fehler beim Hinzufügen des Feeds",
@ -17,15 +16,11 @@
"Link copied to the clipboard" : "Der Link des Feeds wurde in die Zwischenablage kopiert", "Link copied to the clipboard" : "Der Link des Feeds wurde in die Zwischenablage kopiert",
"Play" : "Abspielen", "Play" : "Abspielen",
"Stop" : "Stopp", "Stop" : "Stopp",
"Read" : "Gelesen", "Mark as read" : "Als gelesen markieren",
"Mark as unread" : "Als ungelesen markieren",
"Open website" : "Webseite aufrufen", "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", "Could not fetch episodes" : "Folgen können nicht abgerufen werden",
"Rewind 10 seconds" : "10 Sekunden zurückspulen", "Could not change the status of the episode" : "Kann den Status der Folge nicht ändern",
"Pause" : "Pause",
"Fast forward 30 seconds" : "30 Sekunden vorspulen",
"Mute" : "Stumm",
"Unmute" : "Stummschalten",
"Export subscriptions" : "Abonnements exportieren", "Export subscriptions" : "Abonnements exportieren",
"Filtering episodes" : "Folgen filtern", "Filtering episodes" : "Folgen filtern",
"Show all" : "Zeige alles", "Show all" : "Zeige alles",
@ -35,23 +30,14 @@
"Import subscriptions" : "Importiere Abonnements", "Import subscriptions" : "Importiere Abonnements",
"Import OPML file" : "Importiere OPML-Datei", "Import OPML file" : "Importiere OPML-Datei",
"Rate RePod ❤️" : "Bewerte RePod ❤️", "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", "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?", "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", "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", "Add a podcast" : "Einen Podcast hinzufügen",
"Could not fetch subscriptions" : "Abonnements können nicht abgerufen werden", "Could not fetch subscriptions" : "Abonnements können nicht abgerufen werden",
"Find a podcast" : "Finde einen Podcast", "Find a podcast" : "Finde einen Podcast",
"Error loading feed" : "Fehler beim Laden des Feeds", "Error loading feed" : "Fehler beim Laden des Feeds",
"Missing required app" : "Benötigte App fehlt", "Missing required app" : "Benötigte App fehlt",
"Install GPodder Sync" : "Installiere GPodder Sync", "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" :"" },"pluralForm" :""
} }

View File

@ -5,9 +5,8 @@ OC.L10N.register(
"Podcast" : "Podcast", "Podcast" : "Podcast",
"RePod" : "RePod", "RePod" : "RePod",
"🔊 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", "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",
"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",
@ -19,15 +18,11 @@ OC.L10N.register(
"Link copied to the clipboard" : "Lien vers le flux copié dans le presse-papiers", "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", "Mark as read" : "Marquer comme lu",
"Mark as unread" : "Marquer comme non lu",
"Open website" : "Ouvrir le site web", "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", "Could not change the status of the episode" : "Impossible de changer le status de l'épisode",
"Pause" : "Pause",
"Fast forward 30 seconds" : "Avance rapide de 30 secondes",
"Mute" : "Silencer",
"Unmute" : "Paroler",
"Export subscriptions" : "Exporter les abonnements", "Export subscriptions" : "Exporter les abonnements",
"Filtering episodes" : "Filtrage des épisodes", "Filtering episodes" : "Filtrage des épisodes",
"Show all" : "Montrer tout", "Show all" : "Montrer tout",
@ -37,23 +32,14 @@ OC.L10N.register(
"Import subscriptions" : "Importer les abonnements", "Import subscriptions" : "Importer les abonnements",
"Import OPML file" : "Importer un fichier OPML", "Import OPML file" : "Importer un fichier OPML",
"Rate RePod ❤️" : "Donnez votre avis ❤️", "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", "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",
"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"
}, },
""); "");

View File

@ -3,9 +3,8 @@
"Podcast" : "Podcast", "Podcast" : "Podcast",
"RePod" : "RePod", "RePod" : "RePod",
"🔊 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", "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",
"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",
@ -17,15 +16,11 @@
"Link copied to the clipboard" : "Lien vers le flux copié dans le presse-papiers", "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", "Mark as read" : "Marquer comme lu",
"Mark as unread" : "Marquer comme non lu",
"Open website" : "Ouvrir le site web", "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", "Could not change the status of the episode" : "Impossible de changer le status de l'épisode",
"Pause" : "Pause",
"Fast forward 30 seconds" : "Avance rapide de 30 secondes",
"Mute" : "Silencer",
"Unmute" : "Paroler",
"Export subscriptions" : "Exporter les abonnements", "Export subscriptions" : "Exporter les abonnements",
"Filtering episodes" : "Filtrage des épisodes", "Filtering episodes" : "Filtrage des épisodes",
"Show all" : "Montrer tout", "Show all" : "Montrer tout",
@ -35,23 +30,14 @@
"Import subscriptions" : "Importer les abonnements", "Import subscriptions" : "Importer les abonnements",
"Import OPML file" : "Importer un fichier OPML", "Import OPML file" : "Importer un fichier OPML",
"Rate RePod ❤️" : "Donnez votre avis ❤️", "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", "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",
"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" :""
} }

View File

@ -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) {
} }
} }

View File

@ -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,32 @@ 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] * @NoAdminRequired
#[FrontpageRoute(verb: 'GET', url: '/episodes/list')] * @NoCSRFRequired
*/
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()); 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): string => $episode->getGuid(), $episodes)))); $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] * @NoAdminRequired
#[FrontpageRoute(verb: 'GET', url: '/episodes/action')] * @NoCSRFRequired
*/
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());

View File

@ -10,9 +10,6 @@ use OCA\GPodderSync\Core\SubscriptionChange\SubscriptionChangeSaver;
use OCA\RePod\AppInfo\Application; use OCA\RePod\AppInfo\Application;
use OCA\RePod\Service\UserService; use OCA\RePod\Service\UserService;
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\DataDownloadResponse; use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\Response;
use OCP\IL10N; use OCP\IL10N;
@ -22,18 +19,19 @@ class OpmlController extends Controller
{ {
public function __construct( public function __construct(
IRequest $request, IRequest $request,
private readonly IL10N $l10n, private IL10N $l10n,
private readonly PodcastDataReader $podcastDataReader, private PodcastDataReader $podcastDataReader,
private readonly PodcastMetricsReader $podcastMetricsReader, private PodcastMetricsReader $podcastMetricsReader,
private readonly SubscriptionChangeSaver $subscriptionChangeSaver, private SubscriptionChangeSaver $subscriptionChangeSaver,
private readonly UserService $userService private UserService $userService
) { ) {
parent::__construct(Application::APP_ID, $request); parent::__construct(Application::APP_ID, $request);
} }
#[NoAdminRequired] /**
#[NoCSRFRequired] * @NoAdminRequired
#[FrontpageRoute(verb: 'GET', url: '/opml/export')] * @NoCSRFRequired
*/
public function export(): DataDownloadResponse { public function export(): DataDownloadResponse {
// https://github.com/AntennaPod/AntennaPod/blob/master/core/src/main/java/de/danoeh/antennapod/core/export/opml/OpmlWriter.java // 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 = new \SimpleXMLElement('<opml/>', namespaceOrPrefix: 'http://xmlpull.org/v1/doc/features.html#indent-output');
@ -55,7 +53,7 @@ class OpmlController extends Controller
foreach ($subscriptions as $subscription) { foreach ($subscriptions as $subscription) {
try { try {
$podcast = $this->podcastDataReader->getCachedOrFetchPodcastData($subscription->getUrl(), $this->userService->getUserUID()); $podcast = $this->podcastDataReader->getCachedOrFetchPodcastData($subscription->getUrl(), $this->userService->getUserUID());
} catch (\Exception) { } catch (\Exception $e) {
continue; continue;
} }
@ -84,9 +82,10 @@ class OpmlController extends Controller
return new DataDownloadResponse((string) $xml->asXML(), 'repod-'.$dateCreated->getTimestamp().'.opml', ' application/xml'); return new DataDownloadResponse((string) $xml->asXML(), 'repod-'.$dateCreated->getTimestamp().'.opml', ' application/xml');
} }
#[NoAdminRequired] /**
#[NoCSRFRequired] * @NoAdminRequired
#[FrontpageRoute(verb: 'POST', url: '/opml/import')] * @NoCSRFRequired
*/
public function import(): Response { public function import(): Response {
$file = $this->request->getUploadedFile('import'); $file = $this->request->getUploadedFile('import');

View File

@ -6,28 +6,18 @@ 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\IRequest;
use OCP\Util; use OCP\Util;
class PageController extends Controller class PageController extends Controller
{ {
public function __construct( /**
IRequest $request * @NoAdminRequired
) { * @NoCSRFRequired
parent::__construct(Application::APP_ID, $request); */
}
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/')]
public function index(): TemplateResponse { public function index(): TemplateResponse {
Util::addScript(Application::APP_ID, Application::APP_ID.'-main'); Util::addScript(Application::APP_ID, 'main');
Util::addStyle(Application::APP_ID, Application::APP_ID.'-main');
$csp = new ContentSecurityPolicy(); $csp = new ContentSecurityPolicy();
$csp->addAllowedImageDomain('*'); $csp->addAllowedImageDomain('*');
@ -38,18 +28,4 @@ class PageController extends Controller
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();
}
} }

View File

@ -8,9 +8,6 @@ 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\ICacheFactory;
@ -20,23 +17,24 @@ class PodcastController extends Controller
{ {
public function __construct( public function __construct(
IRequest $request, IRequest $request,
private readonly ICacheFactory $cacheFactory, private ICacheFactory $cacheFactory,
private readonly IClientService $clientService, private IClientService $clientService,
private readonly PodcastDataReader $podcastDataReader private PodcastDataReader $podcastDataReader
) { ) {
parent::__construct(Application::APP_ID, $request); parent::__construct(Application::APP_ID, $request);
} }
#[NoAdminRequired] /**
#[NoCSRFRequired] * @NoAdminRequired
#[FrontpageRoute(verb: 'GET', url: '/podcast')] * @NoCSRFRequired
*/
public function index(string $url): JSONResponse { public function index(string $url): JSONResponse {
$podcast = null; $podcast = null;
if ($this->cacheFactory->isLocalCacheAvailable()) { if ($this->cacheFactory->isLocalCacheAvailable()) {
try { try {
$podcast = $this->podcastDataReader->tryGetCachedPodcastData($url); $podcast = $this->podcastDataReader->tryGetCachedPodcastData($url);
} catch (\Exception) { } catch (\Exception $e) {
} }
} }
@ -51,7 +49,7 @@ class PodcastController extends Controller
if ($this->cacheFactory->isLocalCacheAvailable()) { if ($this->cacheFactory->isLocalCacheAvailable()) {
try { try {
$this->podcastDataReader->trySetCachedPodcastData($url, $podcast); $this->podcastDataReader->trySetCachedPodcastData($url, $podcast);
} catch (\Exception) { } catch (\Exception $e) {
} }
} }

View File

@ -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,14 +14,15 @@ 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] /**
#[NoCSRFRequired] * @NoAdminRequired
#[FrontpageRoute(verb: 'GET', url: '/search')] * @NoCSRFRequired
*/
public function index(string $q): JSONResponse { public function index(string $q): JSONResponse {
return new JSONResponse($this->multiPodService->search($q)); return new JSONResponse($this->multiPodService->search($q));
} }

View File

@ -7,9 +7,6 @@ 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;
@ -17,21 +14,23 @@ class ToplistController 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] * @NoAdminRequired
#[FrontpageRoute(verb: 'GET', url: '/toplist/hot')] * @NoCSRFRequired
*/
public function hot(): JSONResponse { public function hot(): JSONResponse {
return new JSONResponse($this->fyydService->hot()); return new JSONResponse($this->fyydService->hot());
} }
#[NoAdminRequired] /**
#[NoCSRFRequired] * @NoAdminRequired
#[FrontpageRoute(verb: 'GET', url: '/toplist/new')] * @NoCSRFRequired
*/
public function new(): JSONResponse { public function new(): JSONResponse {
return new JSONResponse($this->fyydService->latest()); return new JSONResponse($this->fyydService->latest());
} }

View File

@ -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,
]; ];
} }

View File

@ -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,7 +20,6 @@ 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 {
@ -93,6 +92,9 @@ class EpisodeActionReader extends CoreEpisodeActionReader
$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 (isset($iTunesItemChildren)) {
$duration = $this->stringOrNull($iTunesItemChildren->duration); $duration = $this->stringOrNull($iTunesItemChildren->duration);
@ -105,7 +107,7 @@ class EpisodeActionReader extends CoreEpisodeActionReader
if (isset($pubDate)) { if (isset($pubDate)) {
try { try {
$pubDate = new \DateTime($pubDate); $pubDate = new \DateTime($pubDate);
} catch (\Exception) { } catch (\Exception $e) {
$pubDate = null; $pubDate = null;
} }
} }

View File

@ -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 {
@ -94,8 +94,8 @@ class FyydService implements IPodProvider
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();

View File

@ -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 {

View File

@ -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(

View File

@ -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 {
@ -30,7 +30,7 @@ class SearchProvider implements IProvider
} }
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;
} }

View File

@ -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 {

2764
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +1,43 @@
{ {
"name": "repod", "name": "repod",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build --mode production",
"dev": "vite --mode development build", "dev": "vite build --mode development",
"lint": "vue-tsc && eslint src", "dev:watch": "vite build --mode development --watch",
"lint:fix": "vue-tsc && eslint src --fix", "watch": "npm run dev:watch",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css", "stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css",
"stylelint:fix": "stylelint src/**/*.vue src/**/*.scss src/**/*.css --fix", "stylelint:fix": "stylelint src/**/*.vue src/**/*.scss src/**/*.css --fix"
"watch": "vite --mode development build --watch"
}, },
"browserslist": [ "browserslist": [
"extends @nextcloud/browserslist-config" "extends @nextcloud/browserslist-config"
], ],
"prettier": "@nextcloud/prettier-config", "prettier": "@nextcloud/prettier-config",
"dependencies": { "dependencies": {
"@formatjs/intl-segmenter": "^11.7.3", "@nextcloud/axios": "^2.5.0",
"@nextcloud/axios": "^2.5.1",
"@nextcloud/initial-state": "^2.2.0", "@nextcloud/initial-state": "^2.2.0",
"@nextcloud/l10n": "^3.1.0", "@nextcloud/l10n": "^3.1.0",
"@nextcloud/router": "^3.0.1", "@nextcloud/router": "^3.0.1",
"@nextcloud/vite-config": "^2.2.2", "@nextcloud/vite-config": "^2.2.2",
"@nextcloud/vue": "9.0.0-alpha.5", "@nextcloud/vue": "9.0.0-alpha.5",
"dompurify": "^3.2.0", "dompurify": "^3.1.6",
"linkify-html": "^4.1.3", "linkify-html": "^4.1.3",
"pinia": "^2.2.6", "pinia": "^2.2.2",
"toastify-js": "^1.12.0", "toastify-js": "^1.12.0",
"vite": "^5.4.11", "vite": "^5.4.1",
"vue": "^3.5.12", "vue": "^3.4.38",
"vue-material-design-icons": "^5.3.1", "vue-material-design-icons": "^5.3.0",
"vue-router": "^4.4.5" "vue-router": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@nextcloud/browserslist-config": "^3.0.1", "@nextcloud/browserslist-config": "^3.0.1",
"@nextcloud/eslint-config": "^8.4.1", "@nextcloud/eslint-config": "^8.4.1",
"@nextcloud/prettier-config": "^1.1.0", "@nextcloud/prettier-config": "^1.1.0",
"@nextcloud/stylelint-config": "^3.0.1", "@nextcloud/stylelint-config": "^3.0.1",
"@types/toastify-js": "^1.12.3",
"@vue/eslint-config-typescript": "^13",
"@vue/tsconfig": "^0.6.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-pinia": "^0.4.1", "eslint-plugin-pinia": "^0.2.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1"
"typescript": "5.5",
"vue-tsc": "^2.1.10"
} }
} }

View File

@ -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,
)
;

View File

@ -1,4 +1,3 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json"
"rangeStrategy": "bump"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 857 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 817 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 961 KiB

After

Width:  |  Height:  |  Size: 214 KiB

View File

@ -7,7 +7,7 @@
</NcContent> </NcContent>
</template> </template>
<script lang="ts"> <script>
import 'toastify-js/src/toastify.css' import 'toastify-js/src/toastify.css'
import { mapActions, mapState } from 'pinia' import { mapActions, mapState } from 'pinia'
import Bar from './components/Player/Bar.vue' import Bar from './components/Player/Bar.vue'
@ -15,7 +15,7 @@ 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' import { usePlayer } from './store/player.js'
export default { export default {
name: 'App', name: 'App',
@ -33,9 +33,15 @@ export default {
}, },
mounted() { mounted() {
this.init() this.init()
setInterval(this.loop, 40000)
}, },
methods: { methods: {
...mapActions(usePlayer, ['init']), ...mapActions(usePlayer, ['init', 'time']),
loop() {
if (this.paused === false) {
this.time()
}
},
}, },
} }
</script> </script>

View File

@ -1,13 +1,13 @@
<template> <template>
<NcAppContent :class="{ episode }"> <NcAppContent :class="{ padding: episode }">
<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 { mapState } from 'pinia'
import { usePlayer } from '../../store/player.ts' import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'AppContent', name: 'AppContent',
@ -21,7 +21,7 @@ export default {
</script> </script>
<style scoped> <style scoped>
.episode { .padding {
padding-bottom: 6rem; padding-bottom: 6rem;
} }
</style> </style>

View File

@ -1,5 +1,5 @@
<template> <template>
<NcAppNavigation :class="{ episode }"> <NcAppNavigation :class="{ padding: episode }">
<slot /> <slot />
<template #list> <template #list>
<slot name="list" /> <slot name="list" />
@ -10,10 +10,10 @@
</NcAppNavigation> </NcAppNavigation>
</template> </template>
<script lang="ts"> <script>
import { NcAppNavigation } from '@nextcloud/vue' import { NcAppNavigation } from '@nextcloud/vue'
import { mapState } from 'pinia' import { mapState } from 'pinia'
import { usePlayer } from '../../store/player.ts' import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'AppNavigation', name: 'AppNavigation',
@ -27,7 +27,7 @@ export default {
</script> </script>
<style scoped> <style scoped>
.episode { .padding {
padding-bottom: 6rem; padding-bottom: 6rem;
} }
</style> </style>

View File

@ -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>

View File

@ -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 {

View File

@ -1,43 +1,37 @@
<template> <template>
<div class="flex"> <div class="flex">
<NcAvatar <NcAvatar :display-name="name" :is-no-user="true" :size="256" :url="image" />
:display-name="episode.name" <h2>{{ name }}</h2>
:is-no-user="true" <SafeHtml :source="description" />
:size="256"
:url="episode.image" />
<h2>{{ episode.name }}</h2>
<SafeHtml :source="episode.description || ''" />
<div class="flex"> <div class="flex">
<NcButton v-if="episode.link" :href="episode.link" target="_blank"> <NcButton v-if="link" :href="link" target="_blank">
<template #icon> <template #icon>
<OpenInNewIcon :size="20" /> <OpenInNewIcon :size="20" />
</template> </template>
{{ episode.title }} {{ title }}
</NcButton> </NcButton>
<NcButton <NcButton
v-if="episode.url" v-if="url"
:download="filenameFromUrl(episode.url)" :download="filenameFromUrl(url)"
:href="episode.url" :href="url"
target="_blank"> target="_blank">
<template #icon> <template #icon>
<DownloadIcon :size="20" /> <DownloadIcon :size="20" />
</template> </template>
{{ t('repod', 'Download') }} {{ t('repod', 'Download') }}
{{ episode.size ? `(${humanFileSize(episode.size)})` : '' }} {{ size ? `(${humanFileSize(size)})` : '' }}
</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 DownloadIcon from 'vue-material-design-icons/Download.vue'
import type { EpisodeInterface } from '../../utils/types.ts'
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue' import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'
import SafeHtml from './SafeHtml.vue' import SafeHtml from './SafeHtml.vue'
import { filenameFromUrl } from '../../utils/url.ts' import { filenameFromUrl } from '../../utils/url.js'
import { humanFileSize } from '../../utils/size.ts' import { humanFileSize } from '../../utils/size.js'
import { t } from '@nextcloud/l10n'
export default { export default {
name: 'Modal', name: 'Modal',
@ -49,15 +43,38 @@ export default {
SafeHtml, 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: { methods: {
filenameFromUrl, filenameFromUrl,
humanFileSize, humanFileSize,
t,
}, },
} }
</script> </script>

View File

@ -1,44 +1,22 @@
<template> <template>
<div ref="html" v-sanitize="source" class="html" /> <div v-sanitize="source" class="html" />
</template> </template>
<script lang="ts"> <script>
import dompurify from 'dompurify' import dompurify from 'dompurify'
import linkifyHtml from 'linkify-html' 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 { export default {
name: 'SafeHtml', name: 'SafeHtml',
directives: { directives: {
sanitize: { sanitize: {
mounted(el, binding) { mounted(el, binding) {
el.innerHTML = dompurify el.innerHTML = dompurify.sanitize(
.sanitize(
linkifyHtml(binding.value, { linkifyHtml(binding.value, {
nl2br: true, nl2br: true,
target: '_blank', 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>`,
)
}, },
}, },
}, },
@ -48,31 +26,11 @@ export default {
required: true, 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> </script>
<style> <style>
.html a, .html a {
seekable {
cursor: pointer;
text-decoration: underline; text-decoration: underline;
} }
</style> </style>

View File

@ -1,8 +1,8 @@
<template> <template>
<NcAppNavigationList class="list"> <NcAppNavigationList>
<NcAppNavigationNewItem <NcAppNavigationNewItem
:name="t('repod', 'Add a RSS link')" :name="t('repod', 'Add a RSS link')"
@new-item="(url) => $router.push(toFeedUrl(url))"> @new-item="addSubscription">
<template #icon> <template #icon>
<PlusIcon :size="20" /> <PlusIcon :size="20" />
</template> </template>
@ -10,11 +10,10 @@
</NcAppNavigationList> </NcAppNavigationList>
</template> </template>
<script lang="ts"> <script>
import { NcAppNavigationList, NcAppNavigationNewItem } from '@nextcloud/vue' import { NcAppNavigationList, NcAppNavigationNewItem } from '@nextcloud/vue'
import PlusIcon from 'vue-material-design-icons/Plus.vue' import PlusIcon 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',
@ -24,14 +23,15 @@ export default {
PlusIcon, PlusIcon,
}, },
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>

View File

@ -7,7 +7,7 @@
:key="feed.link" :key="feed.link"
:details="formatLocaleDate(new Date(feed.fetchedAtUnix * 1000))" :details="formatLocaleDate(new Date(feed.fetchedAtUnix * 1000))"
: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"
@ -19,7 +19,7 @@
</template> </template>
<template #actions> <template #actions>
<NcActionButton <NcActionButton
v-if="!getSubByUrl(feed.link)" v-if="!subscriptions.includes(feed.link)"
:aria-label="t('repod', 'Subscribe')" :aria-label="t('repod', 'Subscribe')"
:name="t('repod', 'Subscribe')" :name="t('repod', 'Subscribe')"
:title="t('repod', 'Subscribe')" :title="t('repod', 'Subscribe')"
@ -34,19 +34,18 @@
</div> </div>
</template> </template>
<script lang="ts"> <script>
import { NcActionButton, NcAvatar, NcListItem } from '@nextcloud/vue' import { NcActionButton, NcAvatar, NcListItem } from '@nextcloud/vue'
import { mapActions, mapState } from 'pinia' 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 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 { formatLocaleDate } from '../../utils/time.js'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { showError } from '../../utils/toast.ts' import { showError } from '../../utils/toast.js'
import { t } from '@nextcloud/l10n' import { toUrl } from '../../utils/url.js'
import { toFeedUrl } from '../../utils/url.ts' import { useSubscriptions } from '../../store/subscriptions.js'
import { useSubscriptions } from '../../store/subscriptions.ts'
export default { export default {
name: 'Search', name: 'Search',
@ -63,28 +62,25 @@ export default {
required: true, required: true,
}, },
}, },
data: () => ({ data() {
feeds: [] as PodcastDataInterface[], return {
feeds: [],
loading: false, loading: false,
timeout: null as NodeJS.Timeout | null, }
}), },
computed: { computed: {
...mapState(useSubscriptions, ['getSubByUrl']), ...mapState(useSubscriptions, ['subscriptions']),
}, },
watch: { watch: {
value() { value() {
if (this.timeout) { this.search()
clearTimeout(this.timeout)
}
this.timeout = setTimeout(this.search, 200)
}, },
}, },
methods: { methods: {
...mapActions(useSubscriptions, ['fetch']), ...mapActions(useSubscriptions, ['fetch']),
formatLocaleDate, formatLocaleDate,
t, toUrl,
toFeedUrl, async addSubscription(url) {
async addSubscription(url: string) {
try { try {
await axios.post( await axios.post(
generateUrl('/apps/gpoddersync/subscription_change/create'), generateUrl('/apps/gpoddersync/subscription_change/create'),
@ -97,13 +93,14 @@ export default {
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.fetch()
}, },
async search() { search: debounce(async function value() {
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?q={value}', { generateUrl('/apps/repod/search?q={value}', {
value: currentSearch, value: currentSearch,
}), }),
@ -121,7 +118,7 @@ export default {
this.loading = false this.loading = false
} }
} }
}, }, 200),
}, },
} }
</script> </script>

View File

@ -4,7 +4,7 @@
<Loading v-if="loading" /> <Loading v-if="loading" />
<ul v-if="!loading"> <ul v-if="!loading">
<li v-for="top in tops" :key="top.link"> <li v-for="top in tops" :key="top.link">
<router-link :to="toFeedUrl(top.link)"> <router-link :to="toUrl(top.link)">
<img :src="top.imageUrl" :title="top.author" /> <img :src="top.imageUrl" :title="top.author" />
</router-link> </router-link>
</li> </li>
@ -12,14 +12,12 @@
</div> </div>
</template> </template>
<script lang="ts"> <script>
import Loading from '../Atoms/Loading.vue' import Loading from '../Atoms/Loading.vue'
import type { PodcastDataInterface } from '../../utils/types.ts'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { showError } from '../../utils/toast.ts' import { showError } from '../../utils/toast.js'
import { t } from '@nextcloud/l10n' import { toUrl } from '../../utils/url.js'
import { toFeedUrl } from '../../utils/url.ts'
export default { export default {
name: 'Toplist', name: 'Toplist',
@ -32,10 +30,12 @@ export default {
required: true, required: true,
}, },
}, },
data: () => ({ data() {
return {
loading: true, loading: true,
tops: [] as PodcastDataInterface[], tops: [],
}), }
},
computed: { computed: {
title() { title() {
switch (this.type) { switch (this.type) {
@ -51,8 +51,8 @@ export default {
async mounted() { async mounted() {
try { try {
this.loading = true this.loading = true
const tops = await axios.get<PodcastDataInterface[]>( const tops = await axios.get(
generateUrl('/apps/repod/toplist/{type}', { type: this.type }), generateUrl(`/apps/repod/toplist/${this.type}`),
) )
this.tops = tops.data this.tops = tops.data
} catch (e) { } catch (e) {
@ -63,7 +63,7 @@ export default {
} }
}, },
methods: { methods: {
toFeedUrl, toUrl,
}, },
} }
</script> </script>

View File

@ -1,13 +1,13 @@
<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> <div>
<NcAvatar <NcAvatar
:display-name="feed.author || feed.title" :display-name="author || title"
:is-no-user="true" :is-no-user="true"
:size="128" :size="128"
:url="feed.imageUrl" /> :url="imageUrl" />
<a class="feed" :href="url" @click.prevent="copyFeed"> <a class="feed" :href="url" @click.prevent="copyFeed">
<RssIcon :size="20" /> <RssIcon :size="20" />
<i>{{ t('repod', 'Copy feed') }}</i> <i>{{ t('repod', 'Copy feed') }}</i>
@ -15,15 +15,15 @@
</div> </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 || ''" /> <SafeHtml :source="description" />
</div> </div>
<NcAppNavigationNew <NcAppNavigationNew
v-if="!getSubByUrl(url)" v-if="!subscriptions.includes(url)"
:text="t('repod', 'Subscribe')" :text="t('repod', 'Subscribe')"
@click="addSubscription"> @click="addSubscription">
<template #icon> <template #icon>
@ -35,19 +35,17 @@
</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 { mapActions, mapState } from 'pinia'
import { showError, showSuccess } from '../../utils/toast.ts' import { showError, showSuccess } from '../../utils/toast.js'
import PlusIcon from 'vue-material-design-icons/Plus.vue' 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 RssIcon from 'vue-material-design-icons/Rss.vue'
import SafeHtml from '../Atoms/SafeHtml.vue' import SafeHtml from '../Atoms/SafeHtml.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 { t } from '@nextcloud/l10n' import { useSubscriptions } from '../../store/subscriptions.js'
import { useSubscriptions } from '../../store/subscriptions.ts'
export default { export default {
name: 'Banner', name: 'Banner',
@ -59,20 +57,35 @@ export default {
SafeHtml, 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']), ...mapState(useSubscriptions, ['subscriptions']),
url() { url() {
return decodeUrl(this.$route.params.url as string) return decodeUrl(this.$route.params.url)
}, },
}, },
methods: { methods: {
...mapActions(useSubscriptions, ['fetch']), ...mapActions(useSubscriptions, ['fetch']),
t,
async addSubscription() { async addSubscription() {
try { try {
await axios.post( await axios.post(
@ -86,6 +99,7 @@ export default {
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.fetch()
}, },
copyFeed() { copyFeed() {
@ -129,13 +143,11 @@ export default {
} }
.infos { .infos {
flex: 1;
overflow: auto; overflow: auto;
} }
.inner { .inner {
display: flex; display: flex;
flex: 1;
} }
@media only screen and (max-width: 768px) { @media only screen and (max-width: 768px) {

View File

@ -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>

View File

@ -2,39 +2,175 @@
<div> <div>
<Loading v-if="loading" /> <Loading v-if="loading" />
<ul v-if="!loading"> <ul v-if="!loading">
<Episode <NcListItem
v-for="episode in filteredEpisodes" v-for="episode in filteredEpisodes"
:key="episode.guid" :key="episode.guid"
:episode="episode" :active="isCurrentEpisode(episode)"
:url="url" /> :details="formatLocaleDate(new Date(episode.pubDate?.date))"
:force-display-actions="true"
:href="$route.href"
:name="episode.name"
:style="{ opacity: hasEnded(episode) ? 0.4 : 1 }"
target="_self"
: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 && !hasEnded(episode)"
:aria-label="t('repod', 'Mark as read')"
:disabled="loadingAction"
:name="t('repod', 'Mark as read')"
:title="t('repod', 'Mark as read')"
@click="markAs(episode, true)">
<template #icon>
<PlaylistPlayIcon :size="20" />
</template>
</NcActionButton>
<NcActionButton
v-if="episode.duration && hasEnded(episode)"
:aria-label="t('repod', 'Mark as unread')"
:disabled="loadingAction"
:name="t('repod', 'Mark as unread')"
:title="t('repod', 'Mark as unread')"
@click="markAs(episode, false)">
<template #icon>
<PlaylistRemoveIcon :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>
</template>
<template #icon>
<NcAvatar
:display-name="episode.name"
:is-no-user="true"
:url="episode.image" />
</template>
<template #indicator>
<NcProgressBar
v-if="isListening(episode)"
class="progress"
:value="
(episode.action.position * 100) / episode.action.total
" />
</template>
<template #subname>
{{ episode.duration }}
</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 {
import Episode from './Episode.vue' NcActionButton,
import type { EpisodeInterface } from '../../utils/types.ts' NcActionLink,
NcActions,
NcAvatar,
NcListItem,
NcModal,
NcProgressBar,
} from '@nextcloud/vue'
import { decodeUrl, filenameFromUrl } from '../../utils/url.js'
import {
durationToSeconds,
formatEpisodeTimestamp,
formatLocaleDate,
} from '../../utils/time.js'
import { mapActions, mapState } from 'pinia'
import DownloadIcon from 'vue-material-design-icons/Download.vue'
import Loading from '../Atoms/Loading.vue' import Loading from '../Atoms/Loading.vue'
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 axios from '@nextcloud/axios'
import { decodeUrl } from '../../utils/url.ts'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { mapState } from 'pinia' import { showError } from '../../utils/toast.js'
import { showError } from '../../utils/toast.ts' import { usePlayer } from '../../store/player.js'
import { t } from '@nextcloud/l10n' import { useSettings } from '../../store/settings.js'
import { usePlayer } from '../../store/player.ts'
import { useSettings } from '../../store/settings.ts'
export default { export default {
name: 'Episodes', name: 'Episodes',
components: { components: {
Episode, DownloadIcon,
Loading, Loading,
Modal,
NcActionButton,
NcActionLink,
NcActions,
NcAvatar,
NcListItem,
NcModal,
NcProgressBar,
OpenInNewIcon,
PlayIcon,
PlaylistPlayIcon,
PlaylistRemoveIcon,
StopIcon,
}, },
data: () => ({ data() {
episodes: [] as EpisodeInterface[], return {
episodes: [],
loading: true, loading: true,
}), loadingAction: false,
modalEpisode: null,
}
},
computed: { computed: {
...mapState(usePlayer, ['episode']), ...mapState(usePlayer, ['episode']),
...mapState(useSettings, ['filters']), ...mapState(useSettings, ['filters']),
@ -43,24 +179,27 @@ export default {
if (!this.filters.listened && this.hasEnded(episode)) { if (!this.filters.listened && this.hasEnded(episode)) {
return false return false
} }
if (!this.filters.listening && this.isListening(episode)) { if (!this.filters.listening && this.isListening(episode)) {
return false return false
} }
if (!this.filters.unlistened && !this.isListening(episode)) { if (!this.filters.unlistened && !this.isListening(episode)) {
return false return false
} }
return true return true
}) })
}, },
url() { url() {
return decodeUrl(this.$route.params.url as string) return decodeUrl(this.$route.params.url)
}, },
}, },
watch: { watch: {
episode() { episode() {
if (this.episode) { if (this.episode) {
this.episodes = this.episodes.map((e) => this.episodes = this.episodes.map((e) =>
e.url === this.episode?.url ? this.episode : e, e.url === this.episode.url ? this.episode : e,
) )
} }
}, },
@ -68,15 +207,13 @@ export default {
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}', { generateUrl('/apps/repod/episodes/list?url={url}', {
url: this.url, url: this.url,
}), }),
) )
this.episodes = [...episodes.data].sort( this.episodes = [...episodes.data].sort(
(a, b) => (a, b) => new Date(b.pubDate?.date) - new Date(a.pubDate?.date),
new Date(b.pubDate?.date || '').getTime() -
new Date(a.pubDate?.date || '').getTime(),
) )
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@ -86,8 +223,63 @@ export default {
} }
}, },
methods: { methods: {
hasEnded, ...mapActions(usePlayer, ['load']),
isListening, filenameFromUrl,
formatLocaleDate,
hasEnded(episode) {
return (
episode.action &&
(episode.action.action === 'DELETE' ||
(episode.action.position > 0 &&
episode.action.total > 0 &&
episode.action.position >= episode.action.total))
)
},
isCurrentEpisode(episode) {
return this.episode && this.episode.url === episode.url
},
isListening(episode) {
return (
episode.action &&
episode.action.action &&
episode.action.action.toLowerCase() === 'play' &&
episode.action.position > 0 &&
!this.hasEnded(episode)
)
},
async markAs(episode, read) {
try {
this.loadingAction = true
episode.action = {
podcast: this.url,
episode: episode.url,
guid: episode.guid,
action: 'play',
timestamp: formatEpisodeTimestamp(new Date()),
started: episode.action ? 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.episode && episode.url === this.episode.url) {
this.load(null)
}
} catch (e) {
console.error(e)
showError(t('repod', 'Could not change the status of the episode'))
} finally {
this.loadingAction = false
}
},
}, },
} }
</script> </script>
<style scoped>
.progress {
margin-top: 0.4rem;
}
</style>

View File

@ -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>

View File

@ -13,7 +13,7 @@
</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'
@ -21,7 +21,7 @@ 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 { mapState } from 'pinia'
import { usePlayer } from '../../store/player.ts' import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'Bar', name: 'Bar',
@ -78,7 +78,7 @@ export default {
@media only screen and (max-width: 768px) { @media only screen and (max-width: 768px) {
.infos { .infos {
flex: 1; flex: 2;
} }
.timer, .timer,

View File

@ -1,53 +1,27 @@
<template> <template>
<div class="controls"> <div class="controls">
<Rewind10Icon <PauseIcon v-if="!paused" class="pointer" :size="50" @click="pause" />
class="pointer rewind" <PlayIcon v-if="paused" class="pointer" :size="50" @click="play" />
:size="20"
:title="t('repod', 'Rewind 10 seconds')"
@click="seek((currentTime ?? 0) - 10)" />
<PauseIcon
v-if="!paused"
class="pointer"
:size="50"
:title="t('repod', 'Pause')"
@click="pause" />
<PlayIcon
v-if="paused"
class="pointer"
:size="50"
:title="t('repod', '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 { mapActions, mapState } from 'pinia'
import FastForward30Icon from 'vue-material-design-icons/FastForward30.vue'
import PauseIcon from 'vue-material-design-icons/Pause.vue' import PauseIcon from 'vue-material-design-icons/Pause.vue'
import PlayIcon from 'vue-material-design-icons/Play.vue' import PlayIcon from 'vue-material-design-icons/Play.vue'
import Rewind10Icon from 'vue-material-design-icons/Rewind10.vue' import { usePlayer } from '../../store/player.js'
import { t } from '@nextcloud/l10n'
import { usePlayer } from '../../store/player.ts'
export default { export default {
name: 'Controls', name: 'Controls',
components: { components: {
FastForward30Icon,
PauseIcon, PauseIcon,
PlayIcon, PlayIcon,
Rewind10Icon,
}, },
computed: { computed: {
...mapState(usePlayer, ['currentTime', 'paused']), ...mapState(usePlayer, ['paused']),
}, },
methods: { methods: {
...mapActions(usePlayer, ['play', 'pause', 'seek']), ...mapActions(usePlayer, ['play', 'pause']),
t,
}, },
} }
</script> </script>
@ -55,17 +29,9 @@ export default {
<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>

View File

@ -1,23 +1,30 @@
<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 }} {{ episode.name }}
</strong> </strong>
<router-link :to="toFeedUrl(podcastUrl)"> <router-link :to="hash">
<i>{{ episode.title }}</i> <i>{{ 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="episode.description"
:image="episode.image"
:link="episode.link"
:name="episode.name"
:size="episode.size"
:title="episode.title"
:url="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 { mapState } from 'pinia'
import { toFeedUrl } from '../../utils/url.ts' import { toUrl } from '../../utils/url.js'
import { usePlayer } from '../../store/player.ts' import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'Infos', name: 'Infos',
@ -25,14 +32,16 @@ export default {
Modal, Modal,
NcModal, NcModal,
}, },
data: () => ({ data() {
return {
modal: false, modal: false,
}), }
},
computed: { computed: {
...mapState(usePlayer, ['episode', 'podcastUrl']), ...mapState(usePlayer, ['episode', 'podcastUrl']),
hash() {
return toUrl(this.podcastUrl)
}, },
methods: {
toFeedUrl,
}, },
} }
</script> </script>

View File

@ -1,19 +1,16 @@
<template> <template>
<input <input
v-if="duration"
class="progress" class="progress"
:max="duration" :max="duration"
min="0" min="0"
type="range" type="range"
:value="currentTime" :value="currentTime"
@change=" @change="(event) => seek(event.target.value)" />
(event) => seek(parseInt((event.target as HTMLInputElement).value))
" />
</template> </template>
<script lang="ts"> <script>
import { mapActions, mapState } from 'pinia' import { mapActions, mapState } from 'pinia'
import { usePlayer } from '../../store/player.ts' import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'ProgressBar', name: 'ProgressBar',

View File

@ -1,15 +1,15 @@
<template> <template>
<div v-if="currentTime && duration" class="root"> <div>
<span>{{ formatTimer(new Date(currentTime * 1000)) }}</span> <span>{{ formatTimer(new Date(currentTime * 1000)) }}</span>
<span>/</span> <span>/</span>
<span>{{ formatTimer(new Date(duration * 1000)) }}</span> <span>{{ formatTimer(new Date(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 { mapState } from 'pinia'
import { usePlayer } from '../../store/player.ts' import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'Timer', name: 'Timer',
@ -23,7 +23,7 @@ export default {
</script> </script>
<style scoped> <style scoped>
.root { div {
align-items: center; align-items: center;
display: flex; display: flex;
gap: 5px; gap: 5px;

View File

@ -4,25 +4,21 @@
v-if="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 <VolumeLowIcon
v-if="volume > 0 && 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 <VolumeMediumIcon
v-if="volume > 0.3 && 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 <VolumeMuteIcon
v-if="volume === 0" v-if="volume === 0"
class="pointer" class="pointer"
:size="30" :size="30"
:title="t('repod', 'Unmute')"
@click="setVolume(volumeMuted)" /> @click="setVolume(volumeMuted)" />
<input <input
max="1" max="1"
@ -30,21 +26,17 @@
step="0.1" step="0.1"
type="range" type="range"
:value="volume" :value="volume"
@change=" @change="(event) => setVolume(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 { mapActions, mapState } from 'pinia'
import VolumeHighIcon from 'vue-material-design-icons/VolumeHigh.vue' import VolumeHighIcon from 'vue-material-design-icons/VolumeHigh.vue'
import VolumeLowIcon from 'vue-material-design-icons/VolumeLow.vue' import VolumeLowIcon from 'vue-material-design-icons/VolumeLow.vue'
import VolumeMediumIcon from 'vue-material-design-icons/VolumeMedium.vue' import VolumeMediumIcon from 'vue-material-design-icons/VolumeMedium.vue'
import VolumeMuteIcon 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.js'
import { usePlayer } from '../../store/player.ts'
export default { export default {
name: 'Volume', name: 'Volume',
@ -54,9 +46,11 @@ export default {
VolumeMediumIcon, VolumeMediumIcon,
VolumeMuteIcon, VolumeMuteIcon,
}, },
data: () => ({ data() {
return {
volumeMuted: 0, volumeMuted: 0,
}), }
},
computed: { computed: {
...mapState(usePlayer, ['volume']), ...mapState(usePlayer, ['volume']),
}, },
@ -66,7 +60,6 @@ export default {
this.volumeMuted = this.volume this.volumeMuted = this.volume
this.setVolume(0) this.setVolume(0)
}, },
t,
}, },
} }
</script> </script>

View File

@ -8,11 +8,10 @@
</NcAppNavigationItem> </NcAppNavigationItem>
</template> </template>
<script lang="ts"> <script>
import ExportIcon from 'vue-material-design-icons/Export.vue' import ExportIcon from 'vue-material-design-icons/Export.vue'
import { NcAppNavigationItem } from '@nextcloud/vue' import { NcAppNavigationItem } from '@nextcloud/vue'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { t } from '@nextcloud/l10n'
export default { export default {
name: 'Export', name: 'Export',
@ -22,7 +21,6 @@ export default {
}, },
methods: { methods: {
generateUrl, generateUrl,
t,
}, },
} }
</script> </script>

View File

@ -1,5 +1,6 @@
<template> <template>
<NcAppNavigationItem <NcAppNavigationItem
:allow-collapse="true"
menu-placement="top" menu-placement="top"
:name="t('repod', 'Filtering episodes')"> :name="t('repod', 'Filtering episodes')">
<template #actions> <template #actions>
@ -38,13 +39,13 @@
</NcAppNavigationItem> </NcAppNavigationItem>
</template> </template>
<script lang="ts"> <script>
import { NcActionCheckbox, NcAppNavigationItem } from '@nextcloud/vue' import { NcActionCheckbox, NcAppNavigationItem } from '@nextcloud/vue'
import { mapActions, mapState } from 'pinia' import { mapActions, mapState } from 'pinia'
import FilterIcon from 'vue-material-design-icons/Filter.vue' import FilterIcon from 'vue-material-design-icons/Filter.vue'
import FilterSettingsIcon from 'vue-material-design-icons/FilterSettings.vue' import FilterSettingsIcon from 'vue-material-design-icons/FilterSettings.vue'
import { t } from '@nextcloud/l10n' import { getCookie } from '../../utils/cookies.js'
import { useSettings } from '../../store/settings.ts' import { useSettings } from '../../store/settings.js'
export default { export default {
name: 'Filters', name: 'Filters',
@ -64,9 +65,14 @@ export default {
) )
}, },
}, },
mounted() {
try {
const filters = getCookie('repod.filters')
this.filters = JSON.parse(filters)
} catch {}
},
methods: { methods: {
...mapActions(useSettings, ['setFilters']), ...mapActions(useSettings, ['setFilters']),
t,
}, },
} }
</script> </script>

View File

@ -29,13 +29,12 @@
</NcAppNavigationItem> </NcAppNavigationItem>
</template> </template>
<script lang="ts"> <script>
import { NcAppNavigationItem, NcModal } from '@nextcloud/vue' import { NcAppNavigationItem, NcModal } from '@nextcloud/vue'
import ImportIcon from 'vue-material-design-icons/Import.vue' import ImportIcon from 'vue-material-design-icons/Import.vue'
import Loading from '../Atoms/Loading.vue' import Loading from '../Atoms/Loading.vue'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { t } from '@nextcloud/l10n'
export default { export default {
name: 'Import', name: 'Import',
@ -45,19 +44,19 @@ export default {
NcAppNavigationItem, NcAppNavigationItem,
NcModal, NcModal,
}, },
data: () => ({ data() {
return {
loading: false, loading: false,
modal: false, modal: false,
}), }
},
methods: { methods: {
generateUrl, generateUrl,
t, async importOpml(event) {
async importOpml(event: Event) {
try { try {
const target = event.target as HTMLFormElement const formData = new FormData(event.target)
const formData = new FormData(target) this.importLoading = true
this.loading = true await axios.post(event.target.action, formData)
await axios.post(target.action, formData)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} finally { } finally {

View File

@ -8,10 +8,9 @@
</NcAppNavigationItem> </NcAppNavigationItem>
</template> </template>
<script lang="ts"> <script>
import { NcAppNavigationItem } from '@nextcloud/vue' import { NcAppNavigationItem } from '@nextcloud/vue'
import StarIcon from 'vue-material-design-icons/Star.vue' import StarIcon from 'vue-material-design-icons/Star.vue'
import { t } from '@nextcloud/l10n'
export default { export default {
name: 'Rate', name: 'Rate',
@ -19,8 +18,5 @@ export default {
NcAppNavigationItem, NcAppNavigationItem,
StarIcon, StarIcon,
}, },
methods: {
t,
},
} }
</script> </script>

View File

@ -1,7 +1,6 @@
<template> <template>
<NcAppNavigationSettings> <NcAppNavigationSettings>
<Filters /> <Filters />
<Sleep />
<Speed /> <Speed />
<Import /> <Import />
<Export /> <Export />
@ -9,13 +8,12 @@
</NcAppNavigationSettings> </NcAppNavigationSettings>
</template> </template>
<script lang="ts"> <script>
import Export from './Export.vue' import Export from './Export.vue'
import Filters from './Filters.vue' import Filters from './Filters.vue'
import Import from './Import.vue' import Import from './Import.vue'
import { NcAppNavigationSettings } from '@nextcloud/vue' import { NcAppNavigationSettings } from '@nextcloud/vue'
import Rate from './Rate.vue' import Rate from './Rate.vue'
import Sleep from './Sleep.vue'
import Speed from './Speed.vue' import Speed from './Speed.vue'
export default { export default {
@ -26,7 +24,6 @@ export default {
Import, Import,
NcAppNavigationSettings, NcAppNavigationSettings,
Rate, Rate,
Sleep,
Speed, Speed,
}, },
} }

View File

@ -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>

View File

@ -15,7 +15,7 @@
</NcAppNavigationItem> </NcAppNavigationItem>
</template> </template>
<script lang="ts"> <script>
import { NcAppNavigationItem, NcCounterBubble } from '@nextcloud/vue' import { NcAppNavigationItem, NcCounterBubble } from '@nextcloud/vue'
import { mapActions, mapState } from 'pinia' import { mapActions, mapState } from 'pinia'
import MinusIcon from 'vue-material-design-icons/Minus.vue' import MinusIcon from 'vue-material-design-icons/Minus.vue'
@ -23,8 +23,7 @@ import PlusIcon from 'vue-material-design-icons/Plus.vue'
import SpeedometerIcon from 'vue-material-design-icons/Speedometer.vue' import SpeedometerIcon from 'vue-material-design-icons/Speedometer.vue'
import SpeedometerMediumIcon from 'vue-material-design-icons/SpeedometerMedium.vue' import SpeedometerMediumIcon from 'vue-material-design-icons/SpeedometerMedium.vue'
import SpeedometerSlowIcon from 'vue-material-design-icons/SpeedometerSlow.vue' import SpeedometerSlowIcon from 'vue-material-design-icons/SpeedometerSlow.vue'
import { t } from '@nextcloud/l10n' import { usePlayer } from '../../store/player.js'
import { usePlayer } from '../../store/player.ts'
export default { export default {
name: 'Speed', name: 'Speed',
@ -42,9 +41,8 @@ export default {
}, },
methods: { methods: {
...mapActions(usePlayer, ['setRate']), ...mapActions(usePlayer, ['setRate']),
t, changeRate(diff) {
changeRate(diff: number) { const newRate = (this.rate + diff).toPrecision(2)
const newRate = parseFloat((this.rate + diff).toPrecision(2))
this.setRate(newRate > 0 ? newRate : this.rate) this.setRate(newRate > 0 ? newRate : this.rate)
}, },
}, },

View File

@ -0,0 +1,109 @@
<template>
<NcAppNavigationItem
:loading="loading"
:name="feed ? feed.title : url"
:to="hash">
<template #actions>
<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
v-if="feed"
:display-name="feed.author || feed.title"
:is-no-user="true"
:url="feed.imageUrl" />
<AlertIcon v-if="failed" />
</template>
</NcAppNavigationItem>
</template>
<script>
import { NcActionButton, NcAppNavigationItem, NcAvatar } from '@nextcloud/vue'
import AlertIcon from 'vue-material-design-icons/Alert.vue'
import DeleteIcon from 'vue-material-design-icons/Delete.vue'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { mapActions } from 'pinia'
import { showError } from '../../utils/toast.js'
import { toUrl } from '../../utils/url.js'
import { useSubscriptions } from '../../store/subscriptions.js'
export default {
name: 'Item',
components: {
AlertIcon,
DeleteIcon,
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: {
...mapActions(useSubscriptions, ['fetch']),
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.fetch()
}
}
},
},
}
</script>

View File

@ -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>

View File

@ -2,7 +2,7 @@
<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" /> <PlusIcon :size="20" />
@ -11,14 +11,10 @@
</router-link> </router-link>
<Loading v-if="loading" /> <Loading v-if="loading" />
<NcAppNavigationList v-if="!loading"> <NcAppNavigationList v-if="!loading">
<Subscription <Item
v-for="sub of subs.filter((sub) => sub.isFavorite)" v-for="subscriptionUrl of subscriptions"
:key="sub.metrics.url" :key="subscriptionUrl"
:url="sub.metrics.url" /> :url="subscriptionUrl" />
<Subscription
v-for="sub of subs.filter((sub) => !sub.isFavorite)"
:key="sub.metrics.url"
:url="sub.metrics.url" />
</NcAppNavigationList> </NcAppNavigationList>
</NcAppContentList> </NcAppContentList>
</template> </template>
@ -28,7 +24,7 @@
</AppNavigation> </AppNavigation>
</template> </template>
<script lang="ts"> <script>
import { import {
NcAppContentList, NcAppContentList,
NcAppNavigationList, NcAppNavigationList,
@ -36,31 +32,32 @@ import {
} from '@nextcloud/vue' } from '@nextcloud/vue'
import { mapActions, mapState } from 'pinia' 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 PlusIcon from 'vue-material-design-icons/Plus.vue'
import Settings from '../Settings/Settings.vue' import Settings from '../Settings/Settings.vue'
import Subscription from './Subscription.vue' import { showError } from '../../utils/toast.js'
import { showError } from '../../utils/toast.ts' import { useSubscriptions } from '../../store/subscriptions.js'
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, NcAppNavigationList,
NcAppNavigationNew, NcAppNavigationNew,
PlusIcon, PlusIcon,
Settings, Settings,
Subscription,
}, },
data: () => ({ data() {
return {
loading: true, loading: true,
}), }
},
computed: { computed: {
...mapState(useSubscriptions, ['subs']), ...mapState(useSubscriptions, ['subscriptions']),
}, },
async mounted() { async mounted() {
try { try {
@ -74,7 +71,6 @@ export default {
}, },
methods: { methods: {
...mapActions(useSubscriptions, ['fetch']), ...mapActions(useSubscriptions, ['fetch']),
t,
}, },
} }
</script> </script>

View File

@ -1,12 +1,13 @@
import '@formatjs/intl-segmenter/polyfill' import { n, t } from '@nextcloud/l10n'
import App from './App.vue' import App from './App.vue'
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import router from './router.ts' import router from './router.js'
const Vue = createApp(App) const Vue = createApp(App)
const pinia = createPinia() const pinia = createPinia()
Vue.mixin({ methods: { t, n } })
Vue.use(pinia) Vue.use(pinia)
Vue.use(router) Vue.use(router)
Vue.mount('#content') Vue.mount('#content')

View File

@ -1,22 +1,17 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import Discover from './views/Discover.vue' import Discover from './views/Discover.vue'
import Feed from './views/Feed.vue' import Feed from './views/Feed.vue'
import Home from './views/Home.vue'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
const router = createRouter({ const router = createRouter({
history: createWebHistory(generateUrl('apps/repod')), history: createWebHashHistory(generateUrl('apps/repod')),
routes: [ routes: [
{ {
path: '/', path: '/',
component: Home,
},
{
path: '/discover',
component: Discover, component: Discover,
}, },
{ {
path: '/feed/:url', path: '/:url',
component: Feed, component: Feed,
}, },
], ],

View File

@ -1,32 +1,24 @@
import type { EpisodeActionInterface, EpisodeInterface } from '../utils/types.ts'
import { getCookie, setCookie } from '../utils/cookies.ts'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { formatEpisodeTimestamp } from '../utils/time.ts' import { formatEpisodeTimestamp } from '../utils/time.js'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { showError } from '../utils/toast.ts'
import { t } from '@nextcloud/l10n'
const audio = new Audio() const audio = new Audio()
export const usePlayer = defineStore('player', { export const usePlayer = defineStore('player', {
state: () => ({ state: () => ({
currentTime: null as number | null, currentTime: null,
duration: null as number | null, duration: null,
episode: null as EpisodeInterface | null, episode: null,
loaded: false, loaded: false,
paused: true, paused: null,
playCount: 0, podcastUrl: null,
podcastUrl: null as string | null,
volume: 1, volume: 1,
rate: 1, rate: 1,
started: 0, started: 0,
}), }),
actions: { actions: {
init() { init() {
audio.playbackRate = parseFloat(getCookie('repod.rate') || '1')
audio.volume = parseFloat(getCookie('repod.volume') || '1')
audio.ondurationchange = () => (this.duration = audio.duration) audio.ondurationchange = () => (this.duration = audio.duration)
audio.onended = () => this.stop() audio.onended = () => this.stop()
audio.onloadeddata = () => (this.loaded = true) audio.onloadeddata = () => (this.loaded = true)
@ -36,38 +28,28 @@ export const usePlayer = defineStore('player', {
audio.onseeked = () => (this.currentTime = audio.currentTime) audio.onseeked = () => (this.currentTime = audio.currentTime)
audio.ontimeupdate = () => (this.currentTime = audio.currentTime) audio.ontimeupdate = () => (this.currentTime = audio.currentTime)
audio.onvolumechange = () => (this.volume = audio.volume) audio.onvolumechange = () => (this.volume = audio.volume)
setInterval(this.act, 40000)
setInterval(this.conflict, 1000)
}, },
act() { async load(episode, podcastUrl) {
if (this.paused === false) {
this.time()
}
},
conflict() {
this.playCount = 0
},
async load(episode: EpisodeInterface | null, podcastUrl?: string) {
this.episode = episode this.episode = episode
this.podcastUrl = podcastUrl || null this.podcastUrl = podcastUrl
if (this.episode?.url) { if (this.episode) {
audio.src = this.episode.url audio.src = this.episode.url
audio.load() audio.load()
try { try {
const action = await axios.get<EpisodeActionInterface>( const action = await axios.get(
generateUrl('/apps/repod/episodes/action?url={url}', { generateUrl('/apps/repod/episodes/action?url={url}', {
url: this.episode.url, url: this.episode.url,
}), }),
) )
this.episode.action = action.data this.episode.action = action
} catch {} } catch {}
if ( if (
this.episode.action && this.episode.action &&
this.episode.action.position &&
this.episode.action.position < this.episode.action.total this.episode.action.position < this.episode.action.total
) { ) {
audio.currentTime = this.episode.action.position audio.currentTime = this.episode.action.position
@ -87,17 +69,11 @@ export const usePlayer = defineStore('player', {
this.time() this.time()
}, },
play() { play() {
this.playCount++
if (this.playCount > 10) {
showError(t('repod', 'A browser extension conflict with RePod'))
} else {
audio.play() audio.play()
this.paused = false this.paused = false
this.started = audio.currentTime this.started = audio.currentTime
}
}, },
seek(currentTime: number) { seek(currentTime) {
audio.currentTime = currentTime audio.currentTime = currentTime
this.time() this.time()
}, },
@ -106,10 +82,6 @@ export const usePlayer = defineStore('player', {
this.episode = null this.episode = null
}, },
time() { time() {
if (!this.podcastUrl || !this.episode?.url) {
return
}
this.episode.action = { this.episode.action = {
podcast: this.podcastUrl, podcast: this.podcastUrl,
episode: this.episode.url, episode: this.episode.url,
@ -120,18 +92,15 @@ export const usePlayer = defineStore('player', {
position: Math.round(audio.currentTime), position: Math.round(audio.currentTime),
total: Math.round(audio.duration), total: Math.round(audio.duration),
} }
axios.post(generateUrl('/apps/gpoddersync/episode_action/create'), [ axios.post(generateUrl('/apps/gpoddersync/episode_action/create'), [
this.episode.action, this.episode.action,
]) ])
}, },
setVolume(volume: number) { setVolume(volume) {
audio.volume = volume audio.volume = volume
setCookie('repod.volume', volume.toString(), 365)
}, },
setRate(rate: number) { setRate(rate) {
audio.playbackRate = rate audio.playbackRate = rate
setCookie('repod.rate', rate.toString(), 365)
}, },
}, },
}) })

18
src/store/settings.js Normal file
View File

@ -0,0 +1,18 @@
import { defineStore } from 'pinia'
import { setCookie } from '../utils/cookies.js'
export const useSettings = defineStore('settings', {
state: () => ({
filters: {
listened: true,
listening: true,
unlistened: true,
},
}),
actions: {
setFilters(filters) {
this.filters = { ...this.filters, ...filters }
setCookie('repod.filters', JSON.stringify(this.filters), 365)
},
},
})

View File

@ -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)
},
},
})

View File

@ -0,0 +1,20 @@
import axios from '@nextcloud/axios'
import { defineStore } from 'pinia'
import { generateUrl } from '@nextcloud/router'
export const useSubscriptions = defineStore('subscriptions', {
state: () => ({
subscriptions: [],
}),
actions: {
async fetch() {
const metrics = await axios.get(
generateUrl('/apps/gpoddersync/personal_settings/metrics'),
)
const subs = [...metrics.data.subscriptions].sort(
(a, b) => b.listenedSeconds - a.listenedSeconds,
)
this.subscriptions = subs.map((sub) => sub.url)
},
},
})

View File

@ -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,
)
},
},
})

View File

@ -4,7 +4,7 @@
* @param {string} name Nom du cookie à récupérer * @param {string} name Nom du cookie à récupérer
* @return {string|null} * @return {string|null}
*/ */
export const getCookie = (name: string): string | null => { export const getCookie = (name) => {
const cookies = document.cookie.split('; ') const cookies = document.cookie.split('; ')
const value = cookies.find((c) => c.startsWith(name + '='))?.split('=')[1] const value = cookies.find((c) => c.startsWith(name + '='))?.split('=')[1]
if (value === undefined) { if (value === undefined) {
@ -19,7 +19,7 @@ export const getCookie = (name: string): string | null => {
* @param {string} value Value du cookie * @param {string} value Value du cookie
* @param {number} days Durée de vie du cookie (en jours) * @param {number} days Durée de vie du cookie (en jours)
*/ */
export const setCookie = (name: string, value: string, days: number) => { export const setCookie = (name, value, days) => {
const date = new Date() const date = new Date()
date.setDate(date.getDate() + days) date.setDate(date.getDate() + days)
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${date.toUTCString()}; SameSite=Strict;` document.cookie = `${name}=${encodeURIComponent(value)}; expires=${date.toUTCString()}; SameSite=Strict;`

12
src/utils/debounce.js Normal file
View 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)
}
}

View File

@ -1,8 +1,8 @@
// https://stackoverflow.com/a/20732091 // https://stackoverflow.com/a/20732091
export const humanFileSize = (size: number) => { export const humanFileSize = (size) => {
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)) const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024))
return ( return (
(size / Math.pow(1024, i)).toFixed(2) + (size / Math.pow(1024, i)).toFixed(2) * 1 +
' ' + ' ' +
['B', 'kB', 'MB', 'GB', 'TB'][i] ['B', 'kB', 'MB', 'GB', 'TB'][i]
) )

View File

@ -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)

View File

@ -3,9 +3,9 @@
* @param {Date} date The date * @param {Date} date The date
* @return {string} * @return {string}
*/ */
export const formatTimer = (date: Date): string => { export const formatTimer = (date) => {
const minutes = date.getUTCMinutes().toString().padStart(2, '0') const minutes = date.getUTCMinutes().toString().padStart(2, 0)
const seconds = date.getUTCSeconds().toString().padStart(2, '0') const seconds = date.getUTCSeconds().toString().padStart(2, 0)
let timer = `${minutes}:${seconds}` let timer = `${minutes}:${seconds}`
if (date.getUTCHours()) { if (date.getUTCHours()) {
@ -20,7 +20,7 @@ export const formatTimer = (date: Date): string => {
* @param {Date} date The date * @param {Date} date The date
* @return {string} * @return {string}
*/ */
export const formatEpisodeTimestamp = (date: Date): string => { export const formatEpisodeTimestamp = (date) => {
const year = date.getFullYear() const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0') const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0') const day = date.getDate().toString().padStart(2, '0')
@ -36,7 +36,7 @@ export const formatEpisodeTimestamp = (date: Date): string => {
* @param {Date} date The date * @param {Date} date The date
* @return {string} * @return {string}
*/ */
export const formatLocaleDate = (date: Date): string => export const formatLocaleDate = (date) =>
date.toLocaleDateString(undefined, { dateStyle: 'medium' }) date.toLocaleDateString(undefined, { dateStyle: 'medium' })
/** /**
@ -44,23 +44,10 @@ export const formatLocaleDate = (date: Date): string =>
* @param {string} duration The duration feed's entry * @param {string} duration The duration feed's entry
* @return {number} * @return {number}
*/ */
export const durationToSeconds = (duration: string): number => { export const durationToSeconds = (duration) => {
const splitDuration = duration.split(':').reverse() const splitDuration = duration.split(':').reverse()
let seconds = parseInt(splitDuration[0]) let seconds = parseInt(splitDuration[0])
seconds += splitDuration.length > 1 ? parseInt(splitDuration[1]) * 60 : 0 seconds += splitDuration.length > 1 ? parseInt(splitDuration[1]) * 60 : 0
seconds += splitDuration.length > 2 ? parseInt(splitDuration[2]) * 60 * 60 : 0 seconds += splitDuration.length > 2 ? parseInt(splitDuration[2]) * 60 * 60 : 0
return seconds 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

12
src/utils/toast.js Normal file
View File

@ -0,0 +1,12 @@
import toastify from 'toastify-js'
export const showMessage = (text, backgroundColor) =>
toastify({
text,
backgroundColor,
}).showToast()
export const showError = (text) => showMessage(text, 'var(--color-error)')
export const showWarning = (text) => showMessage(text, 'var(--color-warning)')
export const showInfo = (text) => showMessage(text, 'var(--color-primary)')
export const showSuccess = (text) => showMessage(text, 'var(--color-success)')

View File

@ -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)')

View File

@ -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
}

7
src/utils/url.js Normal file
View File

@ -0,0 +1,7 @@
export const encodeUrl = (url) => encodeURIComponent(btoa(url))
export const decodeUrl = (url) => atob(decodeURIComponent(url))
export const toUrl = (url) => `/${encodeUrl(url)}`
export const filenameFromUrl = (str) => {
const url = new URL(str)
return url.pathname.split('/').pop()
}

View File

@ -1,5 +0,0 @@
export const encodeUrl = (url: string) => encodeURIComponent(btoa(url))
export const decodeUrl = (url: string) => atob(decodeURIComponent(url))
export const toFeedUrl = (url: string) => `/feed/${encodeUrl(url)}`
export const filenameFromUrl = (url: string) =>
new URL(url).pathname.split('/').pop()

View File

@ -1,5 +1,5 @@
<template> <template>
<AppContent class="padding"> <AppContent class="main">
<NcTextField v-model="search" :label="t('repod', 'Find a podcast')"> <NcTextField v-model="search" :label="t('repod', 'Find a podcast')">
<template #icon> <template #icon>
<Magnify :size="20" /> <Magnify :size="20" />
@ -12,14 +12,13 @@
</AppContent> </AppContent>
</template> </template>
<script lang="ts"> <script>
import AddRss from '../components/Discover/AddRss.vue' import AddRss from '../components/Discover/AddRss.vue'
import AppContent from '../components/Atoms/AppContent.vue' import AppContent from '../components/Atoms/AppContent.vue'
import Magnify from 'vue-material-design-icons/Magnify.vue' import Magnify from 'vue-material-design-icons/Magnify.vue'
import { NcTextField } from '@nextcloud/vue' import { NcTextField } from '@nextcloud/vue'
import Search from '../components/Discover/Search.vue' import Search from '../components/Discover/Search.vue'
import Toplist from '../components/Discover/Toplist.vue' import Toplist from '../components/Discover/Toplist.vue'
import { t } from '@nextcloud/l10n'
export default { export default {
name: 'Discover', name: 'Discover',
@ -31,17 +30,16 @@ export default {
Search, Search,
Toplist, Toplist,
}, },
data: () => ({ data() {
return {
search: '', search: '',
}), }
methods: {
t,
}, },
} }
</script> </script>
<style scoped> <style scoped>
.padding { .main {
padding: 15px 51px; padding: 15px 51px;
} }
</style> </style>

View File

@ -1,28 +1,35 @@
<template> <template>
<AppContent> <AppContent>
<Loading v-if="loading" /> <Loading v-if="loading" />
<EmptyContent v-if="failed" :name="t('repod', 'Error loading feed')"> <NcEmptyContent
v-if="failed"
class="error"
:name="t('repod', 'Error loading feed')">
<template #icon> <template #icon>
<Alert /> <Alert />
</template> </template>
</EmptyContent> </NcEmptyContent>
<Banner v-if="feed" :feed="feed" /> <Banner
v-if="feed"
:author="feed.author"
:description="feed.description"
:image-url="feed.imageUrl"
:link="feed.link"
:title="feed.title" />
<Episodes v-if="feed" /> <Episodes v-if="feed" />
</AppContent> </AppContent>
</template> </template>
<script lang="ts"> <script>
import Alert from 'vue-material-design-icons/Alert.vue' import Alert from 'vue-material-design-icons/Alert.vue'
import AppContent from '../components/Atoms/AppContent.vue' import AppContent from '../components/Atoms/AppContent.vue'
import Banner from '../components/Feed/Banner.vue' import Banner from '../components/Feed/Banner.vue'
import EmptyContent from '../components/Atoms/EmptyContent.vue'
import Episodes from '../components/Feed/Episodes.vue' import Episodes from '../components/Feed/Episodes.vue'
import Loading from '../components/Atoms/Loading.vue' import Loading from '../components/Atoms/Loading.vue'
import type { PodcastDataInterface } from '../utils/types.ts' import { NcEmptyContent } from '@nextcloud/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 { t } from '@nextcloud/l10n'
export default { export default {
name: 'Feed', name: 'Feed',
@ -30,24 +37,25 @@ export default {
Alert, Alert,
AppContent, AppContent,
Banner, Banner,
EmptyContent,
Episodes, Episodes,
Loading, Loading,
NcEmptyContent,
}, },
data: () => ({ data() {
return {
failed: false, failed: false,
loading: true, loading: true,
feed: null as PodcastDataInterface | null, feed: null,
}), }
},
computed: { computed: {
url() { url() {
return decodeUrl(this.$route.params.url as string) return decodeUrl(this.$route.params.url)
}, },
}, },
async mounted() { async mounted() {
try { try {
this.loading = true const podcastData = await axios.get(
const podcastData = await axios.get<PodcastDataInterface>(
generateUrl('/apps/repod/podcast?url={url}', { url: this.url }), generateUrl('/apps/repod/podcast?url={url}', { url: this.url }),
) )
this.feed = podcastData.data this.feed = podcastData.data
@ -58,8 +66,11 @@ export default {
this.loading = false this.loading = false
} }
}, },
methods: {
t,
},
} }
</script> </script>
<style scoped>
.error {
margin: 2rem;
}
</style>

View File

@ -1,6 +1,6 @@
<template> <template>
<AppContent> <NcAppContent class="content">
<EmptyContent :name="t('repod', 'Missing required app')"> <NcEmptyContent :name="t('repod', 'Missing required app')">
<template #action> <template #action>
<NcButton :href="gPodderSyncUrl"> <NcButton :href="gPodderSyncUrl">
{{ t('repod', 'Install GPodder Sync') }} {{ t('repod', 'Install GPodder Sync') }}
@ -9,33 +9,27 @@
<template #icon> <template #icon>
<Alert /> <Alert />
</template> </template>
</EmptyContent> </NcEmptyContent>
</AppContent> </NcAppContent>
</template> </template>
<script lang="ts"> <script>
import { NcAppContent, NcButton, NcEmptyContent } from '@nextcloud/vue'
import Alert from 'vue-material-design-icons/Alert.vue' import Alert from 'vue-material-design-icons/Alert.vue'
import AppContent from '../components/Atoms/AppContent.vue'
import EmptyContent from '../components/Atoms/EmptyContent.vue'
import { NcButton } from '@nextcloud/vue'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { t } from '@nextcloud/l10n'
export default { export default {
name: 'GPodder', name: 'GPodder',
components: { components: {
Alert, Alert,
AppContent, NcAppContent,
EmptyContent,
NcButton, NcButton,
NcEmptyContent,
}, },
computed: { computed: {
gPodderSyncUrl() { gPodderSyncUrl() {
return generateUrl('/settings/apps/installed/gpoddersync') return generateUrl('/settings/apps/installed/gpoddersync')
}, },
}, },
methods: {
t,
},
} }
</script> </script>

View File

@ -1,55 +0,0 @@
<template>
<AppContent>
<EmptyContent
v-if="!favorites.length"
class="padding"
:description="
t('repod', 'Pin some subscriptions to see their latest updates')
"
:name="t('repod', 'No favorites')">
<template #icon>
<StarOffIcon />
</template>
</EmptyContent>
<ul v-if="favorites.length">
<li v-for="favorite in favorites" :key="favorite.metrics.url">
<Favorite :feed="favorite" />
</li>
</ul>
</AppContent>
</template>
<script lang="ts">
import AppContent from '../components/Atoms/AppContent.vue'
import EmptyContent from '../components/Atoms/EmptyContent.vue'
import Favorite from '../components/Feed/Favorite.vue'
import StarOffIcon from 'vue-material-design-icons/StarOff.vue'
import { mapState } from 'pinia'
import { t } from '@nextcloud/l10n'
import { useSubscriptions } from '../store/subscriptions.ts'
export default {
name: 'Home',
components: {
AppContent,
EmptyContent,
Favorite,
StarOffIcon,
},
computed: {
...mapState(useSubscriptions, ['subs']),
favorites() {
return this.subs.filter((sub) => sub.isFavorite)
},
},
methods: {
t,
},
}
</script>
<style scoped>
.padding {
padding: 15px 51px;
}
</style>

View File

@ -8,9 +8,7 @@ class EpisodeActionReader
{ {
/** /**
* @param array $episodeActionsArray [] * @param array $episodeActionsArray []
*
* @return EpisodeAction[] * @return EpisodeAction[]
*
* @throws \InvalidArgumentException * @throws \InvalidArgumentException
*/ */
public function fromArray(array $episodeActionsArray) {} public function fromArray(array $episodeActionsArray) {}

View File

@ -29,7 +29,6 @@ class PodcastData implements \JsonSerializable
/** /**
* @return PodcastData * @return PodcastData
*
* @throws \Exception if the XML data could not be parsed * @throws \Exception if the XML data could not be parsed
*/ */
public static function parseRssXml(string $xmlString, ?int $fetchedAtUnix = null) {} public static function parseRssXml(string $xmlString, ?int $fetchedAtUnix = null) {}
@ -83,7 +82,6 @@ class PodcastData implements \JsonSerializable
/** /**
* @param PodcastDataType $data * @param PodcastDataType $data
*
* @return PodcastData * @return PodcastData
*/ */
public static function fromArray(array $data) {} public static function fromArray(array $data) {}

View File

@ -6,9 +6,7 @@ namespace OCA\GPodderSync\Core\SubscriptionChange;
class SubscriptionChangeRequestParser class SubscriptionChangeRequestParser
{ {
public function __construct( public function __construct(private SubscriptionChangesReader $subscriptionChangeReader) {}
private SubscriptionChangesReader $subscriptionChangeReader
) {}
/** /**
* @return SubscriptionChange[] * @return SubscriptionChange[]

View File

@ -21,7 +21,6 @@ class EpisodeActionMapper extends QBMapper
/** /**
* @return EpisodeActionEntity[] * @return EpisodeActionEntity[]
*
* @throws Exception * @throws Exception
*/ */
public function findAll(int $sinceTimestamp, string $userId) {} public function findAll(int $sinceTimestamp, string $userId) {}

View File

@ -8,9 +8,7 @@ use OCA\GPodderSync\Core\EpisodeAction\EpisodeAction;
class EpisodeActionRepository class EpisodeActionRepository
{ {
public function __construct( public function __construct(private EpisodeActionMapper $episodeActionMapper) {}
private EpisodeActionMapper $episodeActionMapper
) {}
/** /**
* @return EpisodeAction[] * @return EpisodeAction[]

View File

@ -8,20 +8,16 @@ use OCP\DB\Exception;
class EpisodeActionWriter class EpisodeActionWriter
{ {
public function __construct( public function __construct(private EpisodeActionMapper $episodeActionMapper) {}
private EpisodeActionMapper $episodeActionMapper
) {}
/** /**
* @return EpisodeActionEntity * @return EpisodeActionEntity
*
* @throws Exception * @throws Exception
*/ */
public function save(EpisodeActionEntity $episodeActionEntity) {} public function save(EpisodeActionEntity $episodeActionEntity) {}
/** /**
* @return EpisodeActionEntity * @return EpisodeActionEntity
*
* @throws Exception * @throws Exception
*/ */
public function update(EpisodeActionEntity $episodeActionEntity) {} public function update(EpisodeActionEntity $episodeActionEntity) {}

View File

@ -6,9 +6,7 @@ namespace OCA\GPodderSync\Db\SubscriptionChange;
class SubscriptionChangeRepository class SubscriptionChangeRepository
{ {
public function __construct( public function __construct(private SubscriptionChangeMapper $subscriptionChangeMapper) {}
private SubscriptionChangeMapper $subscriptionChangeMapper
) {}
/** /**
* @return SubscriptionChangeEntity[] * @return SubscriptionChangeEntity[]

View File

@ -6,9 +6,7 @@ namespace OCA\GPodderSync\Db\SubscriptionChange;
class SubscriptionChangeWriter class SubscriptionChangeWriter
{ {
public function __construct( public function __construct(private SubscriptionChangeMapper $subscriptionChangeMapper) {}
private SubscriptionChangeMapper $subscriptionChangeMapper
) {}
public function purge(): void {} public function purge(): void {}

Some files were not shown because too many files have changed in this diff Show More